Skip to content

#267: aggregate/scalar dbt-expectations macros — source-table routing - #269

Merged
wjduenow merged 18 commits into
devfrom
feature/267-aggregate-macro-routing
Jul 6, 2026
Merged

#267: aggregate/scalar dbt-expectations macros — source-table routing#269
wjduenow merged 18 commits into
devfrom
feature/267-aggregate-macro-routing

Conversation

@wjduenow

@wjduenow wjduenow commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

Super plan for #267 — graduate aggregate/scalar dbt manifest test bodies from skip-recorded to pruned.

Phase: detailing (awaiting approval)
Stories: 6 implementation + Quality Gate + Patterns & Memory
Decisions: 11 (DEC-001 … DEC-011)

Two reframing findings

Design (empirically de-risked)

  • Classify at ingest (is_prunable_count_scalar, sqlglot-AST, bool) — graduate single top-level COUNT/COUNT(col)/COUNT(DISTINCT); reject AVG/SUM/MIN/MAX/COUNT+1/multi → keep skip-recording (SkipReason stays 3-value).
  • Restructure at the compiler — pure-string failing-rows wrap SELECT v FROM (SELECT (<body>) AS v) AS a WHERE v <> 0 (no sqlglot in the compiler → no new confinement scan). Parses on BigQuery/Snowflake/Databricks. Adapter's COUNT(*) AS failures wrap → 0=pass / 1=fail (same contract as row_count_between).
  • 0 = pass sound under dbt's "returned rows = failures" convention. No DropReason/SkipReason/audit-schema/_PROMPT_VERSION changes.

Plan document

plans/super/267-aggregate-macro-routing.md — full Discovery / Architecture Review / Refinement (11 DECs) / Detailed Breakdown.

Next steps

  • Review the plan in this PR
  • Approve in Claude Code, then say "devolve" to create beads

Summary by CodeRabbit

  • New Features
    • Manifest-ingested SQL tests now detect bare scalar “count-of-rows” patterns (including COUNT(*), COUNT(col), and COUNT(DISTINCT ...)) and route them into the standard pruning pass/fail evaluation.
  • Bug Fixes
    • Fixed incorrect behavior where count-based scalar tests could produce an always-1/always-failing outcome instead of correctly reflecting 0 (pass) vs >= 1 (fail).
  • Documentation
    • Updated ingest and pruning operation docs to reflect the refined count-scalar vs non-count handling.
  • Tests
    • Added and expanded coverage for count-scalar classification, prune restructuring, and end-to-end manifest routing.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ce6ebce4-882d-469f-b192-b1e65a3d0159

📥 Commits

Reviewing files that changed from the base of the PR and between b9615bd and 69876a2.

📒 Files selected for processing (3)
  • src/signalforge/ingest/_compiled_sql.py
  • tests/ingest/test_compiled_sql.py
  • tests/prune/test_engine.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/signalforge/ingest/_compiled_sql.py
  • tests/ingest/test_compiled_sql.py
  • tests/prune/test_engine.py

📝 Walkthrough

Walkthrough

This PR adds count-scalar detection for manifest-ingested dbt tests, promotes qualifying scalars to from_manifest candidates, rewrites them into a failing-rows compiler form, and updates fixtures, tests, docs, and planning notes to match the new routing and verdict behavior.

Changes

Count-scalar pruning flow

Layer / File(s) Summary
Count-scalar classifier
src/signalforge/ingest/_compiled_sql.py, tests/ingest/test_compiled_sql.py
Adds is_prunable_count_scalar and tests bare COUNT scalar acceptance plus negative SQL shapes.
Manifest test classification
src/signalforge/ingest/reader.py, tests/ingest/test_manifest_tests.py
Routes prunable count scalars into CandidateTestCustomSQL and keeps other scalar aggregates on the skip path.
Prune compiler rewrite
src/signalforge/prune/compiler.py, tests/prune/test_compiler.py
Wraps prunable ingested count scalars into failing-rows SQL and rejects unsupported scalar bodies with _InvalidIdentifier.
Engine dispatch pins
tests/prune/test_engine.py
Pins source routing, dispatched SQL, and keep/drop outcomes for ingested count-scalar candidates.
Fixture and CLI coverage
tests/fixtures/dbt_project_expectations/..., tests/cli/test_prune_existing.py, tests/manifest/test_expectations_fixture_loads.py
Adds a singular COUNT(*) fixture test and updates load/CLI expectations for the extra manifest candidate.
Docs and plan updates
.claude/rules/*.md, docs/ingest-ops.md, docs/prune-ops.md, CHANGELOG.md, plans/super/267-aggregate-macro-routing.md
Updates user-facing and internal docs plus the super plan to describe the new routing and rewrite behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

Poem

I nibble a count, then hop to the prune,
No more “always-1” in the silvery moon.
With scalar and source path all nicely aligned,
The verdict now lands with a rabbit’s peace of mind.
🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the PR’s focus on dbt-expectations aggregate/scalar handling and source-table routing, though it omits the count-scalar prune behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

wjduenow added 15 commits July 6, 2026 10:02
… US-002)

Change the ingest classification gate in _classify_manifest_test so a
graduatable count-of-rows scalar body (COUNT(*) / COUNT(col) /
COUNT(DISTINCT col)) falls through to the common determinism -> safety ->
candidate tail (from_manifest=True, sql=cc verbatim) instead of being
skip-recorded. A non-count scalar (AVG/SUM/MIN/MAX, arithmetic-on-count,
multi-aggregate) still skip-records malformed-supported-test. The
determinism and safety gates still apply to a graduatable count-scalar
(they live in the common tail). Narrow _AGGREGATE_SKIP_DETAIL to the
non-count residue and note count-of-rows scalars are now pruned as of #267.

Stage-0 preserved: no SQL building at ingest (the compiler restructures in
a separate bead). SkipReason stays the closed 3-value set.

Traces DEC-003, DEC-005.
…te routing pins (#267 US-004)

Pin the manifest-ingested COUNT-scalar custom_sql behaviour at the engine
level via FakeBigQueryClient:

- engineered determinism: a restructured count-scalar body with failures=0
  routes to dropped/always-passes; with failures>0 routes to kept. The fake
  matches on sf_agg_value so the match itself proves the US-003
  failing-rows restructure reached warehouse dispatch (not the always-1 wrap).
- load-bearing mixed-candidate routing pins (materialised + oneshot): one
  ingested count-scalar (from_manifest, bypasses to SOURCE + restructured
  <> 0 wrap) plus one drafted not_null (routes to the sampled/materialised
  temp). Distinct per-test matchers make the pairing order-independent;
  assertions key on each dispatched compiled_sql (source vs temp + restructure
  shape), not the routed decision alone, so a per-test override regression is
  caught.

Traces DEC-006 (source routing already covers from_manifest; mixed-candidate
pin mandatory) and DEC-002 (restructure shape). Tests-only; no production
change.
… test (#267 US-005)

Add a singular test to the dbt-expectations fixture whose compiled body is a
bare scalar COUNT, plus tests proving it becomes a prunable from_manifest
candidate (not skip-recorded). Traces DEC-008.

Part A — tests/no_orders_above_threshold.sql: a singular test with a single
ref('orders') and no GROUP BY, compiling to a bare top-level
`select count(*) from "dev"."main"."orders" where amount > 1000`. amount is
100/200 in the two fixture rows, so the WHERE selects zero rows -> count 0 ->
always-passes drop. Uses Jinja comments (dbt strips them) so compiled_code is
comment-free and the warehouse adapter's validate_test_sql accepts the
restructured body.

Part B — regenerated target/manifest.json via the pinned uvx dbt-duckdb flow
(dbt deps + dbt compile, scrubbing generated_at / invocation_id / created_at).
Purely additive: the five existing dbt-expectations compiled bodies are
byte-identical.

Part C — ingest loads test (test_bridge_routes_scalar_count_singular_test)
proves the node ingests as a CandidateTestCustomSQL with from_manifest=True,
associated to orders, and NOT skip-recorded. Updated the sibling fixture-loads
and dbt-expectations routing tests for the new 6th test node, and the
prune-existing --from-manifest tests for the new 5th prunable manifest
candidate (isolating the manifest path from the fixture's committed singular
test via an empty --tests-dir).
@wjduenow wjduenow changed the title #267: aggregate/scalar dbt-expectations macros — source-table routing (plan) #267: aggregate/scalar dbt-expectations macros — source-table routing Jul 6, 2026
@wjduenow
wjduenow marked this pull request as ready for review July 6, 2026 18:30
@wjduenow
wjduenow requested a review from Copilot July 6, 2026 18:33

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/prune/test_engine.py (2)

5891-5901: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Weaker _SESSION guard than sibling tests.

Line 5900 only excludes "_SESSION._sf_sample_", while the single-candidate pins (Lines 5756, 5803, 5984) exclude the broader "_SESSION" substring. A regression that routes the ingested body to a session-scoped temp table under a different naming pattern (still containing _SESSION but not exactly _SESSION._sf_sample_) would slip past this assertion.

🔧 Tighten the guard to match sibling tests
-    assert "_SESSION._sf_sample_" not in ingested.compiled_sql
+    assert "_SESSION" not in ingested.compiled_sql
🤖 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/prune/test_engine.py` around lines 5891 - 5901, The `_SESSION`
assertion in the `test_engine` count-scalar bypass check is too specific and can
miss other session-scoped temp table names. Update the `ingested` expectation in
this test to match the broader guard used by sibling tests (the same `_SESSION`
substring check), so any session-based temp table routing is caught regardless
of naming pattern.

5977-5993: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Missing parity check vs. the materialised sibling test.

The materialised mixed test asserts "fake_project.dataset.orders" not in not_null.compiled_sql" (Line 5911) to prove the not_null decision doesn't route to source. This oneshot counterpart omits that check, leaving an asymmetry in coverage between the two strategy pins despite the docstring's stated intent to independently pin both strategies.

🔧 Add the missing parity assertion
     # Drafted not_null: sampled via the CTE (per-test override did NOT bypass).
     assert "WITH sample" in not_null.compiled_sql
+    assert "fake_project.dataset.orders" not in not_null.compiled_sql
     assert not_null.decision == "dropped"
🤖 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/prune/test_engine.py` around lines 5977 - 5993, The oneshot mixed prune
test is missing the same source-routing parity assertion that exists in the
materialised sibling test. In the test body around the `by_anchor["column.id"]`
checks, add an assertion against `not_null.compiled_sql` to confirm it does not
contain the source table reference, matching the coverage already pinned by
`test_engine` for the materialised strategy and keeping the
`ingested`/`not_null` strategy parity explicit.
🤖 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 `@src/signalforge/ingest/_compiled_sql.py`:
- Around line 215-267: The bare-count detection in is_prunable_count_scalar is
missing a HAVING guard, so a SELECT with COUNT(*) plus HAVING can still be
treated as prunable even when it returns no rows. Update the Select-root checks
in is_prunable_count_scalar to reject any query with a HAVING clause, alongside
the existing group check, before validating the single Count projection.

---

Nitpick comments:
In `@tests/prune/test_engine.py`:
- Around line 5891-5901: The `_SESSION` assertion in the `test_engine`
count-scalar bypass check is too specific and can miss other session-scoped temp
table names. Update the `ingested` expectation in this test to match the broader
guard used by sibling tests (the same `_SESSION` substring check), so any
session-based temp table routing is caught regardless of naming pattern.
- Around line 5977-5993: The oneshot mixed prune test is missing the same
source-routing parity assertion that exists in the materialised sibling test. In
the test body around the `by_anchor["column.id"]` checks, add an assertion
against `not_null.compiled_sql` to confirm it does not contain the source table
reference, matching the coverage already pinned by `test_engine` for the
materialised strategy and keeping the `ingested`/`not_null` strategy parity
explicit.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0d128578-555c-4c17-b64c-7ec45eeb79a6

📥 Commits

Reviewing files that changed from the base of the PR and between 89e2914 and b9615bd.

📒 Files selected for processing (18)
  • .claude/rules/business-rule-tests.md
  • .claude/rules/ingest-layer.md
  • .claude/rules/prune-engine.md
  • CHANGELOG.md
  • docs/ingest-ops.md
  • docs/prune-ops.md
  • plans/super/267-aggregate-macro-routing.md
  • src/signalforge/ingest/_compiled_sql.py
  • src/signalforge/ingest/reader.py
  • src/signalforge/prune/compiler.py
  • tests/cli/test_prune_existing.py
  • tests/fixtures/dbt_project_expectations/target/manifest.json
  • tests/fixtures/dbt_project_expectations/tests/no_orders_above_threshold.sql
  • tests/ingest/test_compiled_sql.py
  • tests/ingest/test_manifest_tests.py
  • tests/manifest/test_expectations_fixture_loads.py
  • tests/prune/test_compiler.py
  • tests/prune/test_engine.py

Comment thread src/signalforge/ingest/_compiled_sql.py

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR implements #267’s “count-of-rows scalar” manifest-ingest path so bare SELECT COUNT(...) ... compiled test bodies are no longer skip-recorded as scalar/aggregate, but instead are safely pruned/graded by restructuring them into a failing-rows shape that works under the adapter’s SELECT COUNT(*) AS failures FROM (<sql>) envelope. It also adds fixture + unit/engine/CLI coverage and updates plan/docs/rules/changelog to reflect the new behavior and constraints.

Changes:

  • Add is_prunable_count_scalar (sqlglot AST gate) and update manifest-ingest routing to promote only COUNT-family scalar bodies to from_manifest custom_sql candidates.
  • Restructure ingested COUNT-scalar SQL at compile time into a failing-rows form (... WHERE sf_agg_value <> 0) to avoid the “always-1” scalar-collapse bug.
  • Add end-to-end pins: compiled SQL snapshot tests, engine routing tests (including mixed-candidate source-vs-sample behavior), a committed dbt fixture singular test, CLI isolation for --tests-dir, and docs/rules/changelog updates.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/signalforge/ingest/_compiled_sql.py Adds is_prunable_count_scalar classifier for COUNT-only scalar SELECTs.
src/signalforge/ingest/reader.py Promotes prunable COUNT-scalars to candidates; narrows aggregate skip detail.
src/signalforge/prune/compiler.py Restructures ingested COUNT-scalars into failing-rows form; belt-and-braces invalidation for other scalars.
tests/ingest/test_compiled_sql.py Adds unit matrix for is_prunable_count_scalar.
tests/ingest/test_manifest_tests.py Updates ingest expectations: COUNT-scalars become candidates; non-count aggregates/nondeterminism still skip.
tests/prune/test_compiler.py Pins compiler restructure output and guard-rails (row-returning unchanged, non-count scalar sentinel).
tests/prune/test_engine.py Adds engineered-determinism tests + mixed-candidate routing pins for source vs sampled temp tables.
tests/manifest/test_expectations_fixture_loads.py Extends fixture assertions to include the new singular COUNT-scalar test node.
tests/cli/test_prune_existing.py Ensures manifest-on/off behavior is isolated from default tests-dir ingestion via an empty tests dir.
tests/fixtures/dbt_project_expectations/tests/no_orders_above_threshold.sql Adds a singular COUNT-scalar test to exercise the manifest-ingest path.
tests/fixtures/dbt_project_expectations/target/manifest.json Regenerated fixture manifest to include the new singular test node.
docs/ingest-ops.md Documents COUNT-scalar graduation + 0=pass reinterpretation and its caveats.
docs/prune-ops.md Documents compiler restructure and routing implications for ingested COUNT-scalars.
CHANGELOG.md Records the behavior change in [Unreleased].
.claude/rules/ingest-layer.md Updates ingest-layer rules to reflect post-#267 behavior.
.claude/rules/prune-engine.md Updates prune-engine rules to reflect COUNT-scalar restructure behavior.
.claude/rules/business-rule-tests.md Updates business-rule test rules to include the new COUNT-scalar graduation path.
plans/super/267-aggregate-macro-routing.md Adds the detailed super plan and DECs supporting the implementation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/prune/test_engine.py Outdated
@wjduenow

wjduenow commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

PR Review Summary

All review feedback addressed in 69876a2. Full validation green (4637 passed).

Fixed (3 items)

File Issue Reviewer Fix
ingest/_compiled_sql.py is_prunable_count_scalar didn't reject a HAVING clause — SELECT COUNT(*) … HAVING … (no GROUP BY) matches the bare-count shape yet returns zero-or-one rows on a condition unrelated to the failing-row count, so 0=pass breaks CodeRabbit (Major) Added a root.args.get("having") guard alongside group; docstring updated; new reject test case (… HAVING count(*) > 3)
tests/prune/test_engine.py Test docstring implied the restructured path returns an arbitrary count (failures=3); the … WHERE sf_agg_value <> 0 envelope only ever yields failures ∈ {0,1} Copilot Reworded to failures > 0; made the injected fake counts realistic (3/2/5 → 1) across the real-failure + both mixed-candidate tests
tests/prune/test_engine.py Mixed-candidate _SESSION guard weaker than the sibling single-candidate pins CodeRabbit (nit) Split "_SESSION._sf_sample_" not in into separate "_SESSION" + "_sf_sample_" assertions, matching siblings

False positives

None.

The HAVING guard is a genuine correctness improvement (the same "classifier gates on GROUP BY must also gate on HAVING" lesson applies to any future aggregate-shape gate). Thanks @coderabbitai and @copilot.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@wjduenow Thanks for the detailed summary and for addressing all the feedback in 69876a2. The HAVING guard fix is indeed an important catch — glad it's in now, and good call generalizing the lesson for future aggregate-shape classifiers. Nice work getting the full suite green (4637 passed).

ᕦ(ò_óˇ)ᕤ


✏️ Learnings added
Learnt from: wjduenow
URL: https://github.com/wjduenow/SignalForge/pull/269

Timestamp: 2026-07-06T23:20:43.394Z
Learning: In src/signalforge/ingest/_compiled_sql.py, the `is_prunable_count_scalar` classifier (and any future aggregate-shape gate/classifier in this codebase) must guard against both `GROUP BY` and `HAVING` clauses. A `SELECT COUNT(*) ... HAVING ...` with no GROUP BY matches the bare-count scalar shape syntactically but returns zero-or-one rows based on a condition unrelated to the failing-row count, breaking the `0 = pass` convention used by the prune compiler's failing-rows restructure.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@wjduenow
wjduenow merged commit 73ca9d5 into dev Jul 6, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants