Skip to content

mdcode: add Semantic Model IR and model loader - #258

Merged
libei merged 1 commit into
GoogleCloudPlatform:mainfrom
libei:upstream-pr1-semantic-ir-loader
Aug 4, 2026
Merged

mdcode: add Semantic Model IR and model loader#258
libei merged 1 commit into
GoogleCloudPlatform:mainfrom
libei:upstream-pr1-semantic-ir-loader

Conversation

@libei

@libei libei commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

First of a capability-layered series merging the semantic-model tooling from the libei/knowledge-catalog fork back into upstream. This PR is the foundation the rest of the series builds on; it has no downstream dependencies.

What this PR adds

A destination-agnostic Semantic Model IR and a loader for the Open Semantic Interchange (OSI) / Apache Ossie format.

  • src/libts/semantic/ir.ts — the in-memory contract: a graph of entities (nodes) and relationships (edges) with model-level metrics, AI-first annotations (ai_context), and verbatim vendor custom_extensions for round-trip fidelity. datatype is a closed, case-sensitive vocabulary mirroring Ossie's DataType.
  • src/libts/semantic/loader.ts — parses OSI YAML/JSON into the IR. zod schemas form the faithful parse layer; the IR is the normalized/lowered layer (dialect selection, physical-source FQN normalization, ai_context normalization, metric-entity inference). Fail-loud on off-vocabulary datatypes; warns (never silently drops) on soft issues such as missing primary keys or unplaceable metrics.
  • src/libts/semantic/sql_expr_utils.ts — literal-aware helpers for the entity-qualified SQL expressions used by metric-entity inference and qualifier stripping. Ignores text inside string literals and recognizes both bare and BigQuery backtick-quoted qualifiers.
  • Fixtures + tests — real OSI models (TPC-DS and a star schema), a multi-dialect model exercising expression selection across ANSI / Snowflake / Databricks, and models carrying vendor custom_extensions blocks (Databricks, DBT, Google, Salesforce). Fixtures are validated against the vendored OSI JSON Schema (via ajv), plus loader unit tests.
  • package.json — adds ajv (JSON Schema validation for the fixtures) and a test:semantic script wired into test; the package-lock.json churn is the transitive closure of that one dependency.

Scope

IR + loader only. Downstream consumers — BigQuery property-graph DDL generation, the CLI push path, and Knowledge Catalog emit/pull — follow in later PRs in the series.

Testing

  • npx tsc --noEmit — clean
  • bun test tests/libts/semantic/ — 67 pass / 0 fail

@libei

libei commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

@ChrisCChan @dmitrylychagin — requesting your review on this one. Fork permissions block a formal review request, so flagging you here. This is the first of a capability-layered series (IR + loader; downstream BigQuery/CLI/KC consumers follow in later PRs).

@libei

libei commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

@amirhormati — adding you as a reviewer on this one (fork permissions block a formal review request, so flagging you here). First of a capability-layered series: Semantic Model IR + OSI loader; downstream BigQuery/CLI/KC consumers follow in later PRs.

@libei
libei force-pushed the upstream-pr1-semantic-ir-loader branch from 3f7a7cd to b34ae38 Compare August 2, 2026 06:47
@libei
libei marked this pull request as ready for review August 2, 2026 06:47
@amirhormati

Copy link
Copy Markdown
Collaborator

What is the plan to support properties on edges?

@amirhormati
amirhormati self-requested a review August 3, 2026 17:29
datatype: Decimal
expression:
dialects:
- dialect: ANSI_SQL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be ANSO_SQL or GOOGLE_SQL? I know GOOGLE_SQL is not part of the standard but we should probably show out best case here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Switched to BigQuery

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note each engine support GOOGLE SQL differently (different subsets). So we use the product name here.

@libei

libei commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

What is the plan to support properties on edges?

Good question. This PR mostly focusing on compatibility with OSI. I will add properties support in the BQ Graph publication PR (coming soon)

@libei
libei force-pushed the upstream-pr1-semantic-ir-loader branch from b34ae38 to d015f89 Compare August 3, 2026 18:00
Comment thread toolbox/mdcode/src/libts/semantic/ir.ts Outdated
name: string;
// Fully-qualified, normalized physical source: a dotted table reference
// (`project.dataset.table`, or a four-part Lakehouse catalog name
// `project.catalog.namespace.table`), or a verbatim query. The loader

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit. let' remove "or a verbatim query" for now

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done -- dropped the phrase from the comment. (The loader still passes a non-table source through unchanged if one shows up, but the IR contract no longer advertises it.)

