Skip to content

DEV-1450 principled redesign of syntax (WIP)#139

Draft
ZmeiGorynych wants to merge 79 commits into
mainfrom
egor/dev-1450-principled-redesign-of-syntax
Draft

DEV-1450 principled redesign of syntax (WIP)#139
ZmeiGorynych wants to merge 79 commits into
mainfrom
egor/dev-1450-principled-redesign-of-syntax

Conversation

@ZmeiGorynych
Copy link
Copy Markdown
Member

@ZmeiGorynych ZmeiGorynych commented May 21, 2026

Summary

Work-in-progress on DEV-1450 — replacing today's tangled multi-path resolution with a typed pipeline of composable stages: raw → slack normalize → parse_expr → bind_expr → ProjectionPlanner → plan_query → PlannedQuery.

  • 23 commits since main, broken into ~25 numbered stages.
  • The typed pipeline is in place and dormant — generator + engine cutover not yet wired in.
  • Per-stage rhythm: failing tests first → Codex review of tests → implement → unit suite + ruff → Codex review of impl → commit.

See the DEV-1450 progress checkpoints for stage-by-stage detail.

Test plan

  • All non-integration tests green at each stage (currently 3500 passing).
  • SQLite + DuckDB integration tests run by user.
  • Generator slices (7b.7–7b.13) assert SQL parity against the legacy generator via a temporary parity adapter.
  • Engine cutover (7b.15) adds DEV-1445 / DEV-1446 / DEV-1448 / DEV-1449 end-to-end acceptance tests.
  • Bit-identical result keys (orders.revenue_sum, orders._count, orders.customers.regions.name, renamed orders.<user_name>).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Typed multi-stage planning and projection pipeline with cross-model aggregate planning and deterministic filter routing
    • First-class time-dimension support including derived temporal expressions
    • Query transforms (cumsum, change, change_pct, rank, lag/lead) and automatic function-style → colon normalization
    • Structured normalization warnings surfaced in responses
  • Bug Fixes

    • Parametric cross-model aggregate result-key collisions resolved
    • Nested transform/aggregate deduplication improved
    • Model-filter handling for derived SQL fixed; windowed filters rejected
  • Documentation

    • Comprehensive architecture and pipeline docs added/updated
  • Tests

    • Extensive new unit and end-to-end coverage across parsing, binding, planning, and execution

Review Change Stack

ZmeiGorynych and others added 23 commits May 21, 2026 09:44
Add `slayer/core/keys.py` with the typed-key family that the new
resolution pipeline uses for structural identity (P2):

- `ColumnKey(path, leaf)` — row-level base column ref. Local and
  cross-model share this shape; only `path` (empty vs non-empty)
  distinguishes (P3).
- `ColumnSqlKey(model, column_name)` — derived column ref.
- `StarKey` — sentinel source for `*:count`.
- `SqlExprKey(canonical_sql)` — identity for Mode-A fragments
  (used as `AggregateKey.column_filter_key`).
- `AggregateKey(source, agg, args, kwargs, column_filter_key)` —
  unified local + cross-model aggregate identity. Kwargs sorted by
  validator; scalars normalised; `column_filter_key` distinguishes
  same-column-different-filter aggregates.
- `TransformKey(op, input, args, kwargs, partition_keys, time_key)` —
  window / temporal operator over a value. `partition_keys` is a
  frozenset (order-independent).
- `ArithmeticKey(op, operands)` — operand order matters.
- `ScalarCallKey(name, args)` — closed-allowlist scalar call (C12).
- `Phase` IntEnum (ROW=0, AGGREGATE=1, POST=2) — `.phase` property on
  every key; ArithmeticKey/ScalarCallKey take max over operands.
- `SCALAR_FUNCTIONS` frozenset — the closed allowlist.
- `normalize_scalar(value)` — bool/None passthrough; int→Decimal;
  float→Decimal(str(...)) to avoid binary imprecision; Decimal/str
  passthrough; anything else raises TypeError.

All keys are frozen Pydantic models; hashable; usable as dict keys
for slot interning. 60 tests cover identity, hashing, kwarg
canonicalisation, phase composition, and the C12 allowlist contents.

Dormant: no engine code routes through these yet (stages 7a/7b wire
them up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the typed scope kinds (P5) and the resolved-source bundle (P11):

- `slayer/core/scope.py` — `StageColumn` (typed projection element,
  P6), `StageSchema` (flat-namespace projection of one stage that
  downstream stages bind against), `ModelScope` (join-graph-bearing
  scope used during binding).
- `slayer/engine/source_bundle.py` — `ResolvedSourceBundle`: eagerly
  resolved query inputs (source_model + referenced_models +
  inline_extensions + named_queries + query_variables +
  datasource_hint). Built once by the orchestrator; the binder reads
  purely (P11 — no ContextVar machinery).

I2 (anchor-optional, extension injection): `ModelScope.source_model`
and `ResolvedSourceBundle.source_model` are `Optional[SlayerModel]`
from day one. DEV-1450's binder asserts `source_model is not None`
at use sites so behavior is unchanged. The type-level optionality is
the extension point for a future anchor-less mode (where the global
join graph is auto-discovered from `model.column` refs).

25 tests cover: hidden vs visible slots, flat namespace contract
(__-bearing names are flat in StageSchema), lookup helpers,
construction with/without source_model.

Dormant: no engine code routes through these yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the consolidated DEV-1369 join walker out of
`SlayerQueryEngine` into `slayer/engine/path_resolution.py` so the
new binder modules can import it directly without dragging the
engine in.

- New `walk_join_chain(*, source_model, hop_names, resolve_model,
  named_queries, strict_missing_join)` — free function that takes
  the engine's `_resolve_model` as a callback.
- `NoJoinError` sentinel moves with it (the lenient-missing-join
  signal used by dimension-resolution callers).
- `SlayerQueryEngine._walk_join_chain` becomes a thin shim that
  passes `self._resolve_model` to the new function. All existing
  call sites keep their signature.
- `_NoJoinError` is re-imported into the engine module under the
  same name so internal usages don't change.

13 tests pin the contract: cycle detection, strict vs lenient
missing-join, multi-hop returns (terminal_model, first_join),
prefer_data_source threading, named_queries default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lift agg-name collection and parameter resolution out of
enrichment.py and generator.py into
`slayer/engine/agg_registry.py` so the new binder modules don't
have to reach into those tangles. Helpers are pure: given a model
+ a resolve_join_target callback, they produce structured results
without touching storage or spawning side maps.

- `collect_reachable_agg_names(source_model, resolve_join_target,
  named_queries)` — BFS the join graph for custom aggregation
  names (lifted from `enrichment.py:_collect_reachable_agg_names`).
  Cycle-safe via visited set.
- `is_known_aggregation_name(name, custom_names)` — built-in or in
  the custom set.
- `resolve_aggregation(name, available_aggs)` — find the
  `Aggregation` definition for a name, returning None when only a
  built-in default should apply.
- `required_params_for(agg_name)` — required built-in params
  (e.g. `weighted_avg` requires `weight`).
- `merge_agg_params(agg_def, query_kwargs)` — combine defaults
  with query-time overrides.

25 tests cover the BFS, custom-name lookup, builtin-override
resolution, and param merging. Dormant: existing enrichment /
generator code still inlines its own logic; stages 7a/7b switch
over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the typed error / warning classes for the new pipeline with a
stable str() format so snapshot tests can pin the contract:

    <ErrorName>: <one-line summary>
      at <location>
      scope: <short scope summary>
      suggestion: <did-you-mean>

Errors (subclass `SlayerError`):
- `UnknownReferenceError(name, scope_kind, scope_summary, suggestion)`
- `AmbiguousReferenceError(name, candidates)` — sorts candidates
- `IllegalScopeReferenceError(name, scope_kind, reason)` — covers
  C8 (`__` in ModelScope) and DEV-1449 (dotted refs against
  StageSchema)
- `IllegalWindowInFilterError(filter_expr, source, suggestion)` —
  raw `OVER(...)` in DSL filter OR filter naming windowed
  `Column.sql` (predicate promotion was removed in DEV-1336)
- `AggregationNotAllowedError(column, agg, reason)` — type-bucket
  / PK / allowed_aggregations violations
- `UnknownFunctionError(name, location, suggestion)` — Mode-B call
  outside SCALAR_FUNCTIONS / transforms / aggregations (C12)
- `MeasureRecursionLimitError(chain, limit)` /
  `MeasureCycleError(chain)` — named-measure expansion guards
- `DuplicateMeasureNameError(name, occurrences)` /
  `MeasureNameCollidesWithColumnError(name, model)` /
  `CanonicalAliasShadowsColumnError(formula, canonical, model)` —
  DEV-1443 alias-collision validations
- `UnreachableFilterDroppedWarning(filter_text, reason)` — warning,
  emitted by the cross-model planner when it drops an unreachable
  host filter from a sub-query CTE

Slack normalization payload + carrier (`slayer/core/warnings.py`):
- `NormalizationWarning` — Pydantic payload with `rule_id`,
  `original`, `normalized`, `location`, `rule_doc_url`.
- `SlayerNormalizationWarning` — UserWarning carrier so callers
  can route via `warnings.catch_warnings()` AND via the structured
  `SlayerResponse.warnings` list (stage 6 wiring).

36 tests snapshot str(error) for every class and verify the
warning carrier roundtrips through `warnings.warn(...)`.

Dormant: no code raises these yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ED_MEASURE)

Add `slayer/engine/normalization.py` and wire it into the engine.
The layer rewrites tolerant-but-unambiguous agent input to canonical
form before the typed pipeline sees it (P0), and emits structured
`NormalizationWarning` payloads alongside the existing in-tree
UserWarnings (which still fire from `_rewrite_funcstyle_aggregations`
until stage 7b removes them).

Active rules in stage 6:

- `FUNC_STYLE_AGG` (Mode B): `sum(revenue)` → `revenue:sum`;
  `count(*)` → `*:count`; `percentile(amount, p=0.5)` →
  `amount:percentile(p=0.5)`. Applied to `ModelMeasure.formula`,
  `SlayerQuery.measures[].formula`, and `SlayerQuery.filters`
  entries. Custom model-level aggregation names are recognised in
  `normalize_model` via the model's `aggregations` list.

- `MISPLACED_MEASURE` (query shape): bare entries in
  `SlayerQuery.measures` that resolve as a column on the model
  (and not as a `ModelMeasure` name) move to
  `SlayerQuery.dimensions`. Mirrors the existing
  `_auto_move_fields_to_dimensions` heuristic but emits the
  structured warning.

- `DOT_PATH_IN_SQL` (Mode A): wired but stubbed — returns input
  unchanged. Full sqlglot-AST, scope-aware rewrite lands in a
  follow-up. The slot is wired so downstream activation needs no
  plumbing changes.

Engine wiring (`slayer/engine/query_engine.py`):

- `SlayerResponse.warnings: List[NormalizationWarning]` additive
  field (default empty).
- `_execute_pipeline` runs `normalize_query` immediately after the
  source model is resolved (so MISPLACED_MEASURE has model
  context); rewrites flow into the existing
  `_auto_move_fields_to_dimensions` + enrichment, which see
  already-canonical input and silently no-op.
- All three `SlayerResponse(...)` construction sites (dry_run,
  explain, normal execute) propagate `warnings=slack_warnings`.
- `save_model` runs `normalize_model` before persistence so stored
  formulas land in canonical form.

17 tests cover FUNC_STYLE_AGG rewrites + warning emission,
MISPLACED_MEASURE detection + move, custom-agg recognition,
canonical-input no-op behavior, engine wiring through
`engine.execute(..., dry_run=True)` and `engine.save_model(...)`.
Total: 3056 unit tests passing, no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…threading

Codex review of stages 1-6 surfaced two important bugs. Fix both
plus pin the contract with new tests.

1. **bool vs Decimal interning collision in typed keys** (stage 1).
   Python collapses ``True == 1 == Decimal('1')`` and the same for
   ``False`` / ``0`` at both equality and hashing. So
   ``AggregateKey(args=(True,))`` previously interned with
   ``AggregateKey(args=(Decimal('1'),))`` — silently sharing a slot
   for two semantically different aggregations
   (``percentile(p=True)`` vs ``percentile(p=1)``).

   Add `_typed_leaf` / `_typed_args` / `_typed_kwargs` helpers in
   `slayer/core/keys.py` that wrap scalar leaves as
   ``(type_tag, value)`` at hash/eq time without changing the
   stored representation. Override `__hash__` and `__eq__` on
   `AggregateKey`, `TransformKey`, and `ScalarCallKey` to use them.

   `ColumnKey` / `ColumnSqlKey` / `StarKey` / `SqlExprKey` are
   unaffected (they don't take scalar args).

   4 new tests in `tests/test_keys.py::TestBoolDecimalDisambiguation`
   pin the contract across all three key classes.

2. **custom_agg_names not threaded to normalize_query** (stage 6).
   The engine called `normalize_query(query, model=model)` without
   custom aggregation names, so a slack `custom_sum(revenue)` in a
   query measure or filter was left to the legacy
   `_rewrite_funcstyle_aggregations` rewrite (which still
   functions correctly) but didn't surface a structured warning in
   `SlayerResponse.warnings`.

   Collect custom names from `model.aggregations` in
   `_execute_pipeline` and pass them through. Joined-model custom
   aggs are still out of scope (need the full reachable-agg-names
   walk) — that arrives with stage 7a's binder; the legacy rewrite
   continues to catch them until then.

   1 new test in `tests/test_slack_normalization.py` verifies the
   structured warning surfaces for a model-defined custom aggregation.

Codex finding 3 (FUNC_STYLE_AGG on Mode-A filters) is a false
positive: `SlayerQuery.filters` is Mode B per CLAUDE.md / DEV-1378.
Mode-A filters live on `Column.filter` and `SlayerModel.filters`,
which the normalization layer does not touch.

Codex finding 4 (backslash-escaped quote handling in literal-span
detection) is a minor edge case for SQL dialects that allow ``\\'``
inside literals; noted as a follow-up.

Net: 3061 unit tests passing (was 3056), no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `slayer/engine/planned.py` with the typed shapes consumed by the
SQL generator after stage 7b cutover. Dormant — no engine code
routes through them yet.

- `ValueSlot(id, key, declared_name, public_name, public_aliases,
  hidden, phase, label, type, expression)` — one materialised slot.
  `key` is structural identity; rendering metadata lives here. P4 /
  C13 multi-alias supported. Hidden invariant enforced by a model
  validator: hidden slots must not carry public_name / public_aliases.
- `JoinRequirement(source_model, target_model, join_pairs,
  join_type)` — one hop in a cross-model chain. `join_type` defaults
  to LEFT to match `ModelJoin`. Non-empty + well-formed `join_pairs`
  validated.
- `CrossModelAggregatePlan(aggregate_slot_id, target_model,
  datasource, join_chain, join_back_pairs, cte_stage_schema,
  shared_grain_slots, applied_filter_ids,
  dropped_filter_warnings, hidden, public_alias)` — plan for one
  cross-model aggregate slot (P3). Strategy-agnostic: the I1
  CrossModelPlanner Protocol (stage 7a.2) populates this struct;
  the struct doesn't lock in isolated-CTE.
- `TransformLayer(op, slot_ids)` — one transform layer; generator
  picks the render strategy per op (window function vs self-join CTE).
- `FilterPhase(id, phase, text, expression)` — bound filter routed
  by phase (P8). `expression` carries the renderable payload so the
  generator never re-parses text.
- `OrderEntry(slot_id, direction)` — ORDER BY entry; strict
  lowercase `asc`/`desc` since OrderEntry is planner-produced.
- `BoundExpr(value_key, sql_text)` — minimum scaffold for the
  bound-expression payload that stage 7a.5's binder will fill in.
  Carried by ValueSlot.expression and FilterPhase.expression.
- `PlannedQuery(source_relation, join_plan, row_slots,
  aggregate_slots, cross_model_aggregate_plans,
  combined_expression_slots, transform_layers, filters_by_phase,
  projection, order, limit, offset, stage_schema)` — the
  fully typed plan that the SQL generator consumes.

Codex review (round 1) on the new module surfaced three important
items: FilterPhase lacked a bound-expression payload (fixed via
BoundExpr scaffold), JoinRequirement was missing join_type (fixed),
ValueSlot hidden invariant was not enforced (fixed via model
validator). All addressed. 32 unit tests pin the contract,
including the hidden invariant, join_type defaults, OrderEntry case
strictness, and BoundExpr propagation through slots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the no-op stub of `_apply_dot_path_in_sql` with an AST-based,
scope-aware Mode-A rewrite over `Column.sql`, `Column.filter`, and
`SlayerModel.filters`. Root-scope `<join>.<intermediate>.<leaf>` refs
collapse to `<join>__<intermediate>.<leaf>`; first-segment shadowing by
CTE name, AS alias, Subquery/CTE source, or FROM-table schema/catalog
qualifier emits an ambiguity warning without rewriting. Refs inside
subqueries / CTE bodies / set-op branches are left alone via lexical
ancestor walking. Multi-statement slack input is a no-op.

Wired through `normalize_model`. The legacy regex
`slayer.core.models._fix_multidot_sql` still runs at construction time
and pre-empts most real-world inputs; Stage 7b deletes the regex and
hands full ownership to this rule.

32 new tests in `tests/test_dot_path_in_sql.py`. Full unit suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (I1)

Adds slayer/engine/cross_model_planner.py with:
- CrossModelPlanner Protocol (substitutable strategy).
- IsolatedCteCrossModelPlanner — default impl encoding the
  inherited_filter_policy decision table.
- HostFilterRouting input + classify_host_filter() helper.
- FilterRoute enum: DROP_HOST_LOCAL / PROPAGATE_WHERE /
  PROPAGATE_HAVING / DROP_UNREACHABLE / STAY_AT_HOST_POST.

Extends CrossModelAggregatePlan in planned.py with route-explicit
fields the SQL generator (7b) needs without re-classifying:
- where_filter_ids / having_filter_ids / target_model_filters.

Multi-hop semantics: join_back_pairs and CTE schema reference the
FIRST hop's target grain (e.g. customers.id for
orders→customers→regions), while the aggregate output column's
type is sourced from the terminal model.

ColumnSqlKey routing uses host_model_name to distinguish host-local
derived columns (DROP_HOST_LOCAL), derived columns on the target
path (PROPAGATE_WHERE), and derived columns on other branches
(DROP_UNREACHABLE).

32 new tests covering Protocol substitution, single + multi-hop join
chains, every row of the decision table, ColumnSqlKey routing, edge
cases (unknown slot id, empty refs). Full unit suite green.

Dormant — no engine wiring. Stage 7a.6's ProjectionPlanner is the
first consumer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds slayer/engine/syntax.py with parse_expr() — pure syntax for the
Mode-B DSL (no scope resolution, no named-measure expansion, no slack
rewriting; those are upstream).

ParsedExpr family: Ref / DottedRef / StarSource / Literal / AggCall /
TransformCall / ScalarCall / Arith / UnaryOp / Cmp / BoolOp. All
frozen Pydantic models with value-based equality.

Pipeline: colon preprocess (skipping string-literal spans) → Python
ast.parse → AST walk to typed nodes. Closed allowlist for scalar
functions (SCALAR_FUNCTIONS in keys.py); transforms from ALL_TRANSFORMS;
aggregations via placeholder lookup. AST-level dunder rejection across
Name / Attribute / keyword.arg positions — robust to string literals.

Rejections (raises):
- Unknown function call → UnknownFunctionError.
- Raw OVER(...) → IllegalWindowInFilterError.
- `__` in user identifier → ValueError.
- Chained comparisons (`1 < x < 10`) → ValueError.
- Function-style aggregations (`sum(revenue)`) → UnknownFunctionError
  (slack normalization owns the rewrite; if it reaches the parser,
  that's a bypass).
- Scalar-function kwargs (`lower(name=...)`) → ValueError.

62 tests in tests/test_syntax.py. Full unit suite green: 3219 passed.

Dormant — no engine wiring. Stage 7a.5 binder is the first consumer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds slayer/sql/sql_expr.py with:
- parse_sql_expr(text, *, dialect=None) → SqlExprKey
- canonicalize_sql(text, *, dialect=None) → str
- has_window_function(text) → bool (re-export)
- assert_no_window_in_filter(text, *, source) → None / raises

parse_sql_expr wraps as `SELECT (<text>) AS _` before sqlglot parsing,
then strips the wrapper paren. Mirrors the generator's predicate-parse
trick — necessary because sqlglot's SQLite/MySQL parser otherwise
falls back to a Command node for top-level `replace(...)` and other
keyword conflicts.

Dialect-specific rewrites preserved through the canonical key:
- SQLite json_extract function form (vs `->` operator)
- log10 / log2 preservation for dialects with native single-arg aliases
  (allowlist duplicated from slayer/sql/generator.py — comments in
  both call out the sync point; consolidation deferred).

23 tests in tests/test_sql_expr.py. Full unit suite green.

Dormant — no engine wiring. Stage 7a.5 binder is the first consumer
(AggregateKey.column_filter_key).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds slayer/engine/binding.py with:
- bind_expr(parsed, *, scope, bundle) → BoundExpr
- bind_filter(parsed, *, scope, bundle) → BoundFilter
- walk_value_keys(key) — traverse helper for referenced-key collection

Two scope kinds (P5):
- ModelScope: dotted refs walk the join graph via bundle. C14 self-
  prefix stripped before walk. `__` rejected unless it exact-matches a
  column on the model.
- StageSchema: flat namespace; dotted refs raise
  IllegalScopeReferenceError.

FilterBinder layers phase classification (Phase.ROW / AGGREGATE / POST
= max of referenced slot phases) plus IllegalWindowInFilterError when
a filter references a ColumnSqlKey whose Column.sql contains a window
function (DEV-1369: no auto-promotion).

keys.py: add LiteralKey to ValueKey union for arithmetic over scalars
(`amount + 1`); add `path: Tuple[str, ...]` to ColumnSqlKey so joined
derived columns carry the same path-bearing identity ColumnKey has.

Defence-in-depth: _bind_scalar re-checks SCALAR_FUNCTIONS in case the
parser was bypassed via direct ParsedExpr construction.

31 tests in tests/test_binding.py. Full unit suite green: 3273 passed.

Dormant — no engine wiring. Stage 7a.6 planner is the first consumer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds slayer/engine/planning.py with three composable concerns:

1. ValueRegistry — interns ValueKeys by structural identity. Two
   structurally-equal keys share one ValueSlot (P2). Same key declared
   with multiple `name`s accumulates multiple public_aliases on one
   slot (P4 / C13). Alias-collision validations preserved from DEV-1443:
   DuplicateMeasureNameError, MeasureNameCollidesWithColumnError,
   CanonicalAliasShadowsColumnError.

2. desugar_change / desugar_change_pct — lower sugar transforms to
   `x - time_shift(x)` / `(x - time_shift(x)) / time_shift(x)`. The
   inner aggregate keeps the same ValueKey instance across both
   operands so the registry interns it once (DEV-1446). `partition_by`
   from the sugar form threads through to the underlying time_shift
   (C6) — the binder lifts it onto TransformKey.partition_keys, the
   lowerer passes it through.

3. ProjectionPlanner — allocates slots for declared measures + creates
   hidden slots for refs that appear ONLY in order/filter. Slot
   dependency selection (`_iter_slot_deps`) only materialises ColumnKey,
   ColumnSqlKey, AggregateKey, TransformKey — composite nodes
   (ArithmeticKey, ScalarCallKey) and literals stay inlined.

binding.py: _bind_transform now lifts the `partition_by` kwarg onto
TransformKey.partition_keys; non-column partition_by values are
rejected with a clear error.

26 tests in tests/test_value_registry.py / test_transform_lowerer.py /
test_projection_planner.py. Full unit suite green: 3299 passed.

Dormant — no engine wiring. Stage 7a.7 stage_planner is the first
consumer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds slayer/engine/stage_planner.py with:
- plan_query(*, query, bundle, scope=None, ...) → PlannedQuery
  - Parses + binds measures / dimensions / filters / order against
    the supplied scope (ModelScope by default, StageSchema for
    downstream stages).
  - Runs ProjectionPlanner + assembles a PlannedQuery with stage_schema.

- plan_stages(*, queries, bundle, ...) → List[PlannedQuery]
  - Topologically sorts via Kahn's algorithm (rejects duplicate stage
    names and cycles).
  - Per-stage StageSchema becomes the binding scope for downstream
    stages (DEV-1449: dotted refs in downstream stages raise
    IllegalScopeReferenceError).
  - User-supplied `name` on a measure becomes the StageSchema column
    alias (DEV-1448). Multi-alias declarations (same key, two names)
    emit one StageSchema column per alias.

ValueRegistry: exempts self-named dimensions (ColumnKey(leaf=X)
declared as X) from MeasureNameCollidesWithColumnError — the
declaration is the column itself, not a rename.

10 tests in tests/test_stage_planner.py covering:
- single-stage smoke (measure + dimension + filter),
- DEV-1448 (named measure → schema column alias),
- DEV-1449 (downstream flat refs; dotted refs rejected),
- topo sort (duplicate name and cycle rejection),
- multi-alias StageSchema columns.

Full unit suite green: 3309 passed.

Dormant — no engine wiring. cross_model_planner parameter is built
but not yet invoked from PlannedQuery construction; Stage 7b wires
the full cross-model-aggregate plan emission. Same for BoundExpr
payload propagation onto ValueSlot / FilterPhase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move {var} placeholder substitution out of the legacy enrichment path
(slayer/engine/enrichment.py:1162) into a small, pipeline-friendly
slayer/engine/variables.py module. Dormant in this commit — wired in by
stage 7b.15 (engine cutover).

Public surface:
- merge_query_variables(*, runtime, stage, outer, model_defaults):
  4-layer merge replacing the legacy 3-layer _merge_query_variables.
  Precedence runtime > stage > outer > model_defaults; None / empty
  layers act as identities; inputs unmutated.
- apply_variables_to_query(*, query, variables=None, dry_run_placeholders=False):
  returns a fresh SlayerQuery copy with {var} substituted in `filters`.
  Always copies (no shared empty-list aliasing). variables=None is
  normalized to empty dict. dry_run_placeholders=True fills missing
  valid placeholders with "0" but does NOT mask invalid names.

Scope deliberately matches legacy: only SlayerQuery.filters is
substituted. Formula text, Column.sql, Column.filter, and
SlayerModel.filters are not variable-substituted today and this module
preserves that contract.

39 new tests in tests/test_variables_planner.py cover precedence,
escape (`{{` / `}}`), invalid names, unmatched braces, dry-run +
invalid-name interaction, idempotence, copy semantics, and re-export
identity. Codex review of tests caught HIGH#1 (copy vs identity) and
HIGH#2 (explicit idempotence test); both folded in. Codex review of
impl caught two LOW findings (empty-list aliasing and Optional
variables); both folded in.

Full unit suite green (3348 passed, 2 skipped); ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New slayer/engine/measure_expansion.py: AST-to-AST rewrite of a
ParsedExpr tree. Every Ref(name=X) whose X resolves to a ModelMeasure
(on the model or in extra_measures) is replaced with the recursively-
expanded ParsedExpr of that measure's formula.

Per Codex F4 fold-in on the DEV-1450 plan: expansion lives PRE-BIND, not
planner-layer. The binder (slayer/engine/binding.py:341-354) currently
raises UnknownReferenceError for bare measure names; this module runs
ahead of the binder and rewrites those refs into binder-resolvable AST
shapes.

Public API:
    expand_model_measures(
        *,
        expr: ParsedExpr,
        model: SlayerModel,
        extra_measures: Sequence[ModelMeasure] = (),
        depth_limit: Optional[int] = None,
    ) -> ParsedExpr

Eligible positions: root; Arith / UnaryOp / Cmp / BoolOp operands;
ScalarCall.args; TransformCall.input / args / kwarg values.

Not eligible: DottedRef (cross-model paths), AggCall in any position
(source / args / kwargs are column-level by contract), function-name
slots on TransformCall.op / ScalarCall.name, Literal, StarSource,
SlayerQuery.order entries.

Depth limit: SLAYER_MEASURE_EXPANSION_DEPTH env var (default 32);
explicit kwarg wins; <1 raises ValueError. Exceeded chain raises
MeasureRecursionLimitError. Per-chain cycle detection raises
MeasureCycleError with the full traversal chain.

Per-call memoization of parsed measure formulas (Codex impl review
MEDIUM#1). _PARSED_EXPR_TYPES tuple derived from get_args(ParsedExpr) so
future syntax additions are auto-walked (Codex LOW#5).

41 new tests in tests/test_model_measure_expansion.py cover the
eligibility matrix, recursion / cycle semantics, depth-limit boundaries
(default 32 succeeds at 32, fails at 33), env-var override + explicit
override precedence, extra_measures shadowing model measures, purity
under re-expansion, and pre-bind "does not validate columns" contract.

Full unit suite green (3389 passed, 2 skipped); ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add slayer/core/keys.py::TimeTruncKey — row-phase ValueKey identifying
a time-truncated column by (column: ColumnKey, granularity: str). The
underlying column is recoverable via key.column so date-range filters
(stage 7b.3c) can bind against the raw column independently of the
truncation.

Identity is structural: same (column, granularity) interns to one slot;
different granularities on the same column are distinct slots. The
ValueRegistry can keep month / day / raw uses of the same column as
separate materialised values without special-casing — Codex's
recommendation (Option A) over a granularity-on-slot or
TransformKey(op='date_trunc') encoding.

Granularity stored as the str value of a TimeGranularity StrEnum so
the key stays pure data without an enum import at the keys layer.

TimeTruncKey added to the ValueKey union so binders and planners that
dispatch on isinstance(key, ValueKey) see it.

Dormant — no engine wiring yet. Stage 7b.3b adds bind_time_dimension +
planner integration; 7b.3c adds main-TD resolution and date_range ->
filter conversion.

12 new tests in tests/test_time_trunc_key.py cover construction,
identity (same / different grain / different column / cross-model
path), phase=ROW, frozen-immutability, and ValueKey union membership.

Full unit suite green (3401 passed, 2 skipped); ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires SlayerQuery.time_dimensions through to TimeTruncKey slots in the
new pipeline.

New: bind_time_dimension(td, scope, bundle) in slayer/engine/binding.py.
Resolves the underlying column via the existing identifier / dotted-ref
binders, validates it's a base ColumnKey with a temporal (DATE/TIMESTAMP)
type, and returns a BoundExpr whose value_key is
TimeTruncKey(column, granularity). Derived Column.sql temporal columns
(which would resolve to ColumnSqlKey) raise NotImplementedError with a
clear message — TimeTruncKey.column is typed as ColumnKey and widening
it is deferred to a follow-up. StageSchema scopes raise
IllegalScopeReferenceError (downstream stages see the upstream's
already-truncated column as a flat name).

Planner: TimeTruncKey added to _SLOTTABLE_KIND, _iter_slot_deps, and
_canonical_name. The TimeTruncKey itself is the materialized slot
(generator emits DATE_TRUNC at SELECT time); the inner ColumnKey is NOT
yielded as a separate dep — adding a TD must not auto-add the raw
column as a separate output (matches legacy). Canonical name drops the
granularity suffix to match legacy alias contract
(EnrichedTimeDimension.alias = f"{model}.{td.dimension.full_name}").

ValueRegistry: extended the self-named-dimension exemption to cover a
local TimeTruncKey over a same-named column, so declaring a TD on
created_at doesn't trip MeasureNameCollidesWithColumnError. Joined TDs
flatten through __ paths and so don't collide with host source columns.

stage_planner.plan_query: _declared_measures_from_query now iterates
query.time_dimensions between dimensions and measures (legacy
user_projection order: dims → time dims → measures). Each TD becomes a
DeclaredMeasure with flat declared_name / public_name; label propagates.

Codex review of tests caught 4 findings: added joined-TD label
propagation, multi-hop joined TD coverage (orders → customers →
regions), tightened stage schema membership assertions to exact-match
shape, and explicit derived-Column.sql rejection coverage. Codex review
of impl returned 1 MEDIUM (cross_model_planner TimeTruncKey
classification, deferred to 7b.5 when cross-model TD routing surfaces)
and 2 LOW findings deferred (data_type_bucket reuse would introduce an
import cycle through schema_drift → ingestion → query_engine;
NotImplementedError is acceptable for a documented temporary limit).

26 new tests in tests/test_time_dimensions_planner.py.

3427 unit tests passing, 0 regressions, ruff clean. Integration tests
not run locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two pieces wired into stage_planner.plan_query:

1. ``_build_date_range_filter(td, scope, bundle)`` converts a
   TimeDimension's ``date_range=[start, end]`` into a row-phase
   BoundFilter whose predicate is ``ArithmeticKey("and", (>=, <=))``
   over the BARE underlying ColumnKey. Bound literals normalize via
   ``normalize_scalar`` (strings pass through). The filter binds to
   the raw column, not the TimeTruncKey, so generator slice 7b.11 can
   apply it on the outer projection while the shifted self-join CTE
   reads unfiltered raw data (legacy edge-period semantics for
   time_shift / change / change_pct).

   FilterPhase.expression is populated with PlannedBoundExpr for
   auto-generated filters (date_range has no user text). User-filter
   expression population stays deferred to stage 7b.6
   (BoundExpr type unification).

   Malformed date_range entries (``[]``, ``[single]``) silently no-op,
   matching legacy ``slayer/sql/generator.py:2517``.

2. ``_resolve_main_time_dimension(query, model)`` resolves the active
   TD for transform / windowing semantics. 0 TDs → None; 1 TD →
   that TD (ignores main_time_dimension, matches legacy); 2+ TDs with
   main_time_dimension → match by full_name, fall back to leaf,
   ambiguous leaf raises AmbiguousReferenceError, unknown raises
   UnknownReferenceError. 2+ TDs with model.default_time_dimension →
   leaf match restricted to host-local TDs (legacy
   ``_resolve_time_alias`` returns f"{model}.{default}" so the joined
   case is never selected through that path). Neither → None.

Codex review of impl caught two MEDIUM findings folded in here:
ambiguous-leaf matches now raise AmbiguousReferenceError listing both
candidates rather than returning by query order; default_time_dimension
matching restricts to host-local TDs to preserve legacy alias
semantics.

snap_to_whole_periods ownership stays with SlayerQuery's existing
method called pre-normalization in query_engine._execute_pipeline:595;
the planner consumes already-snapped queries and never re-snaps.

25 new tests in tests/test_time_dimensions_filters.py covering:
helper resolution (0/1/multi TDs, full-name vs leaf precedence,
ambiguity, default-host-vs-joined, unknown default no-op);
date_range filter shape (AND-of-comparisons, row-phase, expression
populated, binds raw ColumnKey not TimeTruncKey, hidden raw-column
slot materialized, joined TD path-bearing); malformed date_range
emits no filter; multiple TDs each emit their own filter; user
filters + date_range coexist with date_range last.

3452 unit tests passing, 0 regressions, ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three changes:

1. Per-op binder validation in slayer/engine/binding.py:_bind_transform.
   New _TRANSFORM_KWARG_RULES whitelist per op (partition_by is
   special-cased and accepted everywhere it makes sense). ntile
   requires positive integer n (rejects bool, float-with-fractional,
   negative, zero); time_shift requires periods; lag/lead default
   periods=normalize_scalar(1) when missing. Transforms take exactly
   one positional argument (the value); anything else raises forcing
   the kwarg form (e.g. ``lag(value, periods=2)`` instead of
   ``lag(value, 2)``). New _fold_to_scalar helper folds Literal and
   UnaryOp(-, Literal) into normalised scalars; non-scalar shapes raise
   (TransformKey.kwargs only accepts Scalar, not arbitrary ValueKey).

2. _iter_slot_deps in slayer/engine/planning.py now walks
   TransformKey.partition_keys and TransformKey.time_key so partition
   columns and time-key columns surface as slot deps. The
   ProjectionPlanner now walks measure deps too and interns them as
   hidden slots, materialising inner aggregates / partition columns /
   time-key columns for the generator to consume.

3. New lower_sugar_transforms(value_key) -> value_key recursive walker
   in planning.py. Replaces TransformKey(op="change"|"change_pct") with
   the desugared arithmetic form, preserving inner aggregate identity
   (DEV-1446). Wired into stage_planner._declared_measures_from_query.
   desugar_change / desugar_change_pct now set
   kwargs=(("periods", normalize_scalar(-1)),) on the produced
   time_shift TransformKey, matching the new typed invariant that
   time_shift requires explicit periods.

4. New _emit_transform_layers in stage_planner — one TransformLayer
   per TransformKey slot in topological dependency order (Kahn's
   algorithm). Nested transforms like ``cumsum(change(amount:sum))``
   emit the inner time_shift layer before the outer cumsum layer; the
   generator slices can render windows / self-joins in the right order
   without re-walking. Per-slot transform metadata (partition_keys /
   time_key / args / kwargs) lives on the slot's TransformKey so
   TransformLayer stays minimal.

Codex review caught: positional-args ambiguity (now rejected), missing
periods in desugar output (now -1 explicitly), and op-grouping
collapsing nested same-op layers (now per-slot topological).

Deferred: partition_by=[list, of, cols] parser syntax (Mode-B parser
extension scope); consecutive_periods.period value validation (defer
until generator slice 7b.11 consumes it).

29 new tests in tests/test_transforms_planner.py covering per-op
validation (ntile.n positive int + bool reject + integer-only;
time_shift periods required; lag default; unknown kwargs on rank-family
+ consecutive_periods; positional-arg rejection); transform aux slot
materialisation (single + multi partition keys, time_key
ColumnKey/TimeTruncKey); transform_layers population (per-slot, topo
order, no op-grouping); change → time_shift lowering identity
preservation (DEV-1446 nested-transform dedup).

3481 unit tests passing, 0 regressions, ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the dormant IsolatedCteCrossModelPlanner into plan_query.

1. New filter_referenced_slot_ids(bound_filter, registry) -> set[SlotId]
   in slayer/engine/planning.py. Walks _iter_slot_deps and looks each
   slottable key up in the registry. Codex HIGH #3/#4 fold-in: does
   not mutate BoundFilter.referenced_keys (those are pre-interning
   ValueKeys); does walk composite predicates so
   "rev >= 100 AND customers.revenue:sum < 500" resolves both leaves.
   Silently skips literals and other non-slottable refs.

2. stage_planner.plan_query now, after filters_by_phase is built,
   constructs HostFilterRouting records (with referenced_slot_ids
   sorted for deterministic plan snapshots — Codex LOW #3 fold-in)
   and iterates aggregate slots. For every AggregateKey slot whose
   source.path is non-empty, invokes cross_model_planner.plan() with
   the host_slots + host_filter_routings + slot.public_name +
   slot.hidden, and appends the resulting CrossModelAggregatePlan to
   PlannedQuery.cross_model_aggregate_plans.

3. cross_model_planner._aggregate_alias now uses
   slayer.core.refs.canonical_agg_name so parameterised aggregates
   (revenue:percentile(p=0.5) vs p=0.95) get distinct CTE column
   aliases (Codex HIGH #1 fold-in). Test exercises this with two
   percentile aggregates.

Deferred to follow-ups:
- Cross-model aggregates with column-valued kwargs (weighted_avg /
  corr crossing a join) — the host-local column ref can't be
  evaluated inside the customers CTE. Known limitation; documented
  for a follow-up issue. (Codex HIGH #2 in the impl review.)
- FilterPhase.text / HostFilterRouting.text population for user
  filters — depends on the BoundExpr unification in stage 7b.6.

12 new tests in tests/test_cross_model_planner_wiring.py covering
filter_referenced_slot_ids (simple column, composite predicate, no
composite-only nodes in result, unknown slot silently skipped);
plan_query wiring (local agg → no plan; cross-model agg → emits plan
with target_model/datasource; aggregate_slot_id matches the slot;
two distinct aggregates emit separate plans; parameterised aggs get
distinct CTE aliases; local filter → DROP_HOST_LOCAL → no propagation;
target model filters propagate); classify_host_filter exercise.

3493 unit tests passing, 0 regressions, ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
slayer.engine.planned.BoundExpr was a separate Pydantic class with an
optional sql_text cache; slayer.engine.binding.BoundExpr was the
binder's typed output. Two classes meant ValueSlot.expression and
FilterPhase.expression couldn't carry the binder's payload without
type widening or a side adapter (Codex HIGH F2 in the earlier round).

Unification:

1. slayer.engine.planned now re-exports slayer.engine.binding.BoundExpr
   as ``BoundExpr``. Single canonical class going forward.

2. The render-artifact ``sql_text`` field is dropped. Generator slices
   render from the typed value_key against the slot registry, not a
   cached string. The branch-new tests/test_planned.py:127
   ``test_with_expression_payload`` is updated to assert against the
   value_key shape instead of sql_text.

3. ValueRegistry.intern now accepts an optional ``expression``
   argument; ValueSlot.expression is populated for every materialised
   slot (public AND hidden) — auto-defaulted to BoundExpr(value_key=key)
   when no explicit binder output is passed.

4. stage_planner.plan_query now populates FilterPhase.expression for
   EVERY filter (user-supplied AND auto-generated date_range), so the
   SQL generator can render filters without re-parsing or consulting
   a side map. The previous auto_filter_ids tracking is removed
   (always-populate is simpler and matches the unification goal).

7 new tests in tests/test_boundexpr_unification.py covering: the type
re-export identity; ValueSlot.expression populated for measures,
dimensions, AND hidden filter-dep slots; FilterPhase.expression
populated for user filters AND aggregate-phase (HAVING) filters; the
expression's value_key matches the slot's / filter's key identity.

3500 unit tests passing, 0 regressions, ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@linear
Copy link
Copy Markdown

linear Bot commented May 21, 2026

DEV-1450

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 21, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a typed resolution pipeline: typed keys/phases, Mode‑B parser and Mode‑A SQL canonicalizer, measure expansion, binder, projection/planning (ValueRegistry, ProjectionPlanner), cross‑model planning, source‑bundle builder, normalization rules, engine wiring, response metadata, sync derived‑column expander, structured errors/warnings, docs, and extensive tests.

Changes

DEV-1450 Typed Pipeline and Planning

Layer / File(s) Summary
Core types and scope
slayer/core/keys.py, slayer/core/scope.py, slayer/core/refs.py
Adds ValueKey family with phases, TimeTruncKey, Aggregate/Transform/Arithmetic/Scalar keys, scalar normalization, StageColumn/StageSchema/ModelScope, and agg-kwarg canonicalization helpers.
Errors, warnings, model validators
slayer/core/errors.py, slayer/core/warnings.py, slayer/core/models.py
Stable _format_error_message and many typed errors, NormalizationWarning/SlayerNormalizationWarning, UnreachableFilterDroppedWarning; removes multi-dot auto-rewrite from model validators and validates filters via sql predicate parsing.
Parsing (Mode‑B) and parsed-ref walker
slayer/engine/syntax.py, tests/test_syntax.py
Introduces Python-AST-based Mode‑B parser, colon preprocessing, parse_filter_expr, and walk_parsed_refs for scope‑free reference extraction.
SQL canonicalization (Mode‑A)
slayer/sql/sql_expr.py, tests/test_sql_expr.py
sqlglot wrapper producing SqlExprKey, dialect rewrites (SQLite json_extract, log10/log2), window detection and assert_no_window_in_filter.
Slack-normalization
slayer/engine/normalization.py, slayer/core/warnings.py, tests/test_slack_normalization.py
FUNC_STYLE_AGG, MISPLACED_MEASURE, DOT_PATH_IN_SQL rules; normalize_query/normalize_model produce NormalizationResult and structured warnings surfaced via warnings and SlayerResponse.warnings.
Measure pre-expansion & schema-drift extraction
slayer/engine/measure_expansion.py, slayer/engine/schema_drift.py, tests/test_model_measure_expansion.py
expand_model_measures with recursion/cycle limits and AST-based formula ref extraction replacing legacy parsing for schema drift.
Binding
slayer/engine/binding.py, tests/test_binding.py
bind_expr/bind_filter/bind_time_dimension produce BoundExpr/BoundFilter, scope-dependent ref resolution (ModelScope vs StageSchema), phase computation, transform/agg binding and validations, window-in-filter rejection.
Planned shapes & projection
slayer/engine/planned.py, slayer/engine/planning.py, tests/test_planned.py, tests/test_projection_planner.py
PlannedQuery/ValueSlot/FilterPhase shapes, ValueRegistry interning, desugar_change/desugar_change_pct, lower_sugar_transforms, ProjectionPlanner, hidden-slot materialization, and filter→slot mapping.
Cross-model planning
slayer/engine/cross_model_planner.py, tests/test_cross_model_planner.py, tests/test_cross_model_planner_wiring.py
CrossModelPlanner protocol, IsolatedCteCrossModelPlanner with host-filter routing, shared-grain computation, CTE schema construction, and optional rerooting.
Source bundle & path resolution
slayer/engine/source_bundle.py, slayer/engine/path_resolution.py, tests/test_source_bundle.py, tests/test_path_resolution.py
ResolvedSourceBundle builder, sibling/synthetic model wiring, transitive referenced_models collection, and async walk_join_chain with NoJoinError.
Engine wiring & response metadata
slayer/engine/query_engine.py, slayer/engine/response_meta.py, tests/*
Integrates normalization into engine pipeline, propagates structured warnings, computes touched models, persists normalized models, builds response metadata/expected columns for rendered SQL.
SQL helpers & derived expansion
slayer/engine/column_expansion.py, tests/test_column_expansion_sync.py
Adds synchronous expand_derived_refs_sync for derived Column.sql inlining and related sync utilities for derived-column expansion.
Docs & navigation
docs/**, mkdocs.yml
New architecture docs (parsing, binding, planning, cross-model aggregates, slack-normalization, errors & warnings, orchestration, typed keys) and updated mkdocs nav.
Tests (comprehensive)
tests/**
Large test surface covering keys, parser, binding, measure expansion, planning, cross-model planner wiring, normalization, response metadata, integration/acceptance tests for DEV-1445/1446/1448/1449/1450 behaviors.

Sequence Diagram(s)

sequenceDiagram
  rect rgba(102, 153, 255, 0.5)
  participant Client
  participant Engine
  end
  rect rgba(153, 204, 255, 0.5)
  participant Storage
  participant SourceBundle
  end
  rect rgba(204, 255, 204, 0.5)
  participant Parser
  participant Binder
  participant Planner
  end
  rect rgba(255, 204, 153, 0.5)
  participant SQLGen
  participant DB
  end

  Client->>Engine: execute(query)
  Engine->>Storage: build_resolved_source_bundle
  Storage-->>Engine: models
  Engine->>Parser: normalize + parse
  Parser-->>Engine: ParsedExpr
  Engine->>Binder: bind_expr/bind_filter/bind_time_dimension
  Binder-->>Engine: BoundExpr/BoundFilter
  Engine->>Planner: plan_query/plan_stages
  Planner-->>Engine: PlannedQuery
  Engine->>SQLGen: generate_from_planned
  SQLGen->>DB: run SQL
  DB-->>Engine: rows
  Engine-->>Client: SlayerResponse + warnings
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • AivanF

Poem

A rabbit taps keys with gentle might,
Parsing colons into tidy light—
It binds and plans through day and night,
Warnings hop and errors write,
Thump—ship the pipeline into flight!

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch egor/dev-1450-principled-redesign-of-syntax

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (8)
tests/test_time_trunc_key.py (1)

109-112: ⚡ Quick win

Specify the exact exception type for frozen instance mutation.

The test uses pytest.raises(Exception) which is too broad. Specify the exact exception type that's raised when attempting to mutate a frozen Pydantic model or dataclass field (typically ValidationError for Pydantic v2 or FrozenInstanceError for frozen dataclasses).

♻️ Proposed fix to specify exception type
+from pydantic import ValidationError
+
 class TestTimeTruncKeyImmutability:
     def test_is_frozen(self) -> None:
         k = TimeTruncKey(column=ColumnKey(leaf="ordered_at"), granularity="month")
-        with pytest.raises(Exception):
+        with pytest.raises(ValidationError):
             k.granularity = "day"  # type: ignore[misc]

Note: Use ValidationError if TimeTruncKey is a Pydantic model, or dataclasses.FrozenInstanceError if it's a frozen dataclass.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_time_trunc_key.py` around lines 109 - 112, The test test_is_frozen
should assert the specific exception type instead of pytest.raises(Exception):
replace the broad Exception with the concrete exception thrown when mutating a
frozen TimeTruncKey (use pydantic.errors.ValidationError /
pydantic.ValidationError if TimeTruncKey is a Pydantic model, or
dataclasses.FrozenInstanceError if it is a frozen dataclass), update the test
import(s) accordingly, and keep the mutating line k.granularity = "day" and
references to TimeTruncKey and ColumnKey unchanged.
slayer/engine/path_resolution.py (1)

75-75: ⚡ Quick win

Prefer more specific type annotation.

The nq variable is assigned a dict value, so nq: dict[str, Any] would be clearer than nq: Any.

♻️ Suggested improvement
-    nq: Any = named_queries if named_queries is not None else {}
+    nq: dict[str, Any] = named_queries if named_queries is not None else {}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/engine/path_resolution.py` at line 75, The local variable `nq` is
annotated too broadly as `Any`; change its annotation to a more specific mapping
type (e.g., `dict[str, Any]`) so callers and linters know it holds a dict
produced from `named_queries`. Update the assignment `nq: Any = named_queries if
named_queries is not None else {}` to use `nq: dict[str, Any] = ...` (or use
`cast(dict[str, Any], named_queries)` if necessary) and ensure `typing` imports
include `Any`/`dict` typing forms compatible with the project's Python version.
tests/test_path_resolution.py (1)

27-34: 💤 Low value

Consider using keyword arguments consistently in test calls.

The _make_model helper has 2 parameters. Per coding guidelines, functions with more than 1 parameter should be called using keyword arguments. Several calls use positional arguments (e.g., lines 160, 175, 191).

Example:

# Current
return _make_model(model_name)

# Preferred per guideline
return _make_model(name=model_name)

As per coding guidelines: "Use keyword arguments for functions with more than 1 parameter"

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_path_resolution.py` around lines 27 - 34, The test helper
_make_model accepts parameters (name, joins=None) but several test calls use
positional args; update all usages to use keyword arguments per guideline: call
_make_model(name=..., joins=...) instead of positional calls (e.g., replace
_make_model(model_name) with _make_model(name=model_name) and any two-argument
positional calls with explicit joins=...), locating usages by the function name
_make_model in the tests and editing each call.
tests/test_source_bundle.py (1)

20-29: 💤 Low value

Consider using keyword arguments consistently in test calls.

The _model helper has 2 parameters. Per coding guidelines, functions with more than 1 parameter should be called using keyword arguments for clarity. Multiple calls throughout this file use positional arguments (e.g., lines 39, 49, 50, 56, 64, 74, 83, etc.).

Example:

# Current
m = _model("orders")
m = _model("orders", ds="warehouse")

# Preferred per guideline
m = _model(name="orders")
m = _model(name="orders", ds="warehouse")

As per coding guidelines: "Use keyword arguments for functions with more than 1 parameter"

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_source_bundle.py` around lines 20 - 29, The test helper
_model(name: str, ds: str = "prod") is being invoked with positional arguments
in multiple places; update all calls to use keyword arguments (e.g.,
_model(name="orders") or _model(name="orders", ds="warehouse")) so they follow
the guideline for functions with more than one parameter; search for usages of
_model in tests/test_source_bundle.py and replace positional invocations with
keyword form, leaving the SlayerModel construction (and related Column/DataType
references) unchanged.
tests/test_boundexpr_unification.py (1)

161-161: ⚡ Quick win

Move function-local imports to module scope.

Line 161 and Line 177 import inside test methods; this violates the repo rule to keep imports at the top of the file.

Suggested diff
 from slayer.core.enums import DataType
-from slayer.core.keys import AggregateKey, ColumnKey
+from slayer.core.keys import AggregateKey, ArithmeticKey, ColumnKey
 from slayer.core.models import Column, SlayerModel
 from slayer.core.query import SlayerQuery
 from slayer.engine.binding import BoundExpr as BinderBoundExpr
+from slayer.engine.binding import walk_value_keys
 from slayer.engine.planned import BoundExpr as PlannedBoundExpr
 from slayer.engine.source_bundle import ResolvedSourceBundle
 from slayer.engine.stage_planner import plan_query
@@
-        from slayer.core.keys import ArithmeticKey
         assert isinstance(fp.expression.value_key, ArithmeticKey)
@@
-        from slayer.engine.binding import walk_value_keys
         keys = list(walk_value_keys(fp.expression.value_key))

As per coding guidelines: "**/*.py: Place imports at the top of files".

Also applies to: 177-177

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_boundexpr_unification.py` at line 161, Move the function-local
imports to module scope: remove the in-test import of ArithmeticKey (from
slayer.core.keys) and any other imports currently performed inside test
functions and place them at the top of tests/test_boundexpr_unification.py;
update the module-level import section to include "from slayer.core.keys import
ArithmeticKey" (and any other symbols currently imported inside tests) so tests
reference those symbols without local imports.
tests/test_slack_normalization.py (2)

249-257: ⚡ Quick win

Avoid run_until_complete in pytest async suites.

The fixture manually drives the loop; this is fragile when a loop is already managed by pytest-asyncio. Prefer an async fixture and await the setup calls directly.

As per coding guidelines "**/tests/**/*.py: Tests must use pytest-asyncio with asyncio_mode = \"auto\" so test functions can be async def and await directly."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_slack_normalization.py` around lines 249 - 257, The test uses
asyncio.get_event_loop().run_until_complete to call storage.save_datasource and
storage.save_model, which conflicts with pytest-asyncio-managed loops; change
the fixture to be async (make the fixture function async def) and replace those
run_until_complete calls with direct awaits: await
storage.save_datasource(DatasourceConfig(...)) and await
storage.save_model(_orders()), ensuring the fixture yields/returns as before so
pytest-asyncio (asyncio_mode="auto") manages the event loop correctly.

225-229: ⚡ Quick win

Move test imports to module scope.

These in-function imports make the test module inconsistent with repo style and harder to scan. Please hoist them to the top-level import block.

As per coding guidelines "**/*.py: Place imports at the top of files."

Also applies to: 249-257, 263-271, 287-295, 314-318, 343-351

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_slack_normalization.py` around lines 225 - 229, Several tests in
test_slack_normalization.py contain in-function imports (e.g., DatasourceConfig,
SlayerQueryEngine, YAMLStorage, sqlite3) — hoist these imports to the
module-level import block at the top of the file, remove the now-redundant local
imports inside the test functions, and consolidate/organize them with the
existing top imports; repeat this change for the other in-function import sites
flagged (around the commented ranges) to comply with the project import style
and keep test-scoped symbols available to all functions.
tests/test_dot_path_in_sql.py (1)

474-474: ⚡ Quick win

Hoist local imports to the module import section.

These test-local imports should be moved to top-level to match project conventions.

As per coding guidelines "**/*.py: Place imports at the top of files."

Also applies to: 489-490

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_dot_path_in_sql.py` at line 474, The test contains local imports
(e.g., "from slayer.core.models import ModelMeasure" and the other imports
around the later test block) that should be hoisted to the module-level import
section; move these local imports to the top of the file with the other imports,
remove the in-function/local import statements, and run the tests to ensure no
circular import issues—if a circular dependency appears, refactor the import to
a lazy import helper or import only the needed attribute at top-level.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@slayer/engine/binding.py`:
- Around line 155-156: The call to _bind uses a positional first argument;
change it to use keyword arguments for all parameters (e.g.,
_bind(parsed=parsed, scope=scope, bundle=bundle, in_filter=False)) and update
every other _bind invocation in this module (including the ones around the other
mentioned call sites) to the same keyword style so it conforms to the repo rule;
keep the surrounding return (BoundExpr(value_key=...)) unchanged.

In `@slayer/engine/syntax.py`:
- Around line 180-188: The current check uses _OVER_RE.search(text) and flags
any "OVER(" even when inside string literals (e.g. "status == 'OVER('"); change
the check to ignore matches inside quotes by scanning text
character-by-character tracking single-quote, double-quote, and escape state and
only applying _OVER_RE when not inside a quoted string, then raise
IllegalWindowInFilterError(filter_expr=text, source=..., suggestion=...) as
before; update the code path that currently calls _OVER_RE.search(text) (the
block raising IllegalWindowInFilterError) so it uses the new quote-aware
detection routine on the variable text.
- Around line 409-412: The code silently ignores keyword unpacking (**kwargs)
because the comprehension in _convert_call filters out ast.keyword entries with
arg is None; update _convert_call to detect any kw in node.keywords with kw.arg
is None and raise a clear error (e.g., ValueError or a parsing-specific
exception) rejecting **kwargs in Mode-B call parsing instead of dropping them,
then proceed to build kwargs by converting remaining keywords via _convert;
reference node.keywords, _convert_call, and _convert when implementing the check
and error raise.

In `@tests/test_agg_registry.py`:
- Around line 62-64: Update the test calls in tests/test_agg_registry.py to use
keyword arguments instead of positional arguments for helper functions with more
than one parameter: change calls to collect_reachable_agg_names(...),
resolve_aggregation(...), merge_agg_params(...), and any other multi-parameter
helper invocations (including the occurrences at lines noted in the comment) so
that each argument is passed as param_name=value (e.g., m=...,
resolve_join_target=..., named_queries=...) rather than by position; ensure you
update all instances mentioned (around the ranges 62-64, 79-81, 99-101, 117,
177, 183, 230, 238, 247, 257, 264) and run tests to confirm no signature
mismatches.

In `@tests/test_binding.py`:
- Around line 145-147: Update all calls to bind_expr and bind_filter (and
similar multi-parameter helpers) to pass the parsed expression/result as a
keyword instead of a positional first argument; e.g., replace
bind_expr(parse_expr("amount"), scope=_scope(), bundle=_bundle()) with a keyword
form like bind_expr(parsed=parse_expr("amount"), scope=_scope(),
bundle=_bundle()) (and do the same for bind_filter), applying this change at
every location listed in the comment so all multi-parameter calls use keyword
arguments for clarity and to follow the repository standard.

In `@tests/test_stage_planner.py`:
- Around line 203-220: The test test_stages_in_dependency_order currently
constructs stage1 and stage2 but passes them to plan_stages in dependency order,
so it doesn't verify reordering; update the test to pass stages in reverse order
(pass stage2 then stage1) to force the planner to reorder, and add an assertion
that the planned result is topologically correct (e.g., check planned[0].name ==
"stage1" and planned[1].name == "stage2" or equivalent checks on the planned
stages returned by plan_stages) so the test fails if plan_stages does not
reorder dependencies; references: test_stages_in_dependency_order, plan_stages,
SlayerQuery, planned.

---

Nitpick comments:
In `@slayer/engine/path_resolution.py`:
- Line 75: The local variable `nq` is annotated too broadly as `Any`; change its
annotation to a more specific mapping type (e.g., `dict[str, Any]`) so callers
and linters know it holds a dict produced from `named_queries`. Update the
assignment `nq: Any = named_queries if named_queries is not None else {}` to use
`nq: dict[str, Any] = ...` (or use `cast(dict[str, Any], named_queries)` if
necessary) and ensure `typing` imports include `Any`/`dict` typing forms
compatible with the project's Python version.

In `@tests/test_boundexpr_unification.py`:
- Line 161: Move the function-local imports to module scope: remove the in-test
import of ArithmeticKey (from slayer.core.keys) and any other imports currently
performed inside test functions and place them at the top of
tests/test_boundexpr_unification.py; update the module-level import section to
include "from slayer.core.keys import ArithmeticKey" (and any other symbols
currently imported inside tests) so tests reference those symbols without local
imports.

In `@tests/test_dot_path_in_sql.py`:
- Line 474: The test contains local imports (e.g., "from slayer.core.models
import ModelMeasure" and the other imports around the later test block) that
should be hoisted to the module-level import section; move these local imports
to the top of the file with the other imports, remove the in-function/local
import statements, and run the tests to ensure no circular import issues—if a
circular dependency appears, refactor the import to a lazy import helper or
import only the needed attribute at top-level.

In `@tests/test_path_resolution.py`:
- Around line 27-34: The test helper _make_model accepts parameters (name,
joins=None) but several test calls use positional args; update all usages to use
keyword arguments per guideline: call _make_model(name=..., joins=...) instead
of positional calls (e.g., replace _make_model(model_name) with
_make_model(name=model_name) and any two-argument positional calls with explicit
joins=...), locating usages by the function name _make_model in the tests and
editing each call.

In `@tests/test_slack_normalization.py`:
- Around line 249-257: The test uses asyncio.get_event_loop().run_until_complete
to call storage.save_datasource and storage.save_model, which conflicts with
pytest-asyncio-managed loops; change the fixture to be async (make the fixture
function async def) and replace those run_until_complete calls with direct
awaits: await storage.save_datasource(DatasourceConfig(...)) and await
storage.save_model(_orders()), ensuring the fixture yields/returns as before so
pytest-asyncio (asyncio_mode="auto") manages the event loop correctly.
- Around line 225-229: Several tests in test_slack_normalization.py contain
in-function imports (e.g., DatasourceConfig, SlayerQueryEngine, YAMLStorage,
sqlite3) — hoist these imports to the module-level import block at the top of
the file, remove the now-redundant local imports inside the test functions, and
consolidate/organize them with the existing top imports; repeat this change for
the other in-function import sites flagged (around the commented ranges) to
comply with the project import style and keep test-scoped symbols available to
all functions.

In `@tests/test_source_bundle.py`:
- Around line 20-29: The test helper _model(name: str, ds: str = "prod") is
being invoked with positional arguments in multiple places; update all calls to
use keyword arguments (e.g., _model(name="orders") or _model(name="orders",
ds="warehouse")) so they follow the guideline for functions with more than one
parameter; search for usages of _model in tests/test_source_bundle.py and
replace positional invocations with keyword form, leaving the SlayerModel
construction (and related Column/DataType references) unchanged.

In `@tests/test_time_trunc_key.py`:
- Around line 109-112: The test test_is_frozen should assert the specific
exception type instead of pytest.raises(Exception): replace the broad Exception
with the concrete exception thrown when mutating a frozen TimeTruncKey (use
pydantic.errors.ValidationError / pydantic.ValidationError if TimeTruncKey is a
Pydantic model, or dataclasses.FrozenInstanceError if it is a frozen dataclass),
update the test import(s) accordingly, and keep the mutating line k.granularity
= "day" and references to TimeTruncKey and ColumnKey unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2ce23cea-7e91-49fa-a808-715b179c12a0

📥 Commits

Reviewing files that changed from the base of the PR and between a8b8f2c and b65fab8.

📒 Files selected for processing (43)
  • slayer/core/errors.py
  • slayer/core/keys.py
  • slayer/core/scope.py
  • slayer/core/warnings.py
  • slayer/engine/agg_registry.py
  • slayer/engine/binding.py
  • slayer/engine/cross_model_planner.py
  • slayer/engine/measure_expansion.py
  • slayer/engine/normalization.py
  • slayer/engine/path_resolution.py
  • slayer/engine/planned.py
  • slayer/engine/planning.py
  • slayer/engine/query_engine.py
  • slayer/engine/source_bundle.py
  • slayer/engine/stage_planner.py
  • slayer/engine/syntax.py
  • slayer/engine/variables.py
  • slayer/sql/sql_expr.py
  • tests/test_agg_registry.py
  • tests/test_binding.py
  • tests/test_boundexpr_unification.py
  • tests/test_cross_model_planner.py
  • tests/test_cross_model_planner_wiring.py
  • tests/test_dot_path_in_sql.py
  • tests/test_error_messages.py
  • tests/test_keys.py
  • tests/test_model_measure_expansion.py
  • tests/test_path_resolution.py
  • tests/test_planned.py
  • tests/test_projection_planner.py
  • tests/test_scope_schema.py
  • tests/test_slack_normalization.py
  • tests/test_source_bundle.py
  • tests/test_sql_expr.py
  • tests/test_stage_planner.py
  • tests/test_syntax.py
  • tests/test_time_dimensions_filters.py
  • tests/test_time_dimensions_planner.py
  • tests/test_time_trunc_key.py
  • tests/test_transform_lowerer.py
  • tests/test_transforms_planner.py
  • tests/test_value_registry.py
  • tests/test_variables_planner.py

Comment thread slayer/engine/binding.py
Comment thread slayer/engine/syntax.py Outdated
Comment thread slayer/engine/syntax.py
Comment thread tests/test_agg_registry.py
Comment thread tests/test_binding.py
Comment thread tests/test_stage_planner.py
ZmeiGorynych and others added 4 commits May 21, 2026 19:04
Original scope was a slayer/sql/parity_adapter.py mapping PlannedQuery
back to EnrichedQuery so the legacy SQLGenerator could render it as the
oracle for upcoming generator slices (7b.8-7b.13). A closer read of
slayer/engine/enrichment.py (2300 lines of resolution logic --
derived-column expansion, alias provenance, filter classification,
cross-model rerooting) made it clear that a faithful adapter would
duplicate the bulk of that file in throwaway test-only code, on top of
code already destined for deletion at end of 7b.15.

Pivot: drop the adapter entirely. Each upcoming slice writes parity
tests of the shape

    legacy = await legacy_sql_for(engine, model, q)
    new    = generate_from_planned(plan_query(q, bundle), dialect=...)
    assert_sql_equivalent(legacy, new)

This stage lands the shared helpers each slice imports:

* tests/parity_oracle.py
  - legacy_sql_for(engine, model, query, named_queries=, dialect=)
    routes through engine._enrich + SQLGenerator.generate, the
    production legacy code path.
  - assert_sql_equivalent(legacy, new) does whitespace-canonical
    comparison with a token-level unified-diff on mismatch.
  - norm_sql(sql) collapses runs of whitespace.
  - build_storage_with_models(tmp_path, *models) seeds a YAMLStorage.

* tests/test_parity_oracle.py
  - 9 smoke tests covering norm_sql, assert_sql_equivalent
    (pass-on-whitespace-diff, raise-on-real-diff, pass-on-identity),
    legacy_sql_for non-empty output, deterministic re-runs, and
    joined-dim rendering through the helper.

Codex impl-review fold-ins:

* HIGH: legacy_sql_for now accepts named_queries= so multi-stage and
  cross-model parity (where production passes a name -> SlayerQuery
  map) works through the helper.
* MEDIUM: legacy_sql_for accepts an optional dialect= and threads it
  through to both _enrich and SQLGenerator(dialect=...) so non-postgres
  parity is on the table for later slices.
* MEDIUM: build_storage_with_models docstring rewritten to reflect
  that save-time join-target validation is permissive, so order is a
  convention not a requirement.

Plan amendments (in /home/james/.claude/plans/):

* read-the-linear-issue-mutable-crane.md - 7b.7 redefined as "parity
  oracle test helpers"; 7b.8-7b.13 slice descriptions updated to
  reference legacy_sql_for directly; 7b.15 safe-deletes list swaps
  slayer/sql/parity_adapter.py for tests/parity_oracle.py +
  tests/test_parity_oracle.py.

3509 unit tests passing, 0 regressions across the suite, ruff clean.
The legacy /enriched code path stays untouched; this commit only adds
test-side helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First SQL-generator slice on the typed pipeline. Adds
`SQLGenerator.generate_from_planned(planned_query, *, bundle)` plus
module-level `generate_from_planned(planned_query, *, bundle, dialect)`
shim alongside the legacy `generate()` path. Mirrors the local-only
branch of `_generate_base` but reads typed `PlannedQuery` fields
(`row_slots` / `aggregate_slots` / `filters_by_phase` / `order`)
instead of `EnrichedQuery`, and reuses legacy dialect helpers
(`_resolve_sql` / `_build_agg` / `_wrap_cast_for_type` /
`_parse_predicate` / `_apply_order_limit`) via a synthetic-
`EnrichedMeasure` adapter so dialect parity holds with one
emission codebase.

Scope: single-model queries with row-phase dims, local aggregates,
Mode-B row filters (`status == 'paid'`), ORDER BY a declared
measure/dim alias, LIMIT/OFFSET, and dim-only deduplication.
Cross-model, time dimensions, transforms, HAVING-phase filters,
`column_filter_key`, hidden ORDER BY targets, and `*:<non-count>`
all raise `NotImplementedError` with an explicit `DEV-1450 stage
7b.9+`/`7b.10+`/`7b.12` marker so silent SQL parity drift is
impossible.

Two planner-side gap fixes flagged in the 7b.7 checkpoint:

* ORDER BY resolution against declared-measure aliases — new
  `(public_name, declared_name, canonical_alias) -> bound` map
  built from declared measures; order pass checks it before
  falling back to `bind_expr` (aggregate canonical aliases like
  `amount_sum` aren't columns, so the binder would have raised).

* Pre-bind `ModelMeasure` expansion wired into
  `_declared_measures_from_query` via `expand_model_measures`
  (gated on `ModelScope` with a non-None `source_model` — downstream
  StageSchema stages don't expose saved measures).

Tests (branch-new `tests/test_generator2_local.py`):

* 13 parametrised parity fixtures + 5-dialect smoke (postgres,
  sqlite, duckdb, mysql, clickhouse) — each asserts
  `assert_sql_equivalent(legacy_sql_for(...), generate_from_planned(...))`
  via the 7b.7 parity oracle.
* Regression tests for the four 7b.7-checkpoint planner gaps:
  ORDER BY canonical alias, `ModelMeasure` expansion,
  `column_filter_key` rejection (hand-built + xfail for the real
  planner-path gap), and multi-alias same-key (no-parity, direct
  shape assertion).
* DEV-1443 collision invariants preserved.

Codex review fold-ins (two rounds):

* test review: ORDER BY without LIMIT case; `count_distinct` +
  GROUP BY case; column_filter_key xfail covering the real planner
  gap rather than only the hand-built guard.
* impl review round 1: model.filters defer guard (HIGH);
  `cross_model_aggregate_plans` upfront guard (MEDIUM); unary minus
  handling in `_build_arithmetic_for_filter` (MEDIUM);
  custom/parameterised aggregations defer through new
  `_BUILTIN_BAREARG_AGGS_LOCAL_SLICE` constant (LOW).
* impl review round 2: `*:<non-count>` rejection in synthetic
  measure adapter (MEDIUM); hidden ORDER BY slot defer (MEDIUM).

3533 unit tests pass (24 new + 1 xfailed for the deferred
Column.filter planner gap), 0 regressions. Ruff clean. Integration
tests not run locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends generate_from_planned to render TimeTruncKey row-phase slots via
_build_date_trunc (Postgres/DuckDB/MySQL/ClickHouse DATE_TRUNC, SQLite
STRFTIME), and folds SlayerModel.filters (Mode-A SQL always-applied
WHERE) into the same entry point with legacy WHERE ordering
(date_range -> model.filters -> query.filters).

Introduces BetweenKey, a typed value-key for col BETWEEN low AND high.
The planner's _build_date_range_filter switches from
ArithmeticKey(and, [GE, LE]) to BetweenKey, closing the syntactic
parity gap with legacy generator.py:2533 (which emits BETWEEN). User-
written `col >= a and col <= b` stays as ArithmeticKey -- the DSL parser
never produces BetweenKey, so user-filter parity is preserved.

stage_planner._validate_model_filter validates each entry via
parse_sql_predicate (rejects DSL constructs, raw OVER), rejects refs to
measures (enrichment.py:1147 parity), rejects refs to windowed columns
(enrichment.py:1205 parity), and rejects refs to non-trivial derived
Column.sql columns (deferred to follow-up; trivial-base columns per
_is_trivial_base pass through). Filter is emitted as a text-only
FilterPhase with text_columns extracted from ParsedFilter; the new
_qualify_mode_a_sql_filter in the generator regex-prepends
<source_relation>. to each bare-identifier ref, bit-identical to legacy
_build_where_and_having:2566-2580.

Test count: 3582 (+49 over 7b.8). 48 tests in tests/test_generator2_time_dims.py
cover the granularity sweep (PG + SQLite, 8 each), TD + measures,
date_range, multi-TD disambiguation, ORDER-BY-on-TD, model.filters with
all rejection variants, whole_periods_only pre-snap integration,
dialect cycle smoke, and round-2/round-3 codex regression cases. 1
trivial-base regression test pins _is_trivial_base parity. 7b.3c filter
shape tests in tests/test_time_dimensions_filters.py updated to the new
BetweenKey shape and user-filter-ordering invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renders cumsum / lag / lead / rank / percent_rank / dense_rank / ntile /
first / last through generate_from_planned via a Kahn-batched step CTE
chain wrapped in an outer SELECT (legacy _generate_with_computed shape).
Auto-partition by query dimensions only (not TDs); rank-family defaults
to no PARTITION BY. POST-phase filters route through a _filtered wrapper
between the chain and pagination.

Planner side now attaches the active TD as TransformKey.time_key for
every time-needing transform (cumsum / lag / lead / first / last /
time_shift / consecutive_periods / change / change_pct), closing the
7b.4 carry-over gap. Lowering of change / change_pct runs after the
patch so the desugared time_shift inherits the resolved time_key.
Validation mirrors legacy enrichment.py:564 -- any unresolved
time-needing transform raises "requires an unambiguous time dimension".
PlannedQuery gains active_time_dimension_slot_id so the generator can
look up the TD slot's alias without re-walking the model graph.

time_shift / consecutive_periods / change(lowered) raise
NotImplementedError with "7b.11" markers; composite transform inputs
(e.g., cumsum(amount:sum / qty:sum)) raise with a follow-up marker.

Codex impl-review folded in: list-per-slot alias tracking so duplicate
public aliases (DEV-1450 C13) survive the CTE chain; strict lag / lead
periods validation rejecting bool / non-integral; POST filter operator
coverage (= / <> / unary - / n-ary and-or / typed scalar literals);
order-only hidden refs materialised in the base CTE;
BoundFilter.referenced_keys recomputed after time-key patching.

40 new tests in tests/test_generator2_window.py (38 pass + 1 typed-only
skip for a pre-existing 7b.8 ModelMeasure.type gap + 1 covering nested
cases). Full unit suite: 3621 passed, 3 skipped, 1 xfailed (+39 / +1 / 0
vs baseline); ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
slayer/engine/syntax.py (1)

189-191: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The OVER( guard still misclassifies some valid Python strings.

_STRING_LITERAL_RE understands doubled-quote SQL literals, not Python backslash escapes, so inputs like status == "x\" OVER(" still leave OVER( visible and raise IllegalWindowInFilterError even though it is inside the string literal. This needs a quote-aware scanner/tokenizer that follows Python string rules instead of _STRING_LITERAL_RE.sub("", text).

#!/bin/bash
set -euo pipefail

python - <<'PY'
import re

OVER_RE = re.compile(r"\bOVER\s*\(", re.IGNORECASE)
STRING_LITERAL_RE = re.compile(r"'(?:[^']|'')*'|\"(?:[^\"]|\"\")*\"")

cases = [
    r'status == "OVER("',
    r'status == "x\" OVER("',
]

for text in cases:
    masked = STRING_LITERAL_RE.sub("", text)
    print(f"text   : {text}")
    print(f"masked : {masked}")
    print(f"match? : {bool(OVER_RE.search(masked))}")
    print("-" * 40)
PY
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/engine/syntax.py` around lines 189 - 191, The current guard uses
_STRING_LITERAL_RE to mask literals but fails for Python-style escapes; replace
that masking with a Python-aware tokenizer: use the tokenize module
(tokenize.generate_tokens / tokenize.tokenize) on the input text to produce a
new string where tokens of type STRING are replaced with equal-length whitespace
(so positions remain consistent), then run _OVER_RE.search on that
tokenized/masked string instead of _STRING_LITERAL_RE.sub("", text); update the
conditional using the new helper (e.g., _mask_python_strings(text) and call it
where _STRING_LITERAL_RE.sub(...) is used) and ensure imports for tokenize and
io are added and IllegalWindowInFilterError behavior remains unchanged.
slayer/engine/stage_planner.py (1)

368-374: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Flatten dotted ORDER refs before rebinding in stage scope.

This fallback still parses customers.region verbatim when scope is a StageSchema, but downstream stage columns are emitted as customers__region. Structured ORDER refs without raw_formula will still fail or rebind to the wrong slot in multi-stage queries.

Suggested fix
         else:
             bo = bind_expr(
-                parse_expr(full_name, allow_dunder=flat_scope),
+                parse_expr(
+                    _flatten_dotted(full_name) if flat_scope else full_name,
+                    allow_dunder=flat_scope,
+                ),
                 scope=scope, bundle=bundle,
             )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/engine/stage_planner.py` around lines 368 - 374, The bind is parsing
the dotted ORDER ref verbatim (parse_expr(full_name,...)) so when scope is a
StageSchema downstream columns are emitted flattened (customers__region) and the
current code rebinds incorrectly; fix by detecting StageSchema (or stage scope)
and transform dotted full_name into the stage-flattened form before calling
parse_expr/bind_expr (e.g. replace dots with the stage separator used by the
StageSchema) so bind_expr receives the flattened identifier (use the same
flattening logic/utility the StageSchema uses) while preserving
allow_dunder=flat_scope and passing scope and bundle to bind_expr.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@slayer/engine/schema_drift.py`:
- Around line 866-870: The comprehension that builds custom_agg_names scans all
models via graph.models_by_name but should only consider models reachable from
the stage source; change the comprehension to iterate over graph.reachable and
then look up models in graph.models_by_name (e.g. for name in graph.reachable
for m in (graph.models_by_name.get(name),) if present) so only reachable models'
aggregations (m.aggregations) contribute to custom_agg_names; update the
expression that defines custom_agg_names accordingly (refer to the existing
variable name custom_agg_names and usages in schema_drift.py).

---

Duplicate comments:
In `@slayer/engine/stage_planner.py`:
- Around line 368-374: The bind is parsing the dotted ORDER ref verbatim
(parse_expr(full_name,...)) so when scope is a StageSchema downstream columns
are emitted flattened (customers__region) and the current code rebinds
incorrectly; fix by detecting StageSchema (or stage scope) and transform dotted
full_name into the stage-flattened form before calling parse_expr/bind_expr
(e.g. replace dots with the stage separator used by the StageSchema) so
bind_expr receives the flattened identifier (use the same flattening
logic/utility the StageSchema uses) while preserving allow_dunder=flat_scope and
passing scope and bundle to bind_expr.

In `@slayer/engine/syntax.py`:
- Around line 189-191: The current guard uses _STRING_LITERAL_RE to mask
literals but fails for Python-style escapes; replace that masking with a
Python-aware tokenizer: use the tokenize module (tokenize.generate_tokens /
tokenize.tokenize) on the input text to produce a new string where tokens of
type STRING are replaced with equal-length whitespace (so positions remain
consistent), then run _OVER_RE.search on that tokenized/masked string instead of
_STRING_LITERAL_RE.sub("", text); update the conditional using the new helper
(e.g., _mask_python_strings(text) and call it where _STRING_LITERAL_RE.sub(...)
is used) and ensure imports for tokenize and io are added and
IllegalWindowInFilterError behavior remains unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3ff7eb9f-2ec5-4535-92cf-1e1db5ba5448

📥 Commits

Reviewing files that changed from the base of the PR and between 7a2824a and 2d9729e.

📒 Files selected for processing (22)
  • docs/architecture/errors-and-warnings.md
  • slayer/core/keys.py
  • slayer/core/refs.py
  • slayer/engine/binding.py
  • slayer/engine/cross_model_planner.py
  • slayer/engine/normalization.py
  • slayer/engine/planning.py
  • slayer/engine/query_engine.py
  • slayer/engine/response_meta.py
  • slayer/engine/schema_drift.py
  • slayer/engine/stage_planner.py
  • slayer/engine/syntax.py
  • slayer/memories/resolver.py
  • slayer/sql/generator.py
  • tests/test_agg_registry.py
  • tests/test_dev1445_cross_model_alias_filter.py
  • tests/test_dev1450fix_cross_model_derived.py
  • tests/test_dev1450fix_derived_time_dimension.py
  • tests/test_dev1450fix_group2_correctness.py
  • tests/test_generator2_multistage.py
  • tests/test_path_resolution.py
  • tests/test_stage_planner.py
💤 Files with no reviewable changes (1)
  • slayer/engine/planning.py

Comment thread slayer/engine/schema_drift.py Outdated
…ER edge

Group A — a DERIVED column (ColumnSqlKey) used as a CROSS-MODEL aggregate
source (e.g. customers.rev_x2:sum where rev_x2 = revenue*2) now re-roots and
expands correctly (Codex #1/#2; finishes CodeRabbit cross_model_planner:674):
- cross_model_planner._aggregate_alias / _make_cte_schema resolve the source
  name/type via leaf-or-column_name, so a derived source aliases as
  `rev_x2_sum` (not the star `_sum`) and carries the right type.
- generator cross-model CTE synth re-roots a ColumnSqlKey source to path=()
  and re-roots ColumnSqlKey kwargs symmetrically with ColumnKey.

Group B — schema_drift._measure_refs_on_base scopes custom-agg discovery to
graph.reachable models (not the whole registry), so an unrelated custom-agg
name can't normalize a coincidental call and cause a false cascade
(CodeRabbit schema_drift:870).

Codex #3 — the raw-OVER( pre-scan blanks Python string literals with an
escape-aware matcher (_PY_STRING_LITERAL_RE), so a value like
`status == "x \" OVER("` isn't mistaken for a window clause.

New tests in test_dev1450fix_cross_model_derived.py (derived agg source) and
test_dev1450fix_group2_correctness.py (escaped-literal OVER).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@slayer/engine/syntax.py`:
- Around line 143-147: Replace the non-escape-aware _STRING_LITERAL_RE with the
new escape-aware _PY_STRING_LITERAL_RE in both _normalize_sql_filter_operators
and _preprocess_colons so rewrites (colon preprocessing and SQL operator
normalization in functions _preprocess_colons and
_normalize_sql_filter_operators) skip over Python-style string literals that
contain backslash-escaped quotes; update any local variable or parameter
references in those functions to use _PY_STRING_LITERAL_RE and run existing
literal-masking logic unchanged so behavior remains identical except now
honoring escaped quotes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3c882c1d-4806-4406-9510-0f60db0e0ca6

📥 Commits

Reviewing files that changed from the base of the PR and between 2d9729e and 9e7e59e.

📒 Files selected for processing (6)
  • slayer/engine/cross_model_planner.py
  • slayer/engine/schema_drift.py
  • slayer/engine/syntax.py
  • slayer/sql/generator.py
  • tests/test_dev1450fix_cross_model_derived.py
  • tests/test_dev1450fix_group2_correctness.py

Comment thread slayer/engine/syntax.py Outdated
ZmeiGorynych and others added 2 commits May 26, 2026 10:39
…ract

Resolved conflicts:
- CLAUDE.md: unioned typed parametric keys note (HEAD) with cross-model
  rename paragraph (main) plus the shared workaround / companion-tickets
  trailing clause.
- slayer/engine/query_engine.py: unioned imports (SourceModelOrigin from
  HEAD's typed pipeline + NormalizationWarning).

Test reconciliation (tests/test_nested_dag_cross_stage_refs.py and
tests/test_cross_model_rename_dev1448.py):

Dropped 9 tests that assert legacy mechanisms the typed pipeline
deliberately replaces (breadcrumb-based dotted-form preservation,
intercept Candidate A/B precedence, intercept canonical-collision
guards, lenient fall-through path).

Adapted 18 tests to the typed-pipeline contract: downstream stages
see a flat schema, so dotted refs (`customers.regions.name`) become
flat (`customers__regions__name`); cross-model measure re-aggregation
becomes `flat_alias:sum` against the inner stage's projected column;
result-key assertions updated to match the flat surface.

Skipped 3 tests pending follow-up issues:
- DEV-1471 (cross-stage time_dim re-binding): two tests skipped
  (TestCrossStageTimeDimension::test_multi_hop_dotted_time_dim_cross_stage,
  TestCrossStageTimeShift::test_time_shift_over_multi_hop_dotted_time_dim).
- DEV-1472 (hidden-slot ORDER BY): one test skipped
  (TestCrossModelInterceptDuplicateQfieldGuard::test_intercepted_cmm_order_only_no_qfield_registers_alias).

Full unit suite: 3871 passed, 7 skipped. Ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hedMeasure

DEV-1452 prerequisite #3 — first of four staged commits. Net-neutral
refactor of slayer/sql/generator.py's aggregation-emission codebase so the
legacy `EnrichedMeasure` type can be deleted (Stage D) without forking
dialect SQL emission. Existing test_sql_generator.py + unit suite
(3688 passed, 0 regressions) act as the byte-identical SQL parity gate.

What changed
- New frozen Pydantic `AggRenderSpec` in slayer/sql/generator.py with
  exactly 11 fields (locked decision #4 in the approved plan):
  {sql, name, model_name, aggregation, aggregation_def, agg_kwargs,
   alias, filter_sql, time_column, type, column_type}. Drops the ticket's
  illustrative `agg_args` / `source_measure_name` / `distinct` — the
  dialect helpers don't read any of those; count_distinct dispatches by
  agg name, and first/last's positional time arg is pre-resolved into
  `time_column` at spec-build time.
- 7 dialect helpers refactored to consume AggRenderSpec: `_build_agg`,
  `_build_formula_agg`, `_build_percentile`, `_build_stat_agg`,
  `_build_ranked_subquery_from_planned`, `_resolve_value_sql`,
  `_resolve_agg_param`. `_build_last_ranked_from` is deliberately NOT
  refactored — it's the legacy ranked builder that dies wholesale in
  Stage D.
- `_synthesize_enriched_measure_from_planned` (8 call sites in
  generator.py) renamed to `_build_agg_render_spec_from_planned` and now
  returns `AggRenderSpec` directly — no more synthetic-EnrichedMeasure
  adapter on the typed-pipeline path.
- Transitional shim `_agg_render_spec_from_enriched(em)` — a pure
  field-mapping function the legacy `SQLGenerator.generate(enriched=…)`
  path uses to feed the refactored helpers. Five legacy call sites in
  generator.py (cross-model, ranked-from, having-rewrite, base-select,
  formula-agg-wrapper) and twelve direct-call sites in
  tests/test_sql_generator.py wrap via this shim. Deleted in Stage D
  along with the rest of the legacy stack.
- `AggregateKey.args` schema loosened from `Tuple[Scalar, ...]` to
  `Tuple[_AggregateArgValue, ...]` where
  `_AggregateArgValue = Union[ColumnKey, ColumnSqlKey, Decimal, str,
   bool, None]`. This was authorized by the user during Stage A after a
  bug was uncovered: `_bind_agg_arg` (binding.py:818-819) returns
  `ColumnKey` for identifier positional args, but the old strict-scalar
  schema rejected them — so `amount:last(created_at)` failed at
  AggregateKey validation. End-to-end repro on a fresh fixture confirmed
  the breakage. The legacy `_synthesize_enriched_measure_from_planned`'s
  `for a in key.args: if isinstance(a, ColumnKey)` branch was actually
  correct and forward-looking but had been unreachable. The schema fix
  aligns AggregateKey with the binder's actual output and matches the
  existing `_AggregateKwargValue` union shape used for kwargs.

Tests
- tests/test_agg_render_spec.py (new, 666 lines, 48 tests) — the
  AggRenderSpec field surface + frozen contract + every
  _build_agg_render_spec_from_planned call-site shape: StarKey count and
  rejection branches, ColumnKey bare, ColumnKey with column_filter_key,
  ColumnSqlKey derived, first/last with explicit time arg, custom
  aggregation_def threading, percentile parametric, stat agg with
  other=, unknown aggregation, kwarg ColumnKey path mismatch,
  column-not-found, slot=None HAVING branch, slot.type vs column_type
  independence.
- tests/test_agg_render_spec_shim.py (new, 252 lines, 33 tests) —
  per-field shim mapping + 9 parametrized SQL byte-identical parity
  tests (postgres dialect) against legacy outputs captured pre-refactor
  (basic sum, count*, filtered sum, filtered count*, percentile,
  stat corr with other=, non-aggregation, sql=None bare-column sum,
  median).

Verification
- `poetry run pytest -m "not integration" -q` — 3688 passed, 2 skipped,
  251 deselected. No regressions.
- `poetry run ruff check slayer/ tests/` — clean.

Codex pre-impl review (Stage A test plan): 6 findings, all folded in.
Codex post-impl review (Stage A diff): 3 findings.
- LOW (test_sql_generator.py:2235 direct _build_percentile calls):
  fixed in this commit (14 sites wrapped via the shim).
- 2 MEDIUM findings on cross-model reroot path (cross_model_planner.py
  _local_agg_formula, generator.py _maybe_reroot_cross_model_plan) —
  tracked as follow-up alongside a third related gap
  (_build_first_last_base_select doesn't honor spec.time_column from
  args). All three are end-to-end gaps for `first/last(time_col)` that
  the legacy _query_as_model path currently masks; must be addressed in
  Stage B (the query-backed expansion migration) before Stage D deletes
  legacy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ZmeiGorynych and others added 23 commits May 26, 2026 13:11
Group A — Codex typed-pipeline correctness regressions (3 × major):
* slayer/engine/binding.py: enforce per-column aggregation eligibility
  gates at bind time, mirroring legacy ``enrichment.py:401-417`` —
  primary-key columns restricted to count/count_distinct, explicit
  ``allowed_aggregations`` whitelists honored, type-default whitelist
  applied for built-ins (custom model aggregations exempt). The typed
  pipeline previously accepted ``id:sum`` and ``status:avg`` silently.
* slayer/engine/syntax.py + slayer/sql/generator.py: ``IS`` / ``IS NOT``
  filters work end-to-end. ``_CMP_OP_MAP`` previously rewrote ``IS NULL``
  to lowercase ``is None`` but the AST converter rejected ``ast.Is`` /
  ``ast.IsNot``. Added the ops; both SQL render paths emit ``IS NULL``
  / ``IS NOT NULL`` via ``exp.Is`` / ``exp.Not(exp.Is)``.
* slayer/engine/binding.py: multi-column ``partition_by=[col1, col2]``
  binds correctly. The parser converts a list/tuple kwarg into a
  Python tuple, but the binder only handled a single column ref; now
  binds each element and extends ``partition_keys``.

Group B — CodeRabbit correctness (2 × major):
* slayer/engine/cross_model_planner.py: ``_local_agg_formula`` preserves
  path info for column-valued POSITIONAL aggregate args during
  cross-model rerooting (was only handled for kwargs). Falls back to
  ``_reroot_col_kwarg`` instead of ``_scalar_formula_literal`` for
  ColumnKey / ColumnSqlKey positional args, so the nested sub-query
  binds correctly.
* slayer/engine/syntax.py: switch ``_normalize_sql_filter_operators``
  and ``_preprocess_colons`` from ``_STRING_LITERAL_RE`` (SQL '' / ""
  doubling) to the escape-aware ``_PY_STRING_LITERAL_RE``, so a
  backslash-escaped quote doesn't leak ``IS`` / ``IN`` / ``AND`` /
  ``:sum`` rewrites into the string body. ``_STRING_LITERAL_RE`` was
  removed (no remaining users).

Group C — CodeRabbit hygiene quick-wins:
* tests/test_generator2_multistage.py: make ``_run_sqlite`` keyword-only.
* slayer/engine/stage_planner.py: pass first arg by keyword in every
  ``bind_filter`` / ``bind_expr`` / ``bind_time_dimension`` call.
* tests/test_dot_path_in_sql.py: hoist function-local imports.
* tests/test_time_trunc_key.py: tighten ``pytest.raises(Exception)`` to
  ``pytest.raises(ValidationError)`` for the frozen-Pydantic mutation.

Group D — failed CI deferred via xfail (1):
* tests/integration/test_notebooks.py: ``docs/examples/04_time/time_nb.ipynb``
  xfailed with DEV-1474 reason. The QoQ-by-store cell triggers the typed
  pipeline's documented stage 7b.12 gap (cross-model partition in
  time_shift CTEs). xfail(strict=False) so the test auto-promotes to
  PASSED when DEV-1474 lands.

Follow-up Linear issues filed:
* DEV-1474 — cross-model partition in time_shift CTEs (HIGH).
* DEV-1475 — Mode-B filters: SQL-style IN / NOT IN with tuple RHS (deferred
  from this PR; needs a new ParsedExpr node).

CodeRabbit thread on ``tests/test_generator2_local.py`` line 279 replied
to as outdated (the cited code has moved since the thread was opened).

Regression tests added to tests/test_dev1450fix_group2_correctness.py:
* test_in_keyword_inside_escaped_string_literal_not_rewritten
* test_colon_inside_escaped_string_literal_not_preprocessed
* test_is_null_filter_parses_and_renders
* test_partition_by_multi_column_parses_and_binds
* test_aggregation_eligibility_primary_key_rejected
* test_aggregation_eligibility_type_default_rejected
* test_aggregation_eligibility_allowed_aggregations_whitelist

Full unit suite: 3878 passed, 7 skipped. Ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n-of-syntax' into egor/dev-1452-delete-legacy-enrichment-pipeline-migrate-pre-existing-tests
Two findings collapse to one method (slayer/sql/generator.py:7143
_build_agg_render_spec_from_planned):

- Codex MAJOR on PR #144: the explicit-time-arg loop only matched
  ColumnKey, silently dropping ColumnSqlKey args. With the Stage A
  schema loosen, the binder returns ColumnSqlKey for derived columns;
  amount:last(derived_time) would fall back to the query's default
  ranking column instead of ordering by the explicit derived
  expression — incorrect first/last results with no error.

- Sonar python:S3776 on the same method: cognitive complexity 43 > 15.

Extracts a new _resolve_explicit_time_col helper that handles both
ColumnKey and ColumnSqlKey time args. Bare-identifier derived columns
expand to their underlying SQL qualified under source_relation;
non-trivial derived expressions are emitted as-is and their inner
bare refs resolve against the ranked-subquery's FROM clause. Cross-
model paths on ColumnSqlKey args raise NotImplementedError rather
than silently emitting against the wrong relation alias — that case
is tracked alongside the Stage B cross-model reroot bug (DEV-1452
latent bug (c) in the Linear comment). The extraction drops the
caller's complexity below 15 as a side effect.

End-to-end caveat: first/last with an explicit time arg renders
correctly only when a time dimension is also present in the query
(default_time_col_sql comes from _resolve_ranking_time_column_from_planned).
With no TD, deferred bug (b) — _build_first_last_base_select raises
before any spec is rendered — still bites. Stage B fixes that
boundary as planned.

Tests in tests/test_agg_render_spec.py:
- test_last_with_derived_bare_time_column_local (bare-identifier
  derived column expands to underlying SQL)
- test_first_with_derived_expression_time_column_local (non-trivial
  derived expression round-trips through sqlglot, qualified inside
  the ranked subquery's FROM)
- test_cross_model_derived_time_column_raises (NotImplementedError)
- test_unknown_derived_time_column_raises (ValueError mirroring the
  source-column lookup-miss path)

Unit suite: 3758 passed, 7 skipped, 0 failed (flight tests deselected
— pyarrow not installed in this env).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…anned

Sonar python:S3776 on slayer/sql/generator.py:3822 — cognitive complexity
33 > 15 on _build_ranked_subquery_from_planned.

Extracts the two sub-passes into named helpers, leaving the outer
method as setup → unfiltered pass → filtered pass → assembly:

- _build_unfiltered_rn_columns: one ROW_NUMBER projection per distinct
  effective time column for unfiltered first/last specs, with stable
  per-time-column suffixes (first sorted gets "", then "_2", ...).

- _build_filtered_rn_columns: one dedicated (ROW_NUMBER + match-flag)
  pair per distinct (filter, time, agg) triple for filtered first/last,
  cached so specs sharing a triple reuse the same pair.

Pure structural refactor — no behaviour change. The dedup semantics,
stable suffix assignment, ORDER BY direction, and CASE WHEN match-flag
emission are byte-identical to the pre-split implementation.

Unit suite: 3758 passed, 7 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sonar python:S8396 on slayer/sql/generator.py:60 — `sql: Optional[str]`
without a default flags as a Pydantic anti-pattern.

The intent here is "required field that can hold None" (`*:count`
needs to pass `sql=None` explicitly). Per the rule's documented
exception, `Type | None` is treated as explicit nullability and does
not trigger the warning. Switch `sql: Optional[str]` to `sql: str |
None` — preserves the required-but-may-be-None semantics, dodges the
false-positive, and adds a docstring note pinning the intent so
future readers don't "fix" it back to `Optional[str]`.

Also wires the new DEV-1476 ticket reference into the group 1 helper
docstring + error message + test comment (DEV-1476 is the consolidated
4-bug Stage B package opened today for the explicit-time-arg gaps on
first/last; the cross-model derived-time NotImplementedError in
`_resolve_explicit_time_col` is bug (c) of that package).

Unit suite: 3758 passed, 7 skipped, 0 failed.
Ruff: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two outside-diff CodeRabbit findings on the spec-build adapter, both
verified against current code and fixed minimally.

Finding A — slayer/sql/generator.py:~7350 (agg_def lookup for built-ins)

The lookup for source_model.aggregations was gated on
'key.agg not in _BUILTIN_BAREARG_AGGS_LOCAL_SLICE'. That set includes
weighted_avg / percentile / corr / etc., so a user model that overrode
the default params of a built-in (e.g. supplied a default
weight=quantity for weighted_avg) had the override silently dropped
on the planned path — _resolve_agg_param then raised
'Aggregation \'weighted_avg\' requires parameter \'weight\''. Real
regression vs legacy; the pre-existing comment above the set even
admits this as a deferred follow-up.

Fix: always look up the model-level Aggregation; only raise when the
lookup misses AND key.agg is not a built-in. Built-ins keep their
default renderer when no override exists. Updated the comment above
_BUILTIN_BAREARG_AGGS_LOCAL_SLICE to retire the obsolete TODO.

Finding B — slayer/sql/generator.py:~7375 (kwarg path validation gap)

The for-kname-kval-in-kwargs loop only matched ColumnKey, so a
ColumnSqlKey kwarg with a non-empty path slipped past the
host-vs-target-path check and would have rendered against the wrong
relation. Bug surfaced by the Stage A AggregateKey.args / kwarg-value
schema loosen (which now permits ColumnSqlKey as a kwarg value).

Fix: extend the isinstance check to (ColumnKey, ColumnSqlKey); use
type(kval).__name__ in the error reason so the message reflects the
actual rejected kind. Both Key types carry .path, so the equality
check works unchanged.

CodeRabbit's third sub-finding on the same comment (derived time arg
for first/last silently dropping ColumnSqlKey) was already addressed
in group 1 via _resolve_explicit_time_col + tests; verified no
residual gap.

Tests added in tests/test_agg_render_spec.py:
- test_builtin_aggregation_model_override_threaded: model-level
  weighted_avg override surfaces in spec.aggregation_def with default
  weight=quantity.
- test_builtin_aggregation_no_override_leaves_def_none: a vanilla
  built-in (sum) with no override keeps aggregation_def=None so the
  built-in renderer dispatches.
- test_kwarg_columnsqlkey_path_mismatch_raises: a ColumnSqlKey kwarg
  with a non-empty path against a local source raises the same
  AggregationNotAllowedError as the ColumnKey case.
- _orders_model() fixture grew a weighted_avg model-level override so
  the built-in-override tests don't need a parallel fixture.

Unit suite: 3933 passed, 7 skipped, 0 failed.
Ruff: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…an fold

Codex round 2 caught two remaining correctness gaps:

* slayer/sql/generator.py: ``_build_arithmetic_for_filter`` (the third
  filter-render site) was missing ``is`` / ``is not`` branches.
  Commit b7ba22b patched ``_build_arith_or_cmp_ast`` and
  ``_compose_arithmetic_op`` but missed this one — local-stage filters
  like ``deleted_at IS NULL`` parsed and bound but raised
  ``NotImplementedError`` at SQL generation. Added the branches.

* slayer/sql/generator.py: ``_build_arith_or_cmp_ast`` (cross-model
  filter rendering) silently dropped n-ary boolean operands past index
  1. The binder produces n-ary ``ArithmeticKey`` for ``a AND b AND c``;
  the prior implementation took only ``operands[0]`` / ``[1]`` and
  emitted a single binary node, broadening cross-model HAVING/WHERE
  results. Fold over every operand the same way
  ``_compose_arithmetic_op`` and ``_build_arithmetic_for_filter``
  already do.

Regression tests added to tests/test_dev1450fix_group2_correctness.py:
* test_is_null_filter_renders_end_to_end — exercises the full pipeline
  for ``IS NOT NULL`` against a TEXT joined column.
* test_nary_boolean_cross_model_filter_retains_all_operands — direct
  unit test of ``_build_arith_or_cmp_ast`` for ``a AND b AND c``.

Full unit suite: 3880 passed, 7 skipped. Ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sonar python:S3776 re-flagged both functions after groups 1-4 splits:
- _build_ranked_subquery_from_planned cognitive complexity 21 > 15
  (was 33 pre-Group-2 split)
- _build_agg_render_spec_from_planned cognitive complexity 26 > 15
  (was 43 pre-Group-1 extract)

Hybrid approach (option c on the plan):

1) Extract two more cohesive helpers from _build_agg_render_spec_from_planned:

   - _resolve_aggregation_def: model-level Aggregation lookup + raise on
     unknown non-built-in. Encapsulates the lookup-runs-for-built-ins-too
     semantics (CodeRabbit fold-in on PR #144 group 4) so a future reader
     can see the contract in one place.
   - _validate_aggregate_kwarg_paths: cross-model kwarg path validation
     for both ColumnKey and ColumnSqlKey. Tightens the dispatch in the
     main method down to a single line per concern.

2) NOSONAR(S3776) both functions with documented rationale:

   - The spec-build adapter is fundamentally a sequential isinstance
     dispatch over StarKey / ColumnKey / ColumnSqlKey; each branch has
     its own contract. Further splitting would scatter the per-source-
     kind contract across helpers and obscure flow.
   - The ranked subquery builder, after Group 2 factored
     _build_unfiltered_rn_columns / _build_filtered_rn_columns, is left
     with exp.Select / from / joins / where assembly that has to live in
     one place.

Pure refactor — no behaviour change. Existing tests pin the integrated
behaviour through the public method.

Unit suite: 3933 passed, 7 skipped, 0 failed.
Ruff: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three legacy-stack call sites (_expand_query_backed_model,
_validate_and_populate_cache, get_column_types) now route through
build_resolved_source_bundle → plan_stages → generate_planned_stages
instead of the legacy _query_as_model / _enrich / SQLGenerator.generate
chain. Folds in DEV-1476 bugs (b), (c), (d-cross) so query-backed
models using first / last with an explicit positional time arg keep
working end-to-end after Stage D deletes the legacy fallback.

Helpers extracted (decisions B + F):
* slayer/sql/stage_wrapper.py::build_flat_rename_wrapper — shared
  with generate_planned_stages' CTE chaining + the virtual-model wrap.
* slayer/engine/stage_ordering.py::topologically_order_stages —
  Kahn topo-sort, called by both the runtime-list execute path and
  the stored source_queries save path. _extract_sibling_refs now
  recursively walks inline SlayerModel.source_queries (decision E).
* slayer/engine/source_bundle.py::expand_query_backed_models_in_bundle —
  source + referenced + stage_source expansion + inline_extensions
  re-apply in one place; recursion guard via _resolving handles a
  query-backed join target that references its parent.

Typed primitives extended (decisions #2, #8):
* StageColumn.format retyped Optional[NumberFormat].
* ValueSlot.format / .description added.
* DeclaredMeasure.type / .format / .description carry through
  ProjectionPlanner intern → ValueSlot → StageColumn → virtual Column.
* Slot-type inference: *:count → INT, count/avg/median → built-ins,
  sum/min/max inherit source column type. Replaces legacy blanket
  DOUBLE for virtual-model columns (decision #2).

DEV-1476 fixes (B.6):
* (b) _build_first_last_base_select now only raises when an
  explicit time arg is also missing from every first/last spec.
* (c) _render_cross_model_cte reroots positional ColumnKey /
  ColumnSqlKey args symmetric to kwargs, and wraps the FROM in a
  ROW_NUMBER-ranked subquery for first/last aggregates.
* (d-cross) _build_agg_render_spec_from_planned threads ``bundle``
  so derived ColumnSqlKey aggregate sources get expand_derived_refs_
  sync (their inner bare refs qualify under source_relation).
* tests/test_agg_render_spec.py::TestBuilderFirstLast flipped from
  _raises to _resolves_after_reroot.

Migrated-path acceptance (decision D):
* SourceModelOrigin / agg_column_names dropped from the virtual
  model. Outer dotted-ref re-aggregation against canonical aliases
  uses the flat ``__``-joined name (P4 closure); a dotted ref that
  matched the legacy lineage walk now surfaces a clear binder error.
* The two ContextVars (_join_target_resolving_var,
  _forbidden_sibling_refs_var) are NOT touched by the migrated paths.
  They remain alive for _query_as_model until Stage D.

Test additions / migrations:
* tests/test_query_backed_typed_expansion.py — 32 new tests covering
  topo-sort acceptance / cycle / root-as-sink / self-ref / duplicate
  / inline-nested decision E, virtual-model column types / format /
  description / default_time_dimension / public-only slots, P4
  closure pin, ContextVar non-touch, save path with placeholder fill
  + data_source refresh + save=False, nested query-backed expansion
  (qb-A joins qb-B / overlay-over-qb-base / runtime stage source),
  cross-DS rejection, wrapper alias shapes (local agg / cross-model
  agg / cross-model parametric agg / joined dim).
* tests/test_dev1476_first_last_explicit_time.py — 6 tests (3
  helper-level _local_agg_formula args reroot + 3 end-to-end against
  seeded SQLite for bugs b / c / d-cross).
* tests/test_topologically_order_stages.py — 12 tests pinning the
  extracted helper + decision E recursion (typed / dict / nested
  ModelExtension / inline-nested cycle).
* tests/test_stage_wrapper_helper.py — 4 tests pinning the extracted
  build_flat_rename_wrapper.
* tests/test_query_backed_models.py — loosened the inner-stage-rename
  regex to accept the quoted alias form (DEV-1452 Stage B emits
  ``AS "rev"``; pre-Stage-B legacy emitted ``AS rev``).
* tests/test_projection_trim.py — migrated
  test_get_column_types_uses_outer_render_mode → _does_not_call_
  legacy_generate (the typed pipeline has no render_mode contract).

Verification:
* poetry run pytest -m "not integration" -q — 3985 passed, 0 failed.
* poetry run ruff check slayer/ tests/ — clean.
* SQLite + DuckDB integration smoke — 131 passed.
* Query-backed model tests — 46 passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…adata

Two MAJOR Codex findings around typed-metadata propagation through
the planner. Both surface as wrong virtual-column ``type`` on
query-backed models after the Stage B migration.

* _declared_measures_from_query (stage_planner.py:~1005) ignored a
  user-supplied ``ModelMeasure.type`` on a query measure — only
  ``_type_for_measure_formula`` ran. Now ``m.type or _type_for_
  measure_formula(...)``: explicit override wins, inference is the
  fallback. Mirrors legacy ``EnrichedMeasure.type``'s "honor explicit
  type before inference" contract.
* ValueRegistry._merge_into_existing (planning.py:~221) dropped
  ``type`` / ``format`` / ``label`` / ``description`` on hidden→public
  promotion. Repro: declare ``rank(*:count)`` first (hoists ``*:count``
  hidden) then declare ``*:count`` public — the promoted slot kept
  ``type=None`` and the migrated virtual column regressed to DOUBLE
  fallback. Now the merge fills any None-on-existing metadata field
  from the public re-intern; first-intern wins for already-set fields.

Tests:
* test_query_measure_type_override_wins_over_inference — explicit
  ``type=DOUBLE`` on ``*:count`` surfaces as DOUBLE despite the INT
  inference default.
* test_promoted_hidden_slot_keeps_metadata — ``rank(*:count)`` +
  ``*:count`` ordering yields INT on the public ``_count`` column.

Full non-integration suite green (3987 passed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* S1172 stage_planner.py:840 — drop the unused ``formula`` param
  from ``_format_description_for_measure_formula``. The function
  takes ``scope`` + ``bound`` already; the formula string was a
  leftover from an earlier draft.
* S100 test_query_backed_typed_expansion.py:698 — rename
  ``test_qb_A_joins_qb_B_save_path`` →
  ``test_qb_a_joins_qb_b_save_path`` to match
  ``^[a-z_][a-z0-9_]*$``.

Full non-integration suite green (3987 passed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Six CRITICAL S3776 cognitive-complexity hits on Stage B surface
plus one pre-existing Stage A leftover. Each NOSONAR'd with a
documented rationale on the def line.

* generator.py:3832 _resolve_explicit_time_col (complexity 21,
  pre-existing from Stage A b67eecb) — sequential isinstance
  dispatch over ColumnKey + ColumnSqlKey; splitting fragments the
  per-shape contract.
* generator.py:4068 _build_first_last_base_select (complexity 42)
  — dim/td/derived-dim classification pass + agg-spec synth +
  ranked-subquery wrap + outer SELECT/GROUP BY assembly; shared
  mutable state (partition_exprs / extra_projections / outer_ref_
  by_sid / synth_by_sid) keeps it as one conceptual unit.
* generator.py:5113 _render_cross_model_cte (complexity 56) —
  shared-grain projection + agg reroot (source / args / kwargs) +
  first/last ranked-subquery wrap + target-model-filter qualify +
  WHERE/HAVING routing; each block is interdependent CTE state.
* query_engine.py:788 get_column_types (complexity 22) — linear
  probe pipeline: query-backed prelude → bundle → expand-nested →
  plan → render → execute → result-key map-back.
* source_bundle.py:411 expand_query_backed_models_in_bundle
  (complexity 25) — three sequential expansion blocks (source +
  referenced + stage_source) mutating bundle in series; recursion
  guard is the only shared state.
* stage_ordering.py:47 _walk_spec (complexity 49) — recursive
  walker over five ``source_model`` shapes plus nested
  ``source_queries`` recursion. The recursion + isinstance dispatch
  IS the function.

Full non-integration suite green (3987 passed); ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… cross-model first/last fallback

Two MAJOR Codex findings on the round-1 fixes:

* query_engine.py:_expand_query_backed_model — passed
  ``outer_vars=final_stage.variables`` to
  ``expand_query_backed_models_in_bundle``, dropping the outer model's
  ``query_variables`` layer for nested expansions / sibling stages.
  Substitution then diverged between execute (pre-merged path) and
  save-time dry-run (placeholder ``0``). Now passes the bundle's
  merged ``bundle.query_variables`` (which already includes
  ``{model.query_variables, outer_vars, stage, runtime}`` per the
  layered precedence) so nested layers see the full variable context.
* generator.py:_render_cross_model_cte — cross-model ``first`` /
  ``last`` without an explicit positional time arg emitted the
  ``MAX(CASE WHEN _last_rn = 1 ...)`` expression but only built the
  ROW_NUMBER-ranked subquery when ``synth.time_column`` was truthy.
  Result: agg_expr referenced ``_first_rn`` / ``_last_rn`` against a
  bare ``FROM target`` that didn't supply it. Now falls back to
  ``target_model.default_time_dimension`` (qualified under the
  target relation); raises a clear ``first/last requires a ranking
  time column`` error when even that is unset (mirrors the local
  first/last path's behaviour).

Tests:
* test_cross_model_first_last_uses_target_default_time_dimension —
  ``customers.amount:last`` (no explicit time arg) against a
  customers model with ``default_time_dimension="signup_at"``
  executes end-to-end against seeded SQLite.
* test_cross_model_first_last_with_no_time_at_all_raises — same
  query against a customers model WITHOUT default_time_dimension
  raises the typed error.

Full non-integration suite green (3989 passed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…act metadata-fill helper

The round-1 metadata-carry fix added 4 parallel if-blocks to
``ValueRegistry._merge_into_existing``, bumping cognitive complexity
to 18 (over the 15 cap). Extract a module-level
``_fill_missing_metadata`` that walks the four fields in a single
loop and mutates the ``updates`` dict in place. The
``slot.field is None and new_value is not None`` rule is identical
across the four fields, so the extraction simplifies rather than
obscures.

Full non-integration suite green (3989 passed); ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… call sites

Codex MAJOR finding (generator.py:4229) flagged the local first/last
ranked-subquery call site for skipping ``_expand_derived_column_sql``
on derived ``ColumnSqlKey`` aggregate sources because ``bundle`` was
not passed to ``_build_agg_render_spec_from_planned``. Audit revealed
the same gap at 4 sibling call sites:

* generator.py:4229 _build_first_last_base_select (ranked-subquery
  path; Codex flagged).
* generator.py:4340 _render_aggregate_composite_expr (single-agg-
  inside-arithmetic).
* generator.py:5579 _render_filter_value_key_in_target_scope HAVING
  agg.
* generator.py:6743 transform input agg.
* generator.py:7851 _render_value_key_for_filter (filter ref).

Two enclosing functions did not have ``bundle`` in scope and were
extended to accept it:

* _render_aggregate_composite_expr (4306) — gained ``bundle=None``
  kwarg; its 5 recursive / external callers (3691, 4287, 4352,
  4365, 8166) now thread ``bundle`` through.
* _apply_order_limit_from_planned (8135) — gained ``bundle=None``;
  the one caller at 2901 threads it through.

Test:
* test_local_first_last_over_derived_column_expands_inner_refs —
  ``net_amount:last(created_at)`` where ``net_amount.sql="amount *
  0.9"`` executes end-to-end against seeded SQLite; without the
  expansion the bare ``amount`` inside ``MAX(CASE WHEN _last_rn=1
  ...)`` would render unqualified and SQLite would error.

Full non-integration suite green (3990 passed); ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Codex MAJOR (round 4) — sibling-stage variable substitution in the
migrated ``_expand_query_backed_model`` used ``root_vars =
final_stage.variables`` as a base layer, but
``apply_variables_to_query`` only substitutes into filters and never
updates ``.variables`` — so ``root_vars`` was just the stage-level
dict, dropping ``model.query_variables`` and the outer-query layer.

Repro: a query-backed model with ``query_variables={"threshold":
"100"}`` and a NAMED non-final sibling whose filter references
``{threshold}``. Pre-fix, save-time dry-run silently substituted
the placeholder ``"0"``. Post-fix the sibling sees the outer
default and substitutes ``100``.

Fix: swap ``root_vars`` for ``bundle.query_variables`` as the
base layer. ``bundle.query_variables`` is the merged
``{runtime > final_stage.variables > model.query_variables >
outer_vars > source_model_defaults}`` set so sibling substitution
sees the full outer context.

Test:
* test_sibling_stage_inherits_outer_model_query_variables — pins
  the rendered SQL contains ``> 100`` (the outer model default),
  not ``> 0`` (the placeholder).

Also dismissed Codex round-4 finding "_get_column_types rejects
__ columns" as a false positive — direct repro returns the
flattened ``customers__region`` key correctly; the binder's
literal-column carve-out for ``Column.name`` containing ``__``
works on the migrated path.

Full non-integration suite green (3991 passed); ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…re.type

Two Codex findings (1 MAJOR + 1 MINOR):

* MAJOR query_engine.py:_execute_by_name — built ``named_queries``
  from persisted ``source_queries`` order before ``_execute_pipeline``,
  but ``plan_stages._topo_sort`` only handles ``source_model`` deps.
  A model that saved successfully with out-of-order ``joins[].target_
  model`` sibling stages (the save path goes through ``_expand_query_
  backed_model`` which DOES topo-sort) would fail when run directly
  by name. Fix: call ``topologically_order_stages`` at the top of
  ``_execute_by_name`` so run-by-name matches save-time semantics.

* MINOR stage_planner.py:_declared_measures_from_query — a query
  formula that is a bare reference to a saved ``ModelMeasure(formula
  =..., type=DataType.X)`` on the source model lost the explicit
  ``type=X``. ``expand_model_measures`` rewrites the AST but drops
  the measure's type metadata; the planner then fell back to
  inference, which only happened to match the explicit type by
  coincidence on the pre-existing test. Fix: extend the type-
  priority chain to ``m.type or _saved_model_measure_type(...) or
  _type_for_measure_formula(...)``. New helper
  ``_saved_model_measure_type`` re-looks-up the saved measure when
  the query formula is a bare identifier matching a
  ``ModelMeasure.name`` on the source model.

Tests:
* test_run_by_name_accepts_nonordered_join_target_stages — saves a
  model with out-of-order ``joins.target_model`` sibling, then runs
  it by name; pre-fix this would fail at ``plan_stages``.
* test_saved_model_measure_type_wins_over_inference —
  ``ModelMeasure(formula="amount:sum", type=DataType.INT)`` is
  referenced from a query; pre-fix the resulting column had
  ``type=DOUBLE`` (inference), now ``type=INT`` (explicit).

Full non-integration suite green (3993 passed); ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The DEV-1450 typed-pipeline redesign deferred SQL-style ``col in (a, b)``
filters; the legacy enrichment path accepted them via sqlglot parsing.
On PR #139 the 02_sql_vs_dsl notebook's ``stores.name in ('Brooklyn',
'Philadelphia')`` cell broke with ``ValueError: unsupported comparison
operator In.``. The companion 04_time notebook was xfailed in b7ba22b
under DEV-1474; the 02 one was not.

Implementation:

* slayer/engine/syntax.py: add ``TupleLit`` ParsedExpr node; extend
  ``_CMP_OP_MAP`` with ``ast.In: "in"`` and ``ast.NotIn: "not in"``; in
  ``_convert``'s ``ast.Compare`` branch validate the RHS as a non-empty
  tuple/list of literals and emit a ``Cmp(op, left, TupleLit(...))``.
  ``walk_parsed_refs`` learns the new node (literals → no refs).
* slayer/core/keys.py: add ``InKey(_FrozenKey)`` modelled on
  ``BetweenKey`` — ``column: ValueKey``, ``values: Tuple[LiteralKey,
  ...]``, ``negated: bool``. A ``field_validator`` rejects empty
  ``values`` so direct construction can't reach SQL with ``col IN ()``
  (defense in depth — the parser also rejects). Extend the
  ``ValueKey`` union and the rebuild list.
* slayer/engine/binding.py: ``_bind``'s ``Cmp`` branch routes
  ``"in"``/``"not in"`` to a new ``_bind_in`` helper that binds the LHS
  through the normal column path and folds each ``TupleLit`` element
  to a ``LiteralKey``. Extend ``_VALUE_KEY_TYPES`` and
  ``walk_value_keys`` so cross-model routing / windowed-column
  rejection see through ``InKey``.
* slayer/engine/planning.py: ``lower_sugar_transforms`` /
  ``_iter_slot_deps`` / ``_canonical_name`` handle ``InKey`` parallel
  to ``BetweenKey``. Only the LHS column is recursed; RHS values are
  literals with no slot identity.
* slayer/engine/stage_planner.py: ``_attach_time_keys`` /
  ``_find_unresolved_time_needing_op`` handle ``InKey`` the same way.
* slayer/sql/generator.py: three filter-render sites learn ``InKey``:
  ``_render_value_key_for_filter`` (WHERE/HAVING),
  ``_render_value_key_against_aliases`` (POST-phase against the
  ``_filtered`` wrapper), and
  ``_render_filter_value_key_in_target_scope`` (cross-model CTE).
  Each emits ``exp.In(this=col, expressions=[lits])`` wrapped in
  ``exp.Not(...)`` when ``negated``. Walkers in
  ``_collect_filter_join_paths``, ``_direct_local_column_keys``, the
  three internal closures (transform / slot dep / readiness), and the
  ScalarCallKey arg-descent tuples all gain an ``InKey`` branch so
  scalar-wrapped IN predicates recurse correctly (Codex round 1
  finding) and a joined-column IN filter pulls the join into FROM.

Codex round 1 findings folded into this commit:

* slayer/engine/syntax.py:480: ``amount in (-1, -2)`` was rejected
  because the AST emits ``UnaryOp(USub, Constant)`` not ``Literal``.
  Added ``_convert_in_rhs_element`` to collapse ``USub``/``UAdd`` over
  a numeric constant into a signed ``Literal`` before the literal-only
  check. Booleans and strings are unaffected.
* slayer/core/keys.py: added the ``values`` field_validator so a
  direct ``InKey(values=())`` raises at construction (parser already
  rejects this shape — belt and suspenders for programmatic callers).
* slayer/sql/generator.py: extended four ``ScalarCallKey`` arg-descent
  tuples to include ``InKey`` alongside ``BetweenKey`` so a scalar
  call wrapping an IN predicate recurses through the InKey rather
  than rendering it as a string literal.

Docs:

* docs/concepts/queries.md: added the ``not in`` row to the
  comparison-operators table and a one-line note that the RHS must be
  literals only (no references or expressions).
* docs/examples/02_sql_vs_dsl/sql_vs_dsl_nb.ipynb: re-executed in
  place (no source change). The previously-failing cell now emits
  ``stores.name IN ('Brooklyn', 'Philadelphia')`` against the joined
  stores CTE.

Tests:

* tests/test_dev1475_in_tuple_filters.py: 16 unit tests covering
  parser shape (string / numeric / signed / dotted / list syntax),
  parser guards (empty / scalar / non-literal RHS, direct InKey empty
  guard), binder shape (InKey + LiteralKey values + negated), and SQL
  emission (IN, NOT IN, n-ary AND fold from 7a1110f territory,
  cross-model joined column).
* tests/integration/test_integration.py: ``test_filter_in_tuple`` /
  ``test_filter_not_in_tuple`` SQLite smoke tests asserting the
  expected row count after filtering.

Full unit suite: 3896 passed, 8 skipped (+16 from the 3880 baseline).
Ruff clean. 02_sql_vs_dsl notebook test now passes WITHOUT xfail; 04
remains xfailed under DEV-1474 as before.

The CI failures on PR #139 from 2026-05-26T12:18Z were infrastructure
issues (codeload.github.com 404 on ``actions/setup-java@v4`` archive +
``account suspended`` 403 on integration-examples checkout). Both
cleared on later runs of the same SHA on main; the next CI run on
this push should exercise the full green path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/github.com/MotleyAI/slayer into egor/dev-1452-delete-legacy-enrichment-pipeline-migrate-pre-existing-tests
- Codex major (generator.py): cross-model first/last computed ROW_NUMBER
  over the full target table, then applied target_model_filters + routed
  WHERE filters on the outer CTE. A filtered-out row could still win
  _last_rn = 1, NULL-ing out the MAX(CASE WHEN _last_rn = 1 ...) aggregate
  for that group. Push WHERE into the ranked subquery via the helper's
  existing where_clause param so RN is computed over filtered rows.
  Regression test pins last(amount) = 100.0 when the newer row is
  soft-deleted (instead of NULL).

- CodeRabbit thread (test_dev1476): strengthen the
  default_time_dimension fallback test from "data exists" to an exact
  expected-value pin (NA last = 50.0 by signup_at).

- CodeRabbit nitpick (test_dev1476): drop function-local
  import os/sqlite3/tempfile in two test bodies; they duplicate the
  module-top imports and break the imports-at-top rule.

- CodeRabbit thread (stage_ordering.py): docstring claimed forward
  references raise ValueError, but the topo-sort reorders them — that
  is the entire fault-tolerance contract. Rewrite to list the actual
  raise cases (cycles, self-refs, dupes, root referenced by other) and
  call out forward-reference reordering explicitly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…s-model first/last

_render_cross_model_cte built the first/last aggregate via _build_agg
BEFORE constructing the ROW_NUMBER-ranked subquery, passing a bare
rn_suffix_map and discarding the subquery's filtered_rn_map /
filtered_match_map. For a cross-model first/last over a target column
carrying a Column.filter, the ranked subquery emits _last_rn_f0 /
_match_f0 (skipping the bare _last_rn for filtered specs), so the
aggregate referenced a _last_rn column the subquery never projects —
"no such column: _last_rn".

Reorder to build the ranked subquery first (mirroring the local
first/last path), then thread its rn_suffix_map + filtered_rn_map +
filtered_match_map into _build_agg. Also drops the previously-wrong
tuple-keyed rn_suffix_map in favor of the subquery's correctly
time-col-keyed map.

Regression test: cross-model customers.active_amount:last where
active_amount has filter="status = 'active'" returns the newest ACTIVE
row's value (100.0), skipping newer inactive rows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…qualify; dedup dev1476 tests

Three review fixes from the process-reviews triage on PR #144:

- stage_planner._type_for_dimension now walks the join chain to the
  terminal column's type for dotted (joined) dimensions instead of
  returning None. A None was coerced to DataType.DOUBLE in
  _query_as_model, mistyping joined string/temporal dims on the
  persisted query-backed virtual model (Codex #1).
- generator._resolve_explicit_time_col routes a derived (ColumnSqlKey)
  first/last time arg through _expand_derived_column_sql so inner bare
  refs qualify to the source relation. An unqualified ref in a complex
  expression (e.g. date(created_at)) could bind to the wrong table when
  the ranked subquery includes a join with a same-named column (Codex #2).
- Finish the in-progress dedup of test_dev1476_first_last_explicit_time:
  define the _engine_from_sql / _orders_model helpers the file already
  called, route the fixture through them, and drop the per-test SQLite
  boilerplate. Clears the SonarCloud new-code duplication gate and the
  CodeRabbit function-local-import nitpick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ichment-pipeline-migrate-pre-existing-tests

DEV-1452: Delete legacy enrichment pipeline + migrate pre-existing tests
@sonarqubecloud
Copy link
Copy Markdown

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.

1 participant