Refuse raw output from definition-side semantic expressions - #306
Merged
Conversation
A semantic model is a definition, not a query. Executing one returned the raw underlying table — every physical column at row grain, undeclared columns included, computed dimensions missing — through every output sink (execute/sql/to_pandas/to_csv/...) and every pre-aggregation chain (filters, joins, order_by/limit, group_by without aggregate, empty query()). Nothing about that result was semantic. Output now requires a completed query: - All 14 output sinks on SemanticTable call _ensure_executable(), which raises QueryError with the intended spellings (group_by/aggregate, query(), or .to_untagged() for explicit raw access) unless the chain contains an aggregation stage, an index, or a materialized result model. - group_by without aggregate gets its own error pointing at .aggregate() (an empty .aggregate() returns distinct grouped values). - query() with neither dimensions nor measures fails eagerly. - .mutate() chained after filter/order_by/limit on an aggregate raises: a query result is a plain table, so row math over it is spelled .to_untagged().mutate(...); .mutate() directly on the aggregate (the measure-path desugar) and calc measures remain the semantic spellings. The _build_post_aggregate_model wrapper and the now-unreachable non-additive-total refusal machinery are removed. - SemanticTableOp gains _materialized_result to distinguish result models (compare_periods output, as_table() over an aggregate — executable) from definition models; the flag survives with_dimensions/with_measures, filter-preserving rebuilds, and root-derived models. - Agent help topics and the four bsl-query-expert doc copies now teach window functions via .to_untagged() and document the two new gotchas; ADR 0001 carries an amendment note. test_execute_guard.py pins the contract (30 tests). Existing tests that inspected raw rows now do so via the explicit .to_untagged() escape hatch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… contract CI surfaced consumers of the old behavior that the core test run missed: - chart/md_parser executor: a doc fence ending with a bare semantic model now renders the "semantic table defined" box (the previously unreachable definition branch) by catching the guard's QueryError from the convert step, instead of erroring — no core privates imported, so the extras boundary ratchet stays green. New test pins the rendering. - chart tests: rolling-window and chart-time-detection regressions rewritten to mutate on the aggregate (the measure path handles windows when the window flavor matches — the window carries its own ordering), then order_by/limit. - examples/window_functions.py: windows over a filtered result drop to ibis via .to_untagged().mutate(...). - docs: windowing.md fences and query-methods.md window example rewritten to the mutate-on-the-aggregate spelling (keyword .over(rows=..., order_by=...)) with .to_untagged() for filtered/limited results; agent help topics and the bsl-query-expert copies teach the same spelling — it keeps .chart() and further order_by/limit working, and avoids constructing ibis.window() objects of the wrong flavor. Docs build now completes with zero query errors. - ruff format for tests/test_query.py (lint job failure). Full tree green: 1777 passed (incl. chart/, agents/, md_parser/ suites), docs build clean, skills check clean, examples run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hussainsultan
marked this pull request as ready for review
August 24, 2026 13:07
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Executing a bare semantic model returned the complete raw table: every physical column at row grain, undeclared columns included, computed dimensions missing. Nothing about that result is semantic. The same hole existed for every output sink (
sql,to_pandas,to_csv, … — 14 in total) and for every pre-aggregation chain:Contract
A semantic model is a definition, not a query (matching Malloy, where
run: sourcewithout a query stage is a compile error). Output now requires a completed query; raw access stays one explicit call away:_ensure_executable(); the error names the model, lists its declared dimensions/measures, and shows the intended spellings.group_bywithoutaggregategets a dedicated error pointing at.aggregate(...)(an empty.aggregate()returns distinct grouped values).query()with neither dimensions nor measures fails eagerly.Post-aggregation mutate
Per the direction that post-aggregate row math belongs on
to_untagged()(calc measures are the semantic mechanism),.mutate()chained afterfilter/order_by/limiton an aggregate now raises instead of materializing a pseudo-model:The
_build_post_aggregate_modelwrapper is deleted, along with the now-unreachable non-additive-total refusal machinery (_non_additive_result_columns,NonAdditiveTotalError) — the surviving measure path computes totals from the underlying rows, so it gets the mean case right (verified intest_totals_semantics.py) rather than needing to refuse it.What stays executable
Results, not definitions: aggregates and their filter/order_by/limit chains,
.index()results,as_table()over a completed query, andcompare_periods()output — tracked via a new_materialized_resultflag onSemanticTableOpthat survives metadata rebuilds.Docs
Agent help topics and the four
bsl-query-expertdoc copies now teach window functions via.to_untagged()(the oldorder_by().mutate()recipe raises) plus the two new gotchas; ADR 0001 carries an amendment note.Tests
test_execute_guard.pypins the contract (30 tests)..to_untagged()spelling; the post-agg mutate tests assert the refusal and verify theto_untaggedequivalent produces the old values.Noted while testing (pre-existing, not addressed here)
agg.mutate(share=t.total / t.all(t.total))over integer measures truncates to0.0on the memtable → canonical-backend (xorq/DataFusion) path, while the compiled SQL carries the float cast and duckdb executes it correctly — same family as the ibis/xorq flavor-audit defects. Pinned on duckdb intest_totals_semantics.pywith a comment.🤖 Generated with Claude Code