// `project.catalog.namespace.table`), or a verbatim query. The loader
// produces it: identifiers are unquoted and a missing project/dataset is
// filled from options; four-plus-part names are passed through untouched.
dataSource: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the value needs to be a resource id, not 3/4 part name. //bigquery.googleapis.com/projects/{PROJECT_ID}/datasets/{DATASET_ID}/tables/{TABLE_ID}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IR is meant to stay destination-agnostic, so it holds the normalized project.dataset.table (or 4-part Lakehouse name). The //bigquery.googleapis.com/... resource id is a BigQuery/Dataplex-specific encoding -- I'd rather produce it at the push/emit layer that already knows the target than bake a BigQuery URI into the shared IR. Keeping the dotted form here for PR1; happy to revisit if you'd prefer the resource id live in the IR itself.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworked the comment so it no longer prescribes a 3/4-part shape. The IR now treats dataSource as an opaque, fully-qualified identifier -- it fixes neither a syntax (separators, number of parts, quoting) nor a naming scheme; that's whatever the source system uses. The BigQuery resource-id form (//bigquery.googleapis.com/projects/{PROJECT_ID}/datasets/{DATASET_ID}/tables/{TABLE_ID}) is a target-specific encoding, so I'd produce it in the push/emit layer that already knows it's targeting BigQuery rather than bake a BigQuery URI into the shared, destination-agnostic IR. Updated in the latest push.

Comment thread toolbox/mdcode/src/libts/semantic/ir.ts Outdated
// that node; several: a cross-entity metric whose join path consumers resolve
// via the model's relationships. It MAY be empty when the expression names no
// entity (e.g. `COUNT(*)`); such a metric is not placeable and the loader warns.
entities: string[];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we have an array here? I believe, it should be just one entity

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A metric expression can reference columns from more than one entity (e.g. across a join), so the list is the general form. When it references a single entity, that entity is the attach point and the expression doesn't need to repeat the qualifier; when it spans several, the expression has to name each entity inline anyway. So the array covers both cases -- single-entity is just the common one. Kept as an array.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with your instinct after another look -- changed it to a singular optional entity?: string (dropped the array in the latest push). The loader sets it only when the expression references exactly one entity (the node the metric attaches to). A cross-entity metric leaves it undefined and keeps its qualifiers inline in the expression; a COUNT(*)-style metric that names no entity also leaves it undefined (with the existing "references no known entity" warning).

warnings.push(`${ctx}: 'to' dataset '${r.to}' is not defined in the model`);
}
if (r.from_columns.length !== r.to_columns.length) {
warnings.push(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this a warning? should it be an error instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed -- now a hard error. An endpoint that isn't declared in the model throws instead of warning.

warnings.push(`${ctx}: 'from' dataset '${r.from}' is not defined in the model`);
}
if (!keysByEntity.has(r.to)) {
warnings.push(`${ctx}: 'to' dataset '${r.to}' is not defined in the model`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should it be an error?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes -- converted to a hard error too. A structurally invalid edge shouldn't pass silently.

warnings: string[]): Relationship {
const ctx = `relationship '${r.name}'`;
const fromKeys = keysByEntity.get(r.from);
if (fromKeys === undefined) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should require that fromColumns are defined. It should be an error if fromColumns are missing or empty array. fromKeys are not used in relationships.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. from_columns/to_columns are now required (zod .min(1)), and mismatched arity is a hard error. fromKeys is gone from relationships -- the source entity's own key is looked up from the entity, not duplicated on the edge.

Comment thread toolbox/mdcode/src/libts/semantic/ir.ts Outdated
name: string;
source: RelationshipEnd;
destination: RelationshipEnd;
dataSource?: string; // association/edge table (FQN); absent => direct FK join

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dataSource and keys fields are never set by OSI at this point. should we remove them for now?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done -- removed dataSource, keys (and fields) from Relationship. It's now a pure direct-FK edge: source/destination + their join columns. Edge-table / M:N support will return in the BigQuery-graph PR, where the generator actually consumes it and I can model the endpoints properly.

}

// Fall back to the FK columns when the from dataset declares no primary key.
const sourceKey = fromKeys && fromKeys.length ? fromKeys : r.from_columns;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems confusing. we need to decide whether we need sourceKey at all or not. OSI model does not use it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed -- removed. The relationship no longer carries a source key; source/destination each hold only their join columns. A consumer that needs the source entity's PK looks it up from the entity.

name: r.name,
source: {
entity: r.from,
joinKeys: { relationshipColumns: sourceKey, entityColumns: sourceKey },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think joinKeys is needed here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done -- joinKeys is gone. RelationshipEnd is now just { entity, columns }.

@libei
libei force-pushed the upstream-pr1-semantic-ir-loader branch 3 times, most recently from afe1aa1 to f799a42 Compare August 4, 2026 02:56
Introduce the semantic-model subsystem foundation for the mdcode toolbox:

- ir.ts: the Semantic Model IR contract (M0) — a pure, destination-
  agnostic representation of tables, dimensions, measures, joins and
  graph structure.
- expr.ts: shared expression helpers used across the IR front-end.
- loader.ts: parse AI-first semantic-model files (YAML) into the IR,
  validated with zod.
- tests/libts/semantic/loader.test.ts: behaviour-driven loader tests.
- package.json: add a `test:semantic` script and run it under `test`.

This is additive and self-contained; no existing files change behaviour.
Downstream emitters (BigQuery DDL, Knowledge Catalog) build on this IR
in follow-up changes.
@libei
libei force-pushed the upstream-pr1-semantic-ir-loader branch from f799a42 to 2259c10 Compare August 4, 2026 02:58
@libei
libei merged commit 599a240 into GoogleCloudPlatform:main Aug 4, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants