Skip to content

feat: support dataset-scoped metrics - #343

Open
jklahr wants to merge 6 commits into
apache:mainfrom
jklahr:add-data-set-scoped-metrics
Open

feat: support dataset-scoped metrics#343
jklahr wants to merge 6 commits into
apache:mainfrom
jklahr:add-data-set-scoped-metrics

Conversation

@jklahr

@jklahr jklahr commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds dataset-scoped metrics: a metric may be declared on an individual dataset (datasets[].metrics) when its expression aggregates data held by that dataset, alongside the existing model-scoped placement (semantic_model.metrics). Both placements use the identical metric structure; only the resolution rules differ.

Backward compatible. datasets[].metrics is optional and absent from every existing model, and semantic_model.metrics is unchanged. No breaking changes to the spec.

This revision incorporates review from @jbonofre, @christianeu-db and @khush-bhatia. Two things changed materially as a result:

  • Expression namespace is now unqualified. A dataset-scoped metric writes SUM(amount), not SUM(orders.amount), matching how a field's own expression is written. Adopted from @christianeu-db.
  • Name-collision rules are split into errors and warnings rather than all being errors, and the one warning is attributed to the model rather than the dataset.

Detailed responses to every review comment are in a separate comment on this PR.

Related Issues

Discussed on dev@ossie.apache.org ([DISCUSS] Dataset-scoped metrics). Related: #287 (extended metadata, proposes hidden), #342 (shared filters, dimensions and metric references, where metric-to-metric references arise from a different direction).

Scoping rules

Model-scoped (semantic_model.metrics) Dataset-scoped (datasets[].metrics)
Expression may reference fields from Any dataset in the model Only its own dataset
Expression namespace Qualified: dataset.field Unqualified: field
Aggregation grain Determined by the expression The declaring dataset's grain
Name uniqueness Unique across the model Unique within its dataset, and distinct from that dataset's field names
Referenced as metric_name dataset_name.metric_name
  1. A dataset-scoped metric's expression MUST aggregate only fields of the dataset that declares it, and MUST NOT reference a field of another dataset. Model-scoped metrics carry no equivalent restriction.
  2. A dataset-scoped metric's expression MUST reference fields by unqualified name.
  3. Dataset-scoped metric names MUST be unique within their dataset. Two datasets MAY each declare a metric with the same name.
  4. A dataset-scoped metric name MUST NOT collide with a field name of the same dataset.
  5. A model-scoped metric SHOULD NOT reuse the name of a dataset-scoped metric in the same model. Warning, not error.
  6. An unqualified metric reference resolves to a model-scoped metric. A dataset-scoped metric is referenced as dataset_name.metric_name.

Scope describes the aggregation, not the query. A dataset-scoped metric aggregates data held by its own dataset. That is all the placement asserts. It does not limit how the metric may be queried: it is joined and grouped like any other metric, using the relationships declared in the model, so it can be sliced by dimensions of any dataset the model connects.

Fields, not source columns. Rule 1 means declared fields, not any column the source exposes. Rule 1 places no limit on the complexity of the aggregation.

Warn vs error, and a request for feedback

Not every constraint here deserves the same severity, so the validator distinguishes three levels.

Hard error, where one string resolves two ways or the expression is not anchored to one dataset:

Condition Why an error
Duplicate metric name within a dataset dataset.name resolves two ways
Metric name equals a field name of the same dataset (rule 4) Same qualified namespace, so orders.amount resolves two ways
Expression references another dataset (rule 1) The aggregation is no longer anchored to one dataset
Expression qualifies a field with its own dataset name (rule 2) The expression depends on the name the model gives the dataset

Warning, exit code 0:

Condition Why not an error
Model-scoped metric name equals a dataset-scoped metric name (rule 5) References stay unambiguous, and a dataset may be authored independently and reused across models, so it must not become invalid because of a name the surrounding model introduces

The rule 5 warning is attributed to the model, and names the model-scoped metric as the thing to rename.

Deliberately not checked, and now stated in spec.md so implementations do not add divergent constraints:

Condition Why not
Model-scoped metric name equals a field name Different spellings (revenue vs orders.revenue). Also the common case, since a model-scoped metric usually takes the name of the column it aggregates
Model-scoped metric name equals a dataset name A dataset name only ever appears as a qualifier
Whether a bare name is a declared field An expression also contains function names, literals, and struct paths
A qualifier naming neither a dataset nor a field It is a local alias, CTE, or subquery source

Four questions I would like answered:

  1. Is rule 2 right as a hard error? SUM(orders.amount) inside dataset orders is valid, unambiguous SQL, so erroring enforces a spelling. The case for erroring is that it makes the expression depend on the dataset's name in the model.
  2. Is rule 5 right as a warning? Should shadowing block validation instead?
  3. Should the undeclared-column case be enforced? Rule 1 says fields, but checking it needs heuristics to avoid false positives on functions and struct paths.
  4. Should model-metric-vs-field warn? Currently silent. A warning would fire on most real models, which would devalue rule 5's.

Prior art

Systems differ in where a single-entity aggregation lives, how names resolve, and how strictly scope is enforced. Listed alphabetically.

System Single-entity metric Cross-entity mechanism Name uniqueness Reference form
AtScale SML Standalone metric bound to one dataset + column Separate metric_calc object Global across repositories Bare unique_name
Cube Measures within cubes Calculated measures referencing other measures Per cube Qualified: cube.member
Databricks UC metric views Measures in the view (flat scope) Joins declared inside the view Per metric view MEASURE(name)
dbt MetricFlow (v1.12+) Metrics inside a semantic model Top-level metrics Global across the project Bare name
Snowflake semantic views tables[].metrics Top-level metrics (derived) Per logical table Qualified: table.metric

Four of the five distinguish the two structurally. Ossie has so far provided only the model-level placement, which is the gap this addresses.

Databricks UC metric views are closest to the namespace rules here: one source plus optional joins, sources named so a column is source_name.column_name, with the view itself exposing a flat schema. Snowflake semantic views let a table-scoped metric's own expression reach through a relationship, so the boundary between their placements is softer than rule 1.

Scoped uniqueness with qualified references, rather than the flat global namespace used by AtScale SML and dbt MetricFlow, follows Ossie's existing convention: field names are already scoped to a dataset. Rule 2 follows the same convention.

Rule 1 is the conservative choice: relaxing it later is backward compatible, tightening it is not. Whether a dataset-scoped expression should be permitted to reach through an explicitly declared path is left open. Choosing between multiple relationship paths, where more than one connects the same pair of datasets, is a separate gap Ossie does not currently address.

Related open question: can a metric expression reference another metric?

Raised by @khush-bhatia and seconded by @christianeu-db as belonging in a separate proposal, which I agree with: it affects the expression language rather than metric placement.

Recorded here so the two stay compatible. The gap is ambiguous rather than absent: because expressions are raw SQL strings, SUM(orders.amount) / total_orders parses today with total_orders read as an unqualified column. If metric references are added, the direction rules follow from rule 6:

Referencing metric May reference
Model-scoped Any metric, model- or dataset-scoped, qualified where dataset-scoped
Dataset-scoped Only metrics of the same dataset

A dataset-scoped metric referencing a model-scoped one would break rules 1 and 2, so that case should be disallowed.

The TPC-DS example, and converter support

The first revision moved three single-dataset metrics in the flagship example to store_sales.metrics. @jbonofre showed this broke two omni converter tests, and that all converters read model-level metrics only, so converting the example would silently drop three metrics.

The example is reverted here, byte-identical to main, and no converter is touched. Shipping a flagship example that is lossy through the whole hub-and-spoke is worse than shipping the placement without a flagship demonstration of it, and it makes converter support a prerequisite for moving the example rather than a race against it. The Metric Scoping section of spec.md carries the worked examples meanwhile.

Directly implied by this change, and proposed as follow-ups rather than done here:

  • A shared iter_metrics(model) helper in the spec tooling yielding both placements with qualified names, so converters get this right by default rather than each reimplementing it
  • Updating the converters to read datasets[].metrics, after which the example can move. converters/polaris carries the same latent gap the Pydantic model did: its Dataset class has no metrics field and its parser reads model-level metrics only

Out of scope: findings noted while verifying

None of the following is addressed in this PR, and none of it is caused by this change. Recording it because it came up while verifying, and each looks worth its own issue.

No CI workflow covers the paths this PR touches. Every workflow is path-filtered to cli/** or converters/<name>/**. Nothing watches core-spec/, validation/, python/, docs/ or examples/, so a PR touching only those paths runs no jobs and still shows a green check. This now also affects the validator suite introduced in #330, which no workflow executes. A workflow covering those paths would want to run the validator against examples/, the python suite, and validation/tests/. I have deliberately not added one here so this PR stays a specification change.

converters/databricks has a pre-existing test failure. test_roundtrip_properties.py::TestMetricViewRoundTrip::test_mv_to_ossie_to_mv. Hypothesis generates a metric view whose source table and one of its joins share the name j0, which trips the converter's own duplicate-name guard. Confirmed reproducible on unmodified main by stashing this branch's changes.

converters/wisdom has no CI workflow and no committed uv.lock, unlike every other converter, so its tests never run in CI.

Changes

Spec:

  • core-spec/spec.md: Metric Scoping promoted to a ## section so the TOC entry resolves; rules rewritten; grain, namespace and fields-vs-columns stated; examples corrected; prior art and consumer guidance updated
  • core-spec/spec.yaml: dataset and model metric blocks documented
  • core-spec/ossie-schema.json: Dataset.metrics added; both metric descriptions rewritten

Tooling:

  • validation/validate.py: one shared _parse_expression helper replacing two duplicated parse paths, with caching; scoping check rewritten; field/metric collision check added; rule 5 emitted as a warning
  • validation/tests/test_validate.py: 18 cases added to the suite introduced in Warn when relationship to_columns does not cover a declared key #330, covering the metric scoping and metric name rules. Follows the existing module-loading and importorskip pattern; no new packaging files
  • python/src/ossie/models.py: OssieDataset.metrics added
  • python/tests/test_models.py: new test walking schema $defs to catch schema/model drift

Docs:

  • docs/index.md (3 places) and converters/README.md (2 places, including the dataset property table): all previously stated that metrics are model-level only

Example:

  • examples/tpcds_semantic_model.yaml: unchanged from main

Validation

Rules 1 to 4 are semantic constraints JSON Schema cannot express and are enforced in validate.py. Rule 5 is emitted as a warning. Rule 6 is a resolution rule for consumers rather than a check.

Verified locally, since no CI workflow covers the paths this PR touches:

  • validation/tests/test_validate.py: 28 tests, the 10 from Warn when relationship to_columns does not cover a declared key #330 plus 18 added here. Confirmed non-vacuous by reverting only validate.py to the pre-review commit, where 10 of the 18 new cases fail and all 10 pre-existing ones still pass
  • python: 10 tests. The schema/model drift test was likewise confirmed to fail against a simulated pre-fix model
  • 9 Python converter suites pass: databricks, dbt, gooddata, honeydew, nvidia, omni, orionbelt, snowflake, wisdom. The one databricks failure is a pre-existing Hypothesis case reproducible on unmodified main
  • 2 Java converters not run: polaris and salesforce need a JRE unavailable on this machine
  • examples/tpcds_semantic_model.yaml validates against the core schema; examples/flights.yaml against the ontology schema
  • All 24 fenced YAML blocks in spec.md parse, and the Complete Example validates against the schema
  • The two snippets spec.md marks INVALID are actually rejected by the validator, so the documented rules and the tooling cannot drift apart

Checklist

Specification

  • Spec changes are included in core-spec/ and follow the existing structure
  • Spec changes have been discussed on the mailing list or in a linked issue
  • Breaking changes to the spec are clearly called out in the summary. There are none; the addition is optional and backward compatible

Ontology

  • Ontology changes in ontology/ are consistent with spec changes. None required: ontology.json references SemanticModel by $ref to the published schema, so it picks up Dataset.metrics without change
  • New or modified terms are defined and documented

Converters

  • Converter logic in converters/ is updated to reflect spec or ontology changes. Deliberately not done, see "The TPC-DS example, and converter support" above. No converter reads datasets[].metrics; because the example is unchanged, no converter regresses, and updating them is proposed as a follow-up
  • New converters include tests under the converter's test directory. No new converters

Validation

  • Validation rules in validation/ are updated if the spec changed
  • New validation cases are covered by tests: 18 cases added to validation/tests/test_validate.py, 28 total in that file

Documentation

  • docs/ is updated to reflect any user-facing changes
  • New features or behaviors are documented with examples where appropriate
  • CONTRIBUTING.md is updated if the contribution process changed. Not applicable

Examples

  • examples/ are added or updated for any new spec constructs or converter support. Deliberately not done, so the flagship example is not lossy through converters that do not yet read the new placement. Worked examples live in the Metric Scoping section of spec.md

Tests

  • All existing tests pass (pytest / CI green). Verified locally; note that no CI workflow covers the paths this PR touches, so a green check on this PR would mean no jobs ran
  • New functionality is covered by tests

Compliance

  • ASF license headers are present on all new source files. No new files; all changes are to existing ones
  • No third-party dependencies are added without PMC/IPMC approval. None added

AI disclosure

Per the ASF Generative Tooling Guidance, this contribution was prepared with AI assistance. All specification decisions, the scoping rules, and the responses to review are mine. I have reviewed and verified every change, and the verification results above were produced by running the suites rather than asserted.

Comment thread core-spec/ossie-schema.json Outdated
"items": {
"$ref": "#/$defs/Metric"
},
"description": "Dataset-scoped metrics. Expressions must resolve entirely within this dataset and must not traverse relationships or reference fields of another dataset. Names must be unique within the dataset and must not collide with any model-scoped metric name. Referenced from outside the dataset as dataset_name.metric_name. Metrics that span datasets belong in semantic_model.metrics."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How about clarifying here that the dataset scoped metrics are computed at the grain of the dataset's primary key. Also why restrict relationship traversal ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  1. Agree that dataset-scoped metrics are computed at the dataset's grain. However, this grain should be safely computable without requiring a PK.
  2. Agree with the relationship traversal restriction. To follow on to the layering framing, dataset-scoped metrics belong to the relational / metric layer. In that layer, each dataset acts as a self-contained table that can be independently queried / composed in SQL. If databases supported this layer natively, Ossie datasets could be pushed down to the database layer & used by multiple Ossie models. Relationship traversal is more tied to the multi-table semantics within a Ossie model (i.e. it depends on the graph shape), which would mean that datasets now need to reason about each other in the context of some graph

Comment thread core-spec/spec.yaml
# Represents key calculations like sums, averages, ratios, etc.
#
# The same structure is used in two placements:
# - semantic_model.metrics (model-scoped): may span datasets via relationships

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's add here that semantic_model.metrics can be used to combine multiple dataset scoped metrics ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

To Josh's PR review questions, this fits with the question on if / how to build metrics from other metrics. That's probably worth a followup PR / discussion since it touches on how to extend the expression language

@jbonofre
jbonofre self-requested a review August 28, 2026 05:12

@christianeu-db christianeu-db left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for putting this PR together - definitely agree with adding dataset-scoped metrics. Conceptually, this fits in nicely with the layered model from the working group.

Most of my questions are around making dataset-scoped metrics consistent with fields in terms of name-uniqueness, how they reference fields in their expressions, and the requirement to always be called with a two-level name. Another question is around what can be an input to a dataset-scoped metric.

With respect to your open questions:

  1. Relationship traversal information seems like a model-level concept since it spans across datasets.
  2. Agreed that defining metric / other reference rules should be a separate discussion since that's a pretty fundamental part of the spec

Comment thread core-spec/spec.md Outdated

**Rules**

1. A dataset-scoped metric's expression MUST only reference fields of the dataset that declares it. It MUST NOT traverse relationships or reference fields belonging to another dataset. A metric that needs to span datasets MUST be model-scoped.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does the metric's expression reference the dataset's fields, the dataset's source's columns or both (with some disambiguation mechanism)?

In the example below, COUNT(order_id) references a column in the source, not a field in the containing dataset.

Comment thread core-spec/spec.md Outdated
**Rules**

1. A dataset-scoped metric's expression MUST only reference fields of the dataset that declares it. It MUST NOT traverse relationships or reference fields belonging to another dataset. A metric that needs to span datasets MUST be model-scoped.
2. Dataset-scoped metric names MUST be unique within their dataset. Two different datasets MAY each declare a metric with the same name (e.g. `orders.item_count` and `shipments.item_count`).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should uniqueness apply across both fields & metrics (e.g. can a metric and a field in a dataset have the same name)?

Uniqueness would guarantee that dataset.name referred to exactly one of a field or metric, not both.

Comment thread core-spec/spec.md Outdated

Rule 1 constrains only what a metric's *expression* may reference. It does not restrict how the metric may be queried. A dataset-scoped metric can still be grouped by, or filtered on, dimensions from other datasets reached through relationships — grouping dimensions are supplied by the consumer at query time and are not part of the metric definition.

For example, a metric declared on `store_sales` as `SUM(store_sales.ss_ext_sales_price)` is dataset-scoped because its expression touches only `store_sales`, yet it remains valid to group that metric by `item.i_brand` or `store.s_state` via the model's relationships. Only a metric whose own expression must reach into another dataset — such as `SUM(store_sales.amount) / COUNT(DISTINCT customer.id)` — needs to be model-scoped.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should dataset-scoped metric expressions use a single-layer namespace since they can only reference fields from the dataset and/or source (depending on this discussion)?

For example, store_sales-scoped metric would have the expression SUM(ss_ext_sales_price) instead of SUM(store_sales.ss_ext_sales_price). This would be more consistent with fields and lines up with some of the layering framing (i.e. dataset-scoped objects don't reason about other datasets)

If a single-dataset metric references the two level namespace, that seems to be relational metrics layer being aware of concepts (two-layer names) from the layer above.

Comment thread core-spec/ossie-schema.json Outdated
"items": {
"$ref": "#/$defs/Metric"
},
"description": "Dataset-scoped metrics. Expressions must resolve entirely within this dataset and must not traverse relationships or reference fields of another dataset. Names must be unique within the dataset and must not collide with any model-scoped metric name. Referenced from outside the dataset as dataset_name.metric_name. Metrics that span datasets belong in semantic_model.metrics."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  1. Agree that dataset-scoped metrics are computed at the dataset's grain. However, this grain should be safely computable without requiring a PK.
  2. Agree with the relationship traversal restriction. To follow on to the layering framing, dataset-scoped metrics belong to the relational / metric layer. In that layer, each dataset acts as a self-contained table that can be independently queried / composed in SQL. If databases supported this layer natively, Ossie datasets could be pushed down to the database layer & used by multiple Ossie models. Relationship traversal is more tied to the multi-table semantics within a Ossie model (i.e. it depends on the graph shape), which would mean that datasets now need to reason about each other in the context of some graph

Comment thread core-spec/spec.yaml
# Represents key calculations like sums, averages, ratios, etc.
#
# The same structure is used in two placements:
# - semantic_model.metrics (model-scoped): may span datasets via relationships

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

To Josh's PR review questions, this fits with the question on if / how to build metrics from other metrics. That's probably worth a followup PR / discussion since it touches on how to extend the expression language

Comment thread core-spec/spec.md Outdated

1. A dataset-scoped metric's expression MUST only reference fields of the dataset that declares it. It MUST NOT traverse relationships or reference fields belonging to another dataset. A metric that needs to span datasets MUST be model-scoped.
2. Dataset-scoped metric names MUST be unique within their dataset. Two different datasets MAY each declare a metric with the same name (e.g. `orders.item_count` and `shipments.item_count`).
3. A dataset-scoped metric name MUST NOT collide with the name of any model-scoped metric in the same semantic model. This keeps an unqualified metric reference unambiguous.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could the rule be that only model-scoped metrics can use unqualified names?

The requirement that unqualified metric references must be unambiguous seems to conflict with rule 2, which could allow for two datasets to have metrics with the same name. Rule 2 seems more consistent with field behavior / having datasets not reason about name uniqueness across each other.

Then, the model could be:

  1. Unscoped names: model-level
  2. Scoped names: dataset-level

Some tools, such as Tableau, support cross-dataset dimensions, which also live in the global namespace. In their model, they have both dataset-scoped metrics/dimensions and model-scoped metrics/dimensions

Comment thread core-spec/spec.md
| **AtScale SML** | Standalone `metric` object bound to one `dataset` + `column` | Separate `metric_calc` object type | Global across all repositories | Bare `unique_name` |
| **dbt MetricFlow** (v1.12+) | Metrics inside a semantic model | Top-level `metrics` | Global across the project | Bare name |
| **Cube** | Measures within cubes | Calculated measures referencing other measures | Per cube | Qualified — `cube.member` |
| **Databricks UC metric views** | Measures in the view (one flat scope) | Joins declared inside the view | Per metric view | `MEASURE(name)` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For metric views, it's close to named subqueries when building a view. Each source has a name so within a metric view, field names are <source_name>.<field_name> but the metric view itself acts like a table so its schema has a flat scope

Comment thread core-spec/spec.md Outdated

**Scope restricts the expression, not the query**

Rule 1 constrains only what a metric's *expression* may reference. It does not restrict how the metric may be queried. A dataset-scoped metric can still be grouped by, or filtered on, dimensions from other datasets reached through relationships — grouping dimensions are supplied by the consumer at query time and are not part of the metric definition.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Probably worth clarifying that within the layering proposal, the ability to natively query across datasets from a single interface would be in what is called the dimensional layer (Will was also proposing "presentation layer" as an alternate name)

Comment thread core-spec/spec.md Outdated

**Choosing a placement**

Prefer dataset-scoped for simple aggregations that belong conceptually to one entity — they keep the metric next to the fields it depends on and make the dataset independently interpretable. Use model-scoped for anything requiring a join.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(nit) The aggregations themselves don't necessarily need to be simple. More complex aggregates (e.g. later window calculation extensions) could be dataset-scoped

Comment thread examples/tpcds_semantic_model.yaml Outdated
# store_sales. These may still be grouped by dimensions of other
# datasets (for example item.i_brand or store.s_state) through the
# model's relationships. Referenced as store_sales.<metric_name>.
metrics:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two tests read this file directly and assert on model-level metrics:

  • converters/omni/tests/test_ossie_to_omni.py test_tpcds_export_matches_expected loads this file and compare the export against tests/fixtures/tpcds_omni
  • converters/omni/tests/test_roundtrip.py test_ossie_roundtrip_up_to_documented_normalizations

Both fail due to this change (cd converters/omni && uv run pytest).

I suggest to update the omni converter to host dataset-scoped metrics (updating the fixture in this PR). That would be my preference.

"$ref": "#/$defs/Field"
}
},
"metrics": {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

OssieDataset in python/src/ossie/models.py (the model shipped as the apache-ossie package) has no metrics field. Pydantic's default extra='ignore' means dataset-scoped metrics are discarded on load with no error:

doc = OssieDocument.model_validate(yaml.safe_load(open('examples/tpcds_semantic_model.yaml')))
# store_sales has no `metrics` key; only customer_lifetime_value and
# store_productivity survive to_ossie_yaml(). Three metrics vanish silently.

Adding metrics: list[OssieMetric] | None = None to OssieDataset fixes it.

Worth noting the reason nothing caught this: python/tests/test_models.py:75 only cross-checks the DataType enum against the JSON Schema, so structural drift between the schema and the pydantic model is invisible. A test that walks $defs properties and asserts each one exists on the corresponding model would have failed here.

Comment thread core-spec/spec.md Outdated

**Consumer guidance: flattening to a single metric namespace**

Consumers whose native model has only model-level metrics do not need to represent the two placements separately. Because a dataset-scoped metric's expression resolves entirely within its declaring dataset, that expression is already valid as a model-scoped metric — hoisting requires no expression rewriting.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Every converter reads model-level metrics only:

Converter Location
omni converters/omni/src/ossie_omni/ossie_to_omni.py:187
honeydew converters/honeydew/.../converter.py:136
snowflake converters/snowflake/.../converter.py:169
nvidia converters/nvidia/.../native_converter.py:322
orionbelt converters/orionbelt/.../ossie_to_obml.py:170
databricks converters/databricks/python/.../ossie_to_metric_view.py:168
polaris converters/polaris/.../OssieYamlGenerator.java:72

Convert the new TPC-DS example to any target today and three metrics disappear with no warning. Compare converters/honeydew, which at least warnings.warns when it has to guess.

"Consumers that read only semantic_model.metrics remain valid" is a reasonable spec position, but combined with moving the reference example it means the flagship model is lossy through the entire hub-and-spoke on day one.

Two things would help:

  • A shared hoist helper in the spec tooling (iter_metrics(model) yielding both placements with qualified names), so converters get this right by default rather than each reimplementing it.
  • Stronger wording here: a consumer that ignores datasets[].metrics silently produces an incomplete model, which is a lossy conversion, not a valid one. At minimum it SHOULD warn.

Comment thread validation/validate.py Outdated
if qualifiers is None:
continue

foreign = sorted(q for q in qualifiers if q != dataset_name)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

SQL identifiers are case-insensitive, and sqlglot preserves the raw text of unquoted identifiers, so q != dataset_name is comparing raw casing against the YAML name.

To reproduce, use dataset orders, dialect SNOWFLAKE, expression SUM(ORDERS.AMOUNT):

[Scope] Dataset-scoped metric 'orders.total' in model 'm' (SNOWFLAKE) references dataset(s) 'ORDERS'.

So a correct model is rejected, and the error names a dataset that does not exist. Uppercase is the natural casing for Snowflake semantic views, which the PR body names as the motivating consumer.

Fold case before comparing (q.casefold() != dataset_name.casefold()). Strictly correct handling also depends on whether the identifier was quoted, but case-folding unquoted identifiers covers the realistic cases and is a clear improvement on the current behavior.

Comment thread validation/validate.py Outdated
continue
if tree is None:
continue
return {col.table for col in tree.find_all(exp.Column) if col.table}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verified with sqlglot:

qualifiers("SUM(orders.payload.amount)")  # -> {'payload'}   (not 'orders')
qualifiers("SUM(payload.amount)")         # -> {'payload'}

For a three-part path sqlglot puts the middle part in col.table, so a dataset-scoped metric over a STRUCT/VARIANT column on orders is rejected with references dataset(s) payload'`.

There is no way to express such a metric at dataset scope at all, the only escape is moving it to model scope, where the rule does not apply.

exp.Column has catalog/db/table/this parts, for the three-part case you want to look at col.db (or col.parts[0]) as the potential dataset qualifier, and treat a bare two-part path whose first element is a declared field name as intra-dataset.

Comment thread core-spec/spec.md Outdated

1. A dataset-scoped metric's expression MUST only reference fields of the dataset that declares it. It MUST NOT traverse relationships or reference fields belonging to another dataset. A metric that needs to span datasets MUST be model-scoped.
2. Dataset-scoped metric names MUST be unique within their dataset. Two different datasets MAY each declare a metric with the same name (e.g. `orders.item_count` and `shipments.item_count`).
3. A dataset-scoped metric name MUST NOT collide with the name of any model-scoped metric in the same semantic model. This keeps an unqualified metric reference unambiguous.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rule 4 makes a dataset-scoped metric reachable as dataset_name.metric_name, which is exactly the form a field already uses. Nothing forbids a metric from taking a field's name in the same dataset, and the validator accepts it:

- name: orders
  fields:
    - name: amount        # ...
  metrics:
    - name: amount        # expression: SUM(orders.amount)

Validation PASSED.

A consumer resolving orders.amount now cannot tell whether it means the row-level field or the aggregate, and the metric's own expression SUM(orders.amount) becomes self-referential under rule 4. Suggest a rule 5: a dataset-scoped metric name MUST NOT collide with the name of any field of the same dataset: plus the corresponding check in validate_unique_names, which already has both name lists in scope.

Comment thread core-spec/spec.md
| `description` | string | No | Human-readable description |
| `ai_context` | string/object | No | Additional context for AI tools (e.g., synonyms, common terms) |
| `fields` | array | No | Row-level attributes for grouping, filtering, and metric expressions |
| `metrics` | array | No | Dataset-scoped metrics whose expressions resolve entirely within this dataset. See [Metric Scoping](#metric-scoping). |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

  • docs/index.md:251 (FAQ) — "Metrics are defined at the semantic model level (not within a dataset)"
  • docs/index.md:58 and docs/index.md:321 (glossary) — same claim
  • converters/README.md:169 — "Metrics are aggregate measures defined at the semantic model level"
  • converters/README.md:97 — the dataset property table omits metrics

The converter guide one matters most: an author following it writes a converter that drops dataset-scoped metrics, which is exactly the failure mode already present in all the existing converters.

Comment thread core-spec/spec.md

metrics:
- name: total_revenue
# Model-scoped: spans orders and customers via the relationship

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The comment says "spans orders and customers via the relationship", but it applies to a metrics: list whose second entry (customer_count, line 722) is COUNT(DISTINCT customers.id), single-dataset. By the guidance at line 453 ("prefer dataset-scoped for simple aggregations that belong conceptually to one entity") it belongs on customers.metrics.

Commit 4 moved all three single-dataset metrics in the TPC-DS example for exactly this reason, so the two examples in the PR now teach opposite things. Either move customer_count onto customers here too, or scope the comment to revenue_per_customer alone.

Comment thread core-spec/spec.md
expression:
dialects:
- dialect: ANSI_SQL
expression: COUNT(order_id)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The orders dataset above declares exactly one field, amount; order_id appears only in primary_key. So the metric introduced as "unqualified reference to a field of the declaring dataset" references something that is not among the declaring dataset's fields, the example undercuts the point it is making.

Either add order_id to fields, or use COUNT(amount).

Comment thread core-spec/spec.md
5. [Fields](#fields)
6. [Metrics](#metrics)
7. [Examples](#examples)
7. [Metric Scoping](#metric-scoping)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: every other numbered entry (1–6, 8) maps to a ## heading, but Metric Scoping is authored as ### Metric Scoping at line 427, nested under ## Metrics. Rendered docs get a top-level entry that jumps into the middle of the Metrics section.

Either promote it to ## Metric Scoping, or drop the TOC entry and keep the in-section link at line 370.

Comment thread core-spec/ossie-schema.json Outdated
"items": {
"$ref": "#/$defs/Metric"
},
"description": "Dataset-scoped metrics. Expressions must resolve entirely within this dataset and must not traverse relationships or reference fields of another dataset. Names must be unique within the dataset and must not collide with any model-scoped metric name. Referenced from outside the dataset as dataset_name.metric_name. Metrics that span datasets belong in semantic_model.metrics."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's add here that metrics and fields in the dataset share the same namespace. So the names should be unique across fields and metrics in dataset.

@willpugh willpugh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would like us to focus on the spec, before the code changes. A few issues:
1). Why not just remove the no-aggregation restriction on fields?
2) We should probably work on the semantics first. Particularly around relationship traversal
3) I'm assuming we are going to want grain locking here, but don't think you covered it.

Josh Klahr and others added 6 commits August 29, 2026 11:22
Allow metrics to be declared on an individual dataset (datasets[].metrics)
in addition to the semantic model (semantic_model.metrics), using the same
metric structure in both placements.

Dataset-scoped metrics are for aggregations that resolve entirely within a
single dataset. They keep a metric next to the fields it depends on and make
a dataset independently interpretable. Metrics that span datasets via
relationships remain model-scoped.

Scoping rules:
- A dataset-scoped metric expression must only reference fields of its own
  dataset; it must not traverse relationships.
- Names must be unique within their dataset. Two datasets may each declare a
  metric with the same local name.
- A dataset-scoped name must not collide with any model-scoped metric name,
  keeping unqualified metric references unambiguous.
- Referenced from outside the dataset as dataset_name.metric_name, mirroring
  how a dataset's fields are already referenced in metric expressions.

Prior art: dbt MetricFlow (v1.12+) supports the same two-placement split,
reserving in-model metrics for single-semantic-model metrics and top-level
metrics for cross-model ones. Cube scopes measures to cubes with qualified
cube_name.member references. Ossie follows Cube's scoped-uniqueness and
qualified-reference convention because it matches how Ossie already treats
fields.

Dataset-scoped metrics reuse the existing Metric schema definition, so they
inherit any future additions to the metric shape automatically.

validate.py gains three checks that JSON Schema cannot express: duplicate
metric names within a dataset, collisions with model-scoped metric names, and
cross-dataset references in dataset-scoped expressions. Scope checking
degrades gracefully for dialects sqlglot cannot parse (MDX, TABLEAU, MAQL)
rather than reporting false positives.

.... Generated with [Cortex Code](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code)

Co-Authored-By: Cortex Code <noreply@snowflake.com>
Add Snowflake semantic views and Databricks Unity Catalog metric views to
the prior-art comparison, alongside dbt MetricFlow and Cube.

Snowflake is the closest analogue: table-level metrics scoped to a logical
table plus top-level derived metrics that combine metrics across tables,
with qualified table.metric references. Databricks takes a different
approach, with a single flat scope per metric view and joins declared
inside the view.

Also records that this proposal is deliberately stricter than Snowflake on
scope enforcement: Snowflake permits table-level metrics to traverse
relationships via using_relationships, whereas dataset-scoped metrics here
must resolve within their own dataset. Notes the rationale (a strict
boundary is legible and can be relaxed compatibly later) and leaves the
question open for the community.

.... Generated with [Cortex Code](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code)

Co-Authored-By: Cortex Code <noreply@snowflake.com>
Add AtScale SML as a fifth reference point. SML demonstrates a third
placement pattern: a standalone, globally-named metric object that
declares its binding by property (dataset and column are both required),
with cross-entity calculations as a separate metric_calc object type.

This positions the proposal between the extremes rather than at one end:
SML binds a plain metric to a single column with a single aggregation
method; Snowflake permits table-level metrics to traverse relationships;
Ossie permits an arbitrary expression over the declaring dataset but no
traversal.

Also adds consumer guidance on flattening to a single metric namespace.
Because a dataset-scoped expression resolves within its declaring dataset,
it is already valid as a model-scoped metric, so consumers that support
only model-level metrics can hoist without rewriting expressions. Notes
the two real caveats: flattening requires name qualification, and
consumers reading only semantic_model.metrics will not observe
dataset-scoped metrics.

.... Generated with [Cortex Code](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code)

Co-Authored-By: Cortex Code <noreply@snowflake.com>
Move the three single-dataset metrics in the TPC-DS example into
store_sales.metrics, leaving the two that genuinely span datasets at model
level. The example now demonstrates the placement decision rather than
contradicting the guidance in spec.md.

Dataset-scoped (expressions resolve within store_sales):
- total_sales, total_profit, sales_by_brand

Model-scoped (span datasets via relationships):
- customer_lifetime_value (store_sales + customer)
- store_productivity (store_sales + store)

Referencing these three changes from total_sales to store_sales.total_sales.

Also clarifies in spec.md and spec.yaml that the scoping rule constrains a
metric's expression, not how it may be queried. A dataset-scoped metric can
still be grouped by or filtered on dimensions of other datasets reached
through relationships, since grouping dimensions are supplied by the consumer
at query time. The existing sales_by_brand metric is exactly this case: its
expression touches only store_sales, but its description notes it "requires
grouping by item.i_brand". Without this clarification the rule is easy to
misread as forbidding cross-dataset grouping, which would make the feature
appear far more limited than it is.

.... Generated with [Cortex Code](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code)

Co-Authored-By: Cortex Code <noreply@snowflake.com>
Incorporates review from jbonofre, christianeu-db and khush-bhatia on apache#343.

Two substantive changes:

- Dataset-scoped metric expressions now reference fields by unqualified name
  (SUM(amount), not SUM(orders.amount)), matching how a field's own expression
  is written. Raised by christianeu-db.
- Name-collision rules are split into errors and warnings. A model-scoped
  metric reusing a dataset-scoped metric's name now warns instead of failing,
  and the warning is attributed to the model rather than the dataset, since a
  dataset may be authored independently and reused across models.

Also reframes the placement throughout: a dataset-scoped metric aggregates data
held by its dataset, and is still joined and grouped through the model's
relationships like any other metric. The earlier "no traversal" wording implied
a restriction on how the metric could be queried, which was wrong.

Validator fixes, each reported with a reproduction:

- Case-fold qualifier comparison, so SUM(ORDERS.AMOUNT) on dataset orders is no
  longer rejected against a dataset that does not exist
- Use Column.parts[0] rather than Column.table, so STRUCT and VARIANT paths are
  not read as dataset references
- Cross-check qualifiers against declared dataset names, so local aliases, CTEs
  and subquery sources are not reported as cross-dataset references
- Extract a single cached _parse_expression helper, replacing two duplicated
  parse paths that had begun to diverge
- Sort collision output for determinism under hash randomisation
- Handle an explicitly null expression without raising AttributeError

Spec and docs:

- Metric Scoping promoted to a top-level section so its TOC entry resolves
- Aggregation grain stated, and explicitly not dependent on primary_key
- New rule: a dataset-scoped metric name must not collide with a field name of
  the same dataset
- Namespace model documented, so the permitted repetitions are stated rather
  than left for implementations to constrain differently
- docs/index.md and converters/README.md corrected; both said metrics are
  model-level only
- Consumer guidance strengthened: ignoring datasets[].metrics is a lossy
  conversion, not a valid one

The TPC-DS example is reverted to its state on main. No converter reads
datasets[].metrics yet, so a flagship example using the placement would be
lossy through every converter.

Assisted-by: Cortex Code <noreply@snowflake.com>
Extends the validator test suite added in apache#330 with cases for the metric
scoping and metric name checks.

Each test under "reported in review" corresponds to a defect found in review of
apache#343 and fails against the validator as it stood before that review: raw-cased
qualifier comparison, three-part STRUCT paths read as dataset references, local
aliases and subquery sources reported as cross-dataset references, a traceback
on an explicitly null expression, non-deterministic collision output, and the
missing field/metric name collision check.

Also covers the deliberately permitted cases, so a later change does not
constrain them by accident: two datasets may reuse a metric name, and a
model-scoped metric may take the name of a field or of a dataset.

Follows the module-loading and importorskip pattern established by the existing
tests. sqlglot is skipped rather than asserted, since the scoping checks no-op
without it and would otherwise pass without asserting anything.

Assisted-by: Cortex Code <noreply@snowflake.com>
@jklahr
jklahr force-pushed the add-data-set-scoped-metrics branch from df8819d to 75118a7 Compare August 29, 2026 16:02
@jklahr

jklahr commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks all, this was a genuinely useful round. Two things changed materially as a result: the expression namespace is now unqualified, and the name-collision rules are split into errors and warnings rather than all being errors.

The branch is also rebased onto current main, which brought in #330. That turned out to overlap: #330 added validation/tests/test_validate.py, so the tests here extend that file rather than introduce one. Worth noting that #330's new check emits [Reference] Warning: ..., the same convention I had chosen for rule 5 below, arrived at independently.

Every comment is answered below, grouped by reviewer. Verification for each claim is at the end.


@jbonofre

Six of these were bugs with working reproductions. All six are fixed, and each has a test that fails before the change and passes after.

validate.py:137, non-deterministic collision ordering. Applied your sorted() suggestion. Verified across six runs in one process that the output is now identical every time.

validate.py:172, duplicated parse logic, up to four parses per expression. Extracted a single _parse_expression(expr, dialect) -> (tree, error) used by both callers. This also unified the tree is None divergence you spotted: both paths now treat it as a failed candidate and try the next form. I added lru_cache on top, so repeated checks over the same expression parse once rather than twice; verified by asserting cache hits increase across a scoping pass followed by a SQL pass. The returned tree is shared, so its docstring states it must not be mutated.

validate.py:194, three-part paths. Fixed using parts[0] rather than col.table, as you suggested. SUM(payload.amount) where payload is a declared field is now accepted. I also checked the case you did not raise: SUM(orders.payload.amount) now correctly reports the dataset qualifier rather than payload.

validate.py:228, case sensitivity. Fixed with case folding. Your exact reproduction, dataset orders with dialect SNOWFLAKE and SUM(ORDERS.AMOUNT), no longer produces a phantom cross-dataset error naming a dataset that does not exist. It now reports the rule 2 violation instead, which is the accurate diagnosis.

validate.py:233, aliases and subqueries. Implemented the three-way distinction you proposed, cross-checking qualifiers against the model's dataset names. SUM(o.amount) and your correlated subquery example are now silent rather than advising a move that would not help. A qualifier matching another dataset is a rule 1 error; one matching the declaring dataset is a rule 2 error; anything else is treated as a local alias, CTE, or subquery source and left alone.

validate.py:298, null expression traceback. Applied .get("expression") or {}, and to all three loops including the pre-existing model-level one you flagged. metrics: [{name: total, expression: }] now surfaces the schema error instead of an AttributeError.

ossie-schema.json:218, Pydantic silently dropping metrics. Confirmed and fixed. Before: loading the example yielded only customer_lifetime_value and store_productivity. After: all five survive, and a load/serialize/reload round-trip is stable and schema-valid.

Your diagnosis of why nothing caught it was the more valuable part. Added a test walking every $defs entry with properties and asserting each is representable on the corresponding model, comparing against aliases too since from is a keyword. I then verified the test is not vacuous by simulating the pre-fix model and confirming it reports ['metrics'].

One note on how the fix landed: adding the field needed OssieMetric visible to OssieDataset, and the file uses no forward references anywhere, so I moved OssieMetric above OssieDataset rather than introduce the first one. That is why models.py shows ~30 changed lines for one new field. Happy to switch to Optional[list["OssieMetric"]] if you would prefer the smaller diff.

spec.md:40, TOC level. Promoted Metric Scoping to ##. TOC entry 7 now resolves to a top-level section.

spec.md:134, stale docs. All five fixed: docs/index.md at the component table, the FAQ, and the glossary; converters/README.md at the metrics section and the dataset property table, which now lists metrics and warns that reading only semantic_model.metrics drops them.

spec.md:442, metric/field name collision. Confirmed, and it was accepted by the validator exactly as you showed. Added as rule 4, a hard error, with a check. @christianeu-db raised the same thing independently. See the warn-vs-error section below for why this one is hard while the model-level clash is not.

spec.md:485, example undercut its own point. Right. Added order_id to the orders fields, which also lets the example demonstrate the fields-not-source-columns rule properly.

spec.md:713, the two examples taught opposite things. Moved customer_count onto customers.metrics as COUNT(DISTINCT id) and scoped the model-level comment to revenue_per_customer alone.

spec.md:539, all seven converters, and the lossy-conversion wording. Took your stronger wording: the spec now says a consumer reading only semantic_model.metrics produces an incomplete representation, that this is a lossy conversion rather than a valid one, that it SHOULD warn naming the dropped metrics, and MUST NOT present the result as faithful.

The iter_metrics(model) helper is a good idea and I have not done it, to keep this PR reviewable. Happy to open it as a follow-up.

tpcds_semantic_model.yaml:155, broken omni tests. Reproduced: 2 failed, 81 passed. I went the other way from your preference and reverted the example, so it is byte-identical to main and no converter is touched. All seven suites now pass.

The reasoning is your own point about the flagship model being lossy through the whole hub-and-spoke on day one. Rather than fix one converter and leave six lossy, the example stays as-is until converters support the placement, which makes converter work a prerequisite for moving the example rather than a race against it. spec.md carries the worked examples meanwhile. If you would still rather see omni updated here, say so and I will do it.

Unrelated, found while running the suites: converters/wisdom has no committed uv.lock while every other converter does.


@christianeu-db

spec.md:449, single-level namespace. Adopted as rule 2, with a validator check and rewritten examples throughout. This is the largest change in the revision.

It also simplified the validator, which no longer compares a qualifier against the declaring dataset's name to decide whether a reference is local.

One correction it forced: the first revision claimed hoisting to a flat namespace needed no expression rewriting. It now does, and the consumer guidance says so.

spec.md:440, fields, source columns, or both. Fields, specifically. Rule 1 said "fields" but only implied it, so that is now stated outright: a column used by a metric must be declared as a field. Not machine-enforced, because an expression also contains function names, literals, and struct or variant paths. See question 3 below.

spec.md:441, uniqueness across fields and metrics. Yes, added as rule 4, a hard error. Your framing was the deciding one: uniqueness is what guarantees dataset.name refers to exactly one thing.

spec.md:442, unqualified names for model-level only. Adopted. Rule 6 is now: unqualified resolves to a model-scoped metric, dataset-scoped is always dataset.metric. Rule 3 stays, so two datasets may each declare the same local name.

This had a knock-on effect. The old rule forbidding a dataset-scoped name from colliding with a model-scoped one existed only to keep unqualified references unambiguous. Under rule 6 that justification disappears, so the constraint had to be re-argued, which is how it became a warning.

The spec's prior-art table now records reference form per system, so the Tableau comparison is visible.

spec.md:447, layering terminology. Added a note that presenting many datasets and their metrics through one queryable interface belongs to the layer above. I have not hard-coded "dimensional" or "presentation" while the working group is choosing between them; happy to use whichever once settled.

spec.md:453, aggregations need not be simple. Reworded. The spec now says rule 1 places no limit on the complexity of the aggregation, and that any expression resolving within the declaring dataset is eligible.

spec.md:517, metric views detail. Rewrote that entry with your description: one source plus optional joins, sources named so a column is source_name.column_name, with the view itself exposing a flat schema. The spec now notes it as the closest analogue to the namespace rules here.

ossie-schema.json:223, grain without a PK, and the expression boundary. Took both points. There is now a grain paragraph stating that a dataset-scoped metric aggregates at its dataset's grain, and that this does not depend on a declared primary_key. primary_key matters for reasoning about fan-out but is not a prerequisite.

On the expression boundary, your layering rationale is stronger than the one I had, so I added it: letting an expression reach through relationships makes it depend on the shape of the relationship graph, which would require a dataset to reason about the datasets around it. The schema description carries the grain point too.

spec.yaml:263, metric references as a separate proposal. Agreed, and not attempted here. The spec.yaml comment says so rather than leaving a silent gap, and the PR body records the direction rules that follow from rule 6 so the two proposals stay compatible. The same question is live in #342.

Your review summary also asked what can be an input to a dataset-scoped metric. Two halves: fields rather than source columns, answered above; and whether another metric can be an input, deferred as agreed.


@khush-bhatia

ossie-schema.json:223, grain, and why the expression boundary is where it is.

On grain: added, with @christianeu-db's qualification that it must be computable without requiring a PK. So the spec states the grain is that of the rows the source produces, and does not tie it to primary_key.

On the expression boundary: I had this framed badly in the first revision, and the wording is now fixed throughout. A dataset-scoped metric simply aggregates data held by its dataset. It is then joined and grouped exactly like any other metric, using the relationships declared in the model, so it can be sliced by dimensions of any dataset the model connects. The placement records where an aggregation is anchored; it does not fence off part of the model from the metric, and nothing that Ossie models express today becomes unexpressible.

What rule 1 constrains is only the expression: it must aggregate fields of its own dataset rather than combine fields from several. An expression that genuinely needs to combine columns across datasets, such as SUM(orders.amount) / COUNT(DISTINCT customers.id), is what semantic_model.metrics is for.

As for why the boundary sits there, the honest answer is that it is the conservative choice rather than the obviously correct one. It makes the anchor point checkable, so a dataset and its metrics can be reasoned about or exchanged without resolving the join graph. And the asymmetry matters: relaxing this later is backward compatible, tightening it is not.

Snowflake semantic views do let a table-scoped metric's own expression reach through a relationship, so the boundary between their two placements is softer than the one proposed here. If you think that is the better model, this is the place to push, and the spec records it as an open question rather than settling it.

One thing I should separate out, since I previously ran these together: choosing between multiple relationship paths when more than one connects the same pair of datasets is a different problem entirely. Ossie has no mechanism for it, it affects model-scoped metric expressions and consumer queries regardless of this PR, and nothing here addresses it. I raised it separately in #342.

spec.yaml:263, combining dataset-scoped metrics at model level. This is the metric-references-metric question, and @christianeu-db is right that it belongs in its own proposal since it changes the expression language rather than metric placement.

Not silent about it, though. The spec.yaml comment now states that composing model-scoped metrics from dataset-scoped ones is a frequently requested capability being handled separately, and the PR body records the direction rules so whichever proposal lands second does not have to reopen this one. Worth noting the gap is ambiguous rather than absent: SUM(orders.amount) / total_orders parses today with total_orders read as an unqualified column, so a producer could write it and consumers could disagree on its meaning. That is an argument for settling it explicitly rather than leaving it implicit.


Warn vs error, and where I would like feedback

Review made clear that not every constraint deserves the same severity, so there are now three levels. The full table is in the PR body; the short version and the open questions:

Hard error where one string resolves two ways, or the expression is not self-contained: duplicate metric name in a dataset; metric name equal to a field name of the same dataset (rule 4); expression referencing another dataset (rule 1); expression qualifying a field with its own dataset name (rule 2).

Warning, exit 0 for one case: a model-scoped metric reusing a dataset-scoped metric's name (rule 5). References stay unambiguous, and a dataset may be authored independently and reused across models, so it must not become invalid because of a name the surrounding model introduces. The warning is attributed to the model, and names the model-scoped metric as the thing to rename. The first revision reported it against the dataset, which asks the wrong party to change.

Deliberately unchecked, now stated in spec.md rather than left silent: a model-scoped metric name equal to a field name, or to a dataset name; whether a bare name is a declared field; and qualifiers that are local aliases or CTEs.

The rule 4 versus rule 5 split is the part I most want checked. The line I drew is that rule 4 prevents one string resolving two ways, while rule 5 guards against confusion between names that remain separately addressable. Four specific questions:

  1. Is rule 2 right as a hard error? SUM(orders.amount) inside dataset orders is valid, unambiguous SQL, so erroring enforces a spelling. The case for erroring is that it breaks dataset portability; the case for warning is that nothing is actually wrong.
  2. Is rule 5 right as a warning? Should shadowing block validation instead?
  3. Should the undeclared-column case be enforced? Rule 1 says fields, but checking it needs heuristics to avoid false positives on functions and struct paths.
  4. Should model-metric-vs-field warn? Currently silent. I judged a warning would be noise, since a model-scoped metric usually takes the name of the column it aggregates, and noisy warnings would devalue rule 5's. Reasonable to disagree.

Verification

The reproductions are now committed. #330 landed a validator test suite while this branch was in review, so these extend it rather than introduce one:

  • validation/tests/test_validate.py: 28 tests, the 10 from Warn when relationship to_columns does not cover a declared key #330 plus 18 added here, one per defect reported above plus the rule behaviours. Confirmed non-vacuous by reverting only validate.py to the pre-review commit, where 10 of the 18 new cases fail and all 10 pre-existing ones still pass.
  • python: 10 tests, including the schema/model drift test, which likewise fails against a simulated pre-fix model.
  • 9 Python converter suites pass: databricks, dbt, gooddata, honeydew, nvidia, omni, orionbelt, snowflake, wisdom. I had previously said "seven", which was wrong; my sweep used a hardcoded list and had skipped dbt and gooddata. It now auto-discovers.
  • 2 Java converters not run: polaris and salesforce need a JRE unavailable on this machine.
  • examples/tpcds_semantic_model.yaml validates against the core schema; examples/flights.yaml against the ontology schema.
  • All 24 fenced YAML blocks in spec.md parse, and the Complete Example validates against the schema.
  • The two snippets spec.md marks INVALID are actually rejected by the validator, so the documented rules and the tooling cannot drift apart.

Four things found while verifying that are unrelated to this change and not addressed here. Each looks worth its own issue, and I am happy to open them:

  • No CI workflow covers the paths this PR touches. Every workflow is path-filtered to cli/** or converters/<name>/**, so nothing watches core-spec/, validation/, python/, docs/ or examples/. A green check on this PR would mean no jobs ran. This affects Warn when relationship to_columns does not cover a declared key #330's new validator suite too, which no workflow executes. I have deliberately not added a workflow here so this stays a specification change.
  • converters/databricks has a pre-existing test failure in test_roundtrip_properties.py::test_mv_to_ossie_to_mv. Hypothesis generates a metric view whose source table and a join share the name j0, tripping the converter's duplicate-name guard. Confirmed reproducible on unmodified main.
  • converters/polaris has the same latent gap the Pydantic model had: its Dataset class has no metrics field and its parser reads model-level metrics only. Latent because no model in the repo currently declares dataset-scoped metrics.
  • converters/wisdom has no CI workflow and no committed uv.lock, unlike every other converter.

One process note for anyone re-running the validator tests: they must run under uv. Bare python3 has no sqlglot, so _parse_expression returns "skip" and the scoping checks silently pass. My first attempt produced four false passes that way, which is why test_validate.py opens by asserting sqlglot is importable.

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.

5 participants