DEV-1714 Stage 10: windowed measures (_wm_ range-join) rebuilt on ScopeFrame - #277
Conversation
Reimplement duration-windowed measures (revenue:sum(window='90d')) on the typed planner/generator, closing DEV-1496 (all pinned strict-xfails promote). - Plan-time WindowedAggregatePlan (symmetric with CrossModelAggregatePlan); the `window` kwarg (globally reserved, legacy parity) triggers it. - Host-rooted _wm_<model>__<measure> range-join CTE rendered as a ScopeFrame client: _src self-selects host rows (_w_dim_<n> / _w_td_<n> / _w_time / _w_value CASE-wrapped by Column.filter), discovers its joins via Law 1 (replacing the legacy regex scanner), and range-joins to _base (_src._w_time >= bucket_end - window / < bucket_end) via the dialect interval strategy, joined back null-safe on the grain. Reuses the _cm_* orchestration so windowed + cross-model measures coexist. - Compact-duration parser moved to slayer/core/window_duration.py so the engine planner validates durations without importing the SQL layer; the plan carries parsed parts so the renderer never re-parses. - Windowed-measure filters reclassify to Phase.POST (outer WHERE). - 8 plan-time guards (G1..G8) raise loudly on sum/avg-only, no-time-dim, cross-model, transform, composite, hidden filter-only, mixed, and malformed/empty/non-string duration; DEV-1504 shapes stay guarded. - _src row-filter parity: model + WHERE filters apply, date_range stripped (explicit raw-time filter still truncates — tracked as DEV-1732). - Windowed CAST follows the base path (inferred type). NULL-dim groups get a NULL windowed value (F1-style documented). Tests: promote ~28 pinned xfails to plain tests; add shape/parity/loud-error/ plan-time-guard unit tests + DuckDB/SQLite integration value tests. Full non-integration suite green; ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis change adds typed planning and SQL generation for duration-windowed ChangesDuration-windowed measures
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Query
participant Planner
participant SQLGenerator
participant WindowCTE
participant CombinedQuery
Query->>Planner: plan duration-windowed measure
Planner->>Planner: validate duration, aggregation, scope, and filters
Planner->>SQLGenerator: provide WindowedAggregatePlan
SQLGenerator->>WindowCTE: render host-rooted range-join CTE
WindowCTE->>CombinedQuery: return aggregate and grain aliases
CombinedQuery->>CombinedQuery: apply POST filters and null-safe joins
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
slayer/sql/generator.py (1)
6428-6438: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCarry
wm_ctesinto the transform-chain prelude for future safety.The transform branch returns
_render_cross_model_transform_chain(prelude_ctes=[("_base", base_cte_sql)] + cm_ctes, ...)and omitswm_ctes, whilecombined_select_sqlalready projects and joins each_wm_*CTE. The result would be a statement that references undefined CTEs.The path is unreachable today, because guard G4 in
_guard_windowed_measuresrejects any transform that coexists with a windowed measure, sotransform_layersis empty wheneverwindowed_aggregate_plansis non-empty. When DEV-1504 lifts G4, this becomes a live defect. Include the list now, or raise an explicitNotImplementedErrorhere so the combination cannot fail silently.♻️ Proposed change
if planned_query.transform_layers: + if wm_ctes: + raise NotImplementedError( + "DEV-1714 Stage 10: a windowed measure combined with a " + "transform layer is guarded at plan time (G4); the " + "transform chain does not carry `_wm_` CTEs.", + ) return self._render_cross_model_transform_chain( prelude_ctes=[("_base", base_cte_sql)] + cm_ctes,🤖 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/sql/generator.py` around lines 6428 - 6438, Update the transform branch in the surrounding query-rendering method to include wm_ctes in prelude_ctes before calling _render_cross_model_transform_chain, preserving the existing CTE order and ensuring every _wm_* reference in combined_select_sql is defined.slayer/engine/stage_planner.py (1)
275-279: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAlign the stated guard precedence with the execution order.
The docstring states
Precedence: G1 → G8 → G3 → G4 → G5 → G7 → G6 → G2and calls it a contract. The function runs G4 first (line 287), then G5 (line 304), then G1/G8/G3 (line 312), then G7/G6, then G2. Forcumsum(revenue:min(window='90d'))the raised error is the G4 transform message, not the G1 non-sum/avg message, so the listed order does not describe observable behavior.test_windowed_transform_input_precedence_not_selectedpins G4 over G6, which matches the code, not the list.Restate the line as the execution order (
G4 → G5 → G1 → G8 → G3 → G7 → G6 → G2) so a later reader does not reorder the checks to match the comment.🤖 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 275 - 279, Update the guard precedence statement in the stage planner docstring to match the actual execution order: G4 → G5 → G1 → G8 → G3 → G7 → G6 → G2. Leave the guard implementation and surrounding explanation unchanged.
🤖 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 `@docs/architecture/sql-generation.md`:
- Around line 103-104: In the documentation text around the host-row column
description, keep the `_w_td_<n>` identifier entirely on one line within a
single code span, matching the unbroken `_w_dim_<n>` formatting; change only
this line break.
In `@docs/concepts/formulas.md`:
- Around line 73-75: Revise the formula documentation clause describing windowed
measures so mixed filters that reference both a windowed measure and a plain
aggregate are restricted regardless of whether the windowed measure is selected.
Keep filter-only use as a separate case, since it is already documented by the
preceding clause and is governed independently by _guard_windowed_measures guard
G7.
In `@slayer/engine/planned.py`:
- Around line 278-289: Update the windowed-plan construction flow around
`_build_windowed_plans` and `WindowedAggregatePlan` so `active_td_slot_id` is
never passed as `None`. After `projection.registry.find_by_key(active_td_key)`,
explicitly handle a missing slot by either interning the active time dimension
as a hidden slot or raising the existing G2 validation error message; preserve
the required `window_time_dimension_slot_id: SlotId` contract.
In `@slayer/engine/stage_planner.py`:
- Around line 382-399: The windowed-plan construction loop must fail loudly when
registry.find_by_key(key) returns None instead of continuing. Update the lookup
branch in the function building WindowedAggregatePlan instances to raise an
appropriate error for the missing slot, preserving plan creation only for
successful lookups and preventing the measure from falling back to a plain
aggregate.
---
Nitpick comments:
In `@slayer/engine/stage_planner.py`:
- Around line 275-279: Update the guard precedence statement in the stage
planner docstring to match the actual execution order: G4 → G5 → G1 → G8 → G3 →
G7 → G6 → G2. Leave the guard implementation and surrounding explanation
unchanged.
In `@slayer/sql/generator.py`:
- Around line 6428-6438: Update the transform branch in the surrounding
query-rendering method to include wm_ctes in prelude_ctes before calling
_render_cross_model_transform_chain, preserving the existing CTE order and
ensuring every _wm_* reference in combined_select_sql is defined.
🪄 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: 97664657-f69e-48dc-9c81-ec971998ed3d
📒 Files selected for processing (10)
DECISIONS.mddocs/architecture/sql-generation.mddocs/concepts/formulas.mdslayer/core/window_duration.pyslayer/engine/planned.pyslayer/engine/stage_planner.pyslayer/sql/generator.pytests/integration/test_integration_windowed_measures.pytests/test_carrier_scope_matrix.pytests/test_sql_generator.py
…dges Address CodeRabbit + Codex review of the Stage-10 windowed-measure PR (all findings valid; Sonar/CI clean). Planner (stage_planner.py): - CR#3: a windowed measure whose only time dim is the model default (never selected, so never interned) raised a Pydantic crash on window_time_dimension_slot_id=None; now raises the clean G2 message. - CR#4: a windowed slot with no projection slot silently degraded to a plain aggregate; now raises loudly (planner/projection-drift invariant). - Codex#3: reorder guards to the documented G1->G8->G3->G4 precedence (per-key agg/duration/cross-model checks run before the transform check). Generator (generator.py): - Codex#1: a joined window/other time dimension referenced an unbound alias in _src; register its crossed join into the _src ScopeFrame. - Codex#2: the same windowed formula selected under two names dropped the later alias; cycle public_aliases like the cross-model path. - CR nitpick: the cross-model transform-chain prelude omits _wm_ CTEs (unreachable under G4); raise NotImplementedError so lifting G4 can't emit a statement referencing undefined _wm_ CTEs. Docs: unbreak the `_w_td_<n>` code span; reword the mixed-filter restriction so it reads independently of filter-only use. Tests: add pins for CR#3 (default-TD-not-selected), Codex#1 (joined time dim join-in-_src), Codex#2 (two aliases both surface). Full non-integration suite green (8986 passed); ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex re-review: ordering by a SELECTED windowed measure pulled its slot into the order-only-local path (it is excluded from base_projection), materialising a dead plain SUM(...) AS "<alias>" in _base and routing the ORDER BY through bare_ids by accident. Skip windowed slots in the order-only loop and add them to bare_order_slot_ids explicitly: the ORDER BY now references the bare combined output column (the _wm_ CTE value) with no dead _base aggregate. Strengthen test_order_by_windowed_alias_resolves to assert the order term is NOT _base.-qualified and the windowed measure does not render in _base. Full non-integration suite green (8986 passed); ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex re-review: selected_windowed was a set, so the emitted _wm_ CTEs and combined-SELECT columns for multiple windowed measures came out in nondeterministic order across runs — breaking the SQL-text cache key (DEV-1587). Make it an insertion-ordered dict keyed in measure declaration order; the CTEs and columns now follow that order deterministically. Full non-integration suite green (8986 passed); ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…4 Stage 10 Conflicts resolved: - stage_planner.py: keep both the DEV-1714 windowed-plan build and the DEV-1712 plan-time ORDER BY validation after _bucket_slots (independent; a selected windowed measure ordered-by is an AggregateKey the Stage-8 pass passes through). - DECISIONS.md: append-only, both sides' entries kept in date order. - generator.py: route the windowed renderer's allocator through the DEV-1726 _new_allocator factory so the single-construction-site invariant holds (dialect-aware case folding). Full non-integration suite green (9086 passed); ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex re-review (post-merge with DEV-1726): windowed CTE names were minted via raw _cte_name_from_alias, so two measures whose aliases lossy-sanitise to the same name (rev-a / rev_a), or case-only variants on a case-folding dialect, would collide and trip the CTE-name-collision belt. Mint _wm_ names through the DEV-1726 collision-aware allocate_cte factory (dialect fold-aware); exact names are preserved for the non-colliding case, distinct auto-numbered names for collisions. The single-AliasAllocator-construction-site invariant still holds. Codex also re-flagged that windowed columns are grouped after _base/_cm_ columns rather than woven into projection order — documented in place as consistent with the cross-model path and harmless (results keyed by name), not fixed (would rework the shared combined-projection assembly). Full non-integration suite green (9086 passed); ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Codex re-review: _windowed_phase promotes an ENTIRE filter to Phase.POST when it references a windowed measure, so a filter mixing a windowed predicate with a ROW predicate (e.g. "revenue:sum(window='90d') > 100 and status = 'active'") moved the row part to the outer WHERE too — neither applied pre-aggregation nor resolvable when its column is unprojected (dangling _base ref / scope leak). The pre-projection G7 guard only caught windowed+plain-aggregate mixes. Add a plan-time guard: a filter referencing a windowed slot must reference ONLY windowed slots (+ literals); mixing with a row column or plain aggregate raises NotImplementedError (DEV-1504). Pin with a windowed+row-filter test. Full non-integration suite green (9087 passed); ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (3)
slayer/engine/stage_planner.py (2)
1171-1178: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAssign a per-plan copy of
src_where_ids.Every plan receives the same list object. Any later in-place mutation of one plan's
where_filter_idswould change all plans. Pass a copy per plan to keep the plans independent.♻️ Proposed refactor
for wp in windowed_plans: - wp.where_filter_ids = src_where_ids + wp.where_filter_ids = list(src_where_ids)🤖 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 1171 - 1178, Update the windowed-plans loop around windowed_plans and where_filter_ids so each plan receives an independent copy of src_where_ids rather than the shared list object. Preserve the existing filter selection and assignment behavior.
393-404: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueExtract the shared G2 message into one constant.
This block repeats the exact G2 error text from lines 369-373. The two copies must stay identical, because tests match on the phrase
could not resolve its time dimension. A single module-level constant removes the drift risk.♻️ Proposed refactor
+_WINDOWED_NO_TIME_DIM_MSG = ( + "Windowed measure could not resolve its time dimension. Add a single " + "time_dimensions entry, or set main_time_dimension to select among " + "multiple time dimensions." +)Then use it at both raise sites:
if active_td_slot_id is None: - raise ValueError( - "Windowed measure could not resolve its time dimension. Add a single " - "time_dimensions entry, or set main_time_dimension to select among " - "multiple time dimensions." - ) + raise ValueError(_WINDOWED_NO_TIME_DIM_MSG)🤖 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 393 - 404, Extract the repeated G2 error text into a single module-level constant in stage_planner.py, then update both raise sites—including the active_td_slot_id check and the earlier G2 validation—to raise ValueError using that constant. Keep the message exactly unchanged so existing tests continue matching it.slayer/sql/generator.py (1)
6466-6488: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the shared grain join-back emission.
This loop repeats the cross-model join-back loop at lines 6439-6464. Both build the same null-safe
LEFT JOIN … ON …string, or fall back toCROSS JOIN. One helper that takes the CTE name and the join-back pairs removes the duplicate logic, so a future change to the join-back shape applies to both paths.♻️ Proposed refactor
+ def _grain_joinback_sql( + self, *, cte_name: str, joinback_pairs: List[Tuple[str, str]], + ) -> str: + """One grain join-back clause: null-safe LEFT JOIN, or CROSS JOIN when + the CTE shares no grain with ``_base``.""" + if not joinback_pairs: + return f"\nCROSS JOIN {cte_name}" + join_parts = [ + self._null_safe_join_pair_sql( + left_sql=f'_base.{self._quote_ident(host)}', + right_sql=f'{cte_name}.{self._quote_ident(cte_col)}', + ) + for host, cte_col in joinback_pairs + ] + return f"\nLEFT JOIN {cte_name} ON " + _SQL_AND_JOINER.join(join_parts)Then both loops reduce to one call each:
for plan in planned_query.windowed_aggregate_plans: cte_name = wm_cte_name_for_plan[plan.aggregate_slot_id] - joinback_pairs = wm_joinback_pairs_for_plan.get( - plan.aggregate_slot_id, [], - ) - if joinback_pairs: - join_parts = [ - self._null_safe_join_pair_sql( - left_sql=f'_base.{self._quote_ident(host)}', - right_sql=f'{cte_name}.{self._quote_ident(cte_col)}', - ) - for host, cte_col in joinback_pairs - ] - from_clause_str += ( - f"\nLEFT JOIN {cte_name} ON " + _SQL_AND_JOINER.join(join_parts) - ) - else: - from_clause_str += f"\nCROSS JOIN {cte_name}" + from_clause_str += self._grain_joinback_sql( + cte_name=cte_name, + joinback_pairs=wm_joinback_pairs_for_plan.get( + plan.aggregate_slot_id, [], + ), + )🤖 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/sql/generator.py` around lines 6466 - 6488, Extract the repeated join-back SQL construction into a shared helper near the existing generator logic, accepting a CTE name and its join-back pairs and returning either the null-safe LEFT JOIN clause or CROSS JOIN fallback. Update both the cross-model loop and the windowed-aggregate loop to call this helper, preserving their existing join inputs and generated SQL.
🤖 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.
Nitpick comments:
In `@slayer/engine/stage_planner.py`:
- Around line 1171-1178: Update the windowed-plans loop around windowed_plans
and where_filter_ids so each plan receives an independent copy of src_where_ids
rather than the shared list object. Preserve the existing filter selection and
assignment behavior.
- Around line 393-404: Extract the repeated G2 error text into a single
module-level constant in stage_planner.py, then update both raise
sites—including the active_td_slot_id check and the earlier G2 validation—to
raise ValueError using that constant. Keep the message exactly unchanged so
existing tests continue matching it.
In `@slayer/sql/generator.py`:
- Around line 6466-6488: Extract the repeated join-back SQL construction into a
shared helper near the existing generator logic, accepting a CTE name and its
join-back pairs and returning either the null-safe LEFT JOIN clause or CROSS
JOIN fallback. Update both the cross-model loop and the windowed-aggregate loop
to call this helper, preserving their existing join inputs and generated SQL.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 60a9a945-c702-4ec2-b684-b6d2fcaaff49
📒 Files selected for processing (6)
DECISIONS.mddocs/architecture/sql-generation.mddocs/concepts/formulas.mdslayer/engine/stage_planner.pyslayer/sql/generator.pytests/test_sql_generator.py
🚧 Files skipped from review as they are similar to previous changes (4)
- docs/concepts/formulas.md
- DECISIONS.md
- docs/architecture/sql-generation.md
- tests/test_sql_generator.py
b082ef9
into
egor/dev-1703-comprehensive-approach-expressions-crossing-joins-on-the



Reimplements duration-windowed measures (
revenue:sum(window='90d')) on the typed planner/generator as a plan-timeWindowedAggregatePlan+ host-rooted_wm_range-join CTE (a ScopeFrame client), closing DEV-1496. Stage 10 of the DEV-1703 typed-pipeline redesign.What changed
slayer/core/window_duration.py(new) — compact-duration parser, dependency-free so the engine planner validates durations at plan time without importing the SQL layer.slayer/engine/planned.py—WindowedAggregatePlan+PlannedQuery.windowed_aggregate_plans.slayer/engine/stage_planner.py— window detection, 8 plan-time guards (precedenceG1→G8→G3→G4→G5→G7→G6→G2on the original value-key trees so transform/composite win over hidden), plan construction, andPhase.POSTfilter reclassification.slayer/sql/generator.py—_render_window_measure_cte_from_plannedbuilds the_srcself-join subquery (_w_dim_<n>/_w_td_<n>/_w_time/_w_value, CASE-wrapped byColumn.filter) with Law-1 join discovery, range-joins to_base(_src._w_time >= bucket_end − window/< bucket_end) via the DEV-1716 dialect strategy, and joins back null-safe on the grain. Wired into_render_with_cross_model_plansso windowed + cross-model measures coexist.Semantics
sum/avglocal measures only. Cross-model, transform (input or sibling), arithmetic/composite, hidden filter-only, mixed windowed+plain filters, and non-sum/avg all raise loudly at plan time (DEV-1504 shapes stay guarded, never silently degrade)._srcrow filters: model + WHERE-phase filters apply inside_src; only the typeddate_rangeis stripped (the trailing window must reach rows before the range start). An explicit raw-time-column filter still truncates — documented inconsistency tracked as DEV-1732.=inside the CTE never matches NULL).windowis a globally reserved aggregation kwarg name (legacy parity).Tests
test_sql_generator.pyTestFields/TestMultiDialectGeneration/TestCastEmissionNonBasePaths/TestWindowedMeasureGuards,test_carrier_scope_matrix.py) to plain passing tests.plan_query-level, proving guards fire before rendering), and DuckDB + SQLite integration value tests asserting hand-computed rolling values ({Jan:300, Feb:600, Mar:900}, avgMar:300, NULL-dim → NULL, March-onlydate_rangestill returning 900).🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation