Prune layer — operations guide
Operational reference for users of signalforge.prune. Companion to
docs/safety-ops.md,
docs/draft-ops.md,
docs/manifest-loader-ops.md, and
docs/warehouse-adapter-ops.md, and to the
design record in plans/super/6-prune-engine.md.
The prune layer sits between the LLM-drafting layer (#5) and the diff
renderer (#8). Every candidate test produced by the drafter goes through
one entry point — signalforge.prune.prune_tests — which compiles the
test to failing-rows SQL, runs it against the warehouse, classifies the
verdict, and writes a fail-closed JSONL audit record per decision.
This is the load-bearing differentiator (Architectural Commitment #1 in
CLAUDE.md) — competitors generate; SignalForge
generates and grades.
Default posture
Sample-scope is the default. Architectural Commitment #1 (signal over volume) penalises always-pass tests because they consume reviewer attention without catching anything; sampling 100k rows is enough signal to detect always-pass while keeping query bytes under control.
The layer is fail-closed on the audit-write boundary: any I/O error from
PruneEvent persistence aborts the run as PruneAuditWriteError
(DEC-016, mirrors safety's DEC-011 and draft's DEC-006). And the layer
is conservative on the verdict boundary: tests we cannot evaluate
(warehouse error, total-budget exhausted, ambiguous evidence) are
kept, not dropped — kept-without-evidence lands the test in front
of a human reviewer rather than silently losing potential signal.
User-facing tagline: always-pass tests are dropped; everything we cannot confidently drop is kept.
Public API
Import from signalforge.prune. The 14 names exported by __all__:
Orchestrator
prune_tests(model, adapter, candidates, manifest, *, config=None, project_dir=None, audit_path=None) -> PruneResult— End-to-end orchestrator. Compiles everyCandidateTest, runs each through the warehouse adapter, writes one JSONL audit record per decision, returns the aggregatePruneResult. Mirrorssignalforge.draft.draft_schemaso the CLI / wrapper layers see one consistent end-to-end shape across pipeline stages.project_dirdefaults toPath.cwd().audit_pathdefaults to<project_dir>/.signalforge/prune.jsonl.
Result shapes
-
PruneResult— Aggregate verdict for one model. Frozen Pydantic model with fieldsprune_schema_version: Literal[1],model_unique_id: str,decisions: tuple[PruneDecision, ...],elapsed_ms: int,signalforge_version: str. Computed properties:kept_decisions,dropped_decisions,kept_count,dropped_count,total_tests— all derived fromdecisions(DEC-003) so aPruneResultreconstructed from a JSONL log carries identical views to a freshly produced one. -
PruneDecision— One verdict per candidate test. Carriestest_anchor: str("column.<name>"or"model"),test: CandidateTest(the typed discriminated union from the drafter — six variants as of issue #169, includingcustom_sql(#116) androw_count_between(#169); NOT a loose dict — DEC-004; the grader and diff renderer reuse the drafter's per-variant display logic),decision: Literal["kept", "dropped"],reason: DropReason,failures: int,sampled_rows: int | None,scope: Scope,elapsed_ms: int,compiled_sql_hash: str(16 hex chars; blake2b-8 per DEC-005),compiled_sql: str,why: str,sample_failures: tuple[dict[str, Any], ...] | None.
Configuration
-
PruneConfig— User-facing knobs. Frozen Pydantic model withextra="forbid"(config-shaped per DEC-015 — typos fail loud). Field reference: see Configuration below. -
load_prune_config(project_dir, path=None) -> PruneConfig— Loads theprune:block fromsignalforge.yml. Resolves to<project_dir>/signalforge.ymlwhenpathisNone. Returns defaults when the file is missing, empty, or theprune:key is absent. RaisesPruneConfigErroron parse / schema failures. Mirrorsload_safety_config/load_draft_configso the CLI sees one calling convention across stages.
Discriminator literals
-
DropReason—Literal["always-passes", "requires-future-data", "failed-on-known-clean-data", "kept", "kept-without-evidence"]. Closed set so the diff renderer (#8) can branch on the literal value rather than sniffing prose. -
Scope—Literal["sample", "full"]. Whenscope == "full",PruneDecision.sampled_rowsisNone(every row inspected).
Audit
PruneEvent— One JSONL audit record perPruneDecision. Constructed ONLY bysignalforge.prune.audit._build_prune_event(DEC-018; AST-gated bytests/test_audit_completeness.py).extra="ignore"for forward-compat read-back. See Audit JSONL schema for the field set.
Errors
from signalforge.prune import errors. Every exception subclasses
PruneError and carries a class-level default_remediation rendered on
a ↳ Remediation: line by __str__.
PruneError— Base class. Never raised directly.PruneConfigError—signalforge.ymlprune:block failed parse or schema validation.PruneTrustedModelNotFoundError—prune.trusted_modelsreferences aunique_idnot in the manifest. Subclass ofPruneConfigError. Raised at orchestrator entry, BEFORE any warehouse call (DEC-008).PruneTimeoutError— Internal control-flow signal for budget-exhausted dispatch. Callers ofprune_testsdo NOT see this — the orchestrator routes the in-flight test plus every remaining un-started test tokept-without-evidence(DEC-011).PruneAuditWriteError— Fail-closed audit-write failure (OSError/PermissionError/ encoding /fsync). Aborts the run; original cause exposed via.causeand__cause__(DEC-016).PruneAuditRecordTooLargeError— Serialised JSONL line exceeded the POSIX-atomic-append cap (4000 bytes). Raised BEFORE any file is opened so an oversize record leaves no on-disk artefact.
DEC-006 deliberately omits a PruneCompilerError class. Compilation
always succeeds; failures like relationships(to: unknown) emit a
structured requires-future-data drop reason rather than an exception.
Configuration: signalforge.yml prune: block
Top-level namespace is prune: (DEC-020; sibling keys safety:,
llm:, future grade: are reserved for other stages and silently
ignored by the prune loader).
safety:
# ... (loaded by signalforge.safety)
llm:
# ... (loaded by signalforge.draft)
prune:
enabled: true # set false to skip prune entirely (no warehouse contact)
scope: sample # "sample" | "full"
sample_strategy: materialised # "materialised" (default, v0.2) | "oneshot" (v0.1 fallback)
sample_size: 100000 # rows
test_timeout_seconds: 30
total_budget_seconds: 600
capture_failure_rows: 3
min_kept_rate_warn: 0.0 # WARN when kept/total <= this; 0.0 = warn on "all dropped"
trusted_models:
- model.shop.dim_customers
partition_filter:
column: event_dt
op: ">="
value: "2026-01-01"
Field-by-field:
enabled—bool. Defaulttrue. Whenfalse,prune_testsshort-circuits: it does NOT issue any warehouse calls, does NOT validatetrusted_models, and routes every candidate tokept-without-evidencewithwhy="prune disabled in signalforge.yml". The audit JSONL still records onePruneEventper candidate (fail-closed audit preserved). The CLI emits an INFO line at prune-stage entry. Trade-off: disabling prune lets always-pass tests reach the diff — directly counter to Architectural Commitment #1 (signal over volume). Use as a temporary escape hatch when warehouse contact is unavailable (offline, credentials issue, cost ceiling) and you still need a draft run.scope—"sample"|"full". Default"sample". Whether candidate tests run against a deterministic warehouse sample or a full table scan. Switch to"full"only when the model is small enough thatsample_sizewould scan most of it anyway.sample_strategy—"materialised"|"oneshot". Default"materialised"(v0.2 — see issue #22). When set to"materialised",prune_testscallsadapter.materialise_sample(...)ONCE before the per-test loop — the adapter creates a_SESSION._sf_sample_<run_id>temp table and every test's compiled SQL reads from it (per-test bytes drop from ~9.92 GB to <100 MB on the AR-B1 reference workload). When set to"oneshot", the v0.1 path runs unchanged — every test issues its own deterministic-sample query against the source table. Adapters that don't overridematerialise_sample(any non-BigQuery adapter in v0.2) raiseMaterialisationNotSupportedError; the orchestrator then routes every candidate tokept-without-evidenceper the conservative-bias rule (see Drop-reason taxonomy). Operators on non-BQ adapters opt out viasample_strategy: oneshot.sample_size— Integer row count for sample scope. Default100_000. Passed toWarehouseAdapter.sample_rows. Increase when the always-pass false-positive rate on small samples hides real signal; decrease to cap query bytes on very wide tables (column-pruning does NOT apply throughFARM_FINGERPRINT(TO_JSON_STRING(t))— see Cost model).test_timeout_seconds— Per-test wall-clock budget. Default30. Reserved for v0.2 — the adapter's_default_job_config(timeout_ms=...)plumbing exists (US-002 of issue #3) butWarehouseAdapter.run_test_sqldoes not yet accept a per-call timeout kwarg, so v0.1 does not enforce this knob. Per-test wall-clock control in v0.1 comes implicitly fromtotal_budget_secondsplus theWarehouseErrorcatch path: a test that exceeds the warehouse's own budget surfaces as a typed error →kept-without-evidence. See v0.2 deferrals.total_budget_seconds— Whole-run wall-clock budget. Default600. Once exceeded, every remaining test drains tokept-without-evidencewithwhy="Total prune budget (Ns) exceeded before evaluation."(DEC-011). Conservative bias — no test is silently dropped because the run ran long.capture_failure_rows— Number of failing rows recorded on thePruneDecision.sample_failuresfield per failed test. Default3. Set to0to omit row-level evidence entirely (the audit record stays compact for very wide tables).trusted_models— List of manifestunique_ids whose data is treated as known-clean. A failure on a trusted model surfaces asfailed-on-known-clean-data(drop, presumed buggy test) rather thankept. Opt-in only (Q1=B). Validated against the manifest atprune_testsentry — typos raisePruneTrustedModelNotFoundErrorBEFORE any warehouse call (DEC-008).min_kept_rate_warn— Float in[0.0, 1.0]. Default0.0. Soft signal threshold for "did the prune work as intended?" (issue #51). Whenkept_count / total_testsis at or below this value AND at least one candidate was evaluated, the orchestrator emits oneWARNING-level log line summarising the run shape (model id, total/kept/dropped counts, kept rate, threshold). Default0.0fires only when every candidate was dropped — the "did we lose the whole LLM draft?" signal. Set to0.10to catch "fewer than 10% kept" on typical staging models, or to1.0to always emit the summary line. The WARNING is informational — the run still returns aPruneResultand exits cleanly. Empty candidate sets skip the check (no division-by-zero; the drafter producing nothing is its own degenerate signal). See Expected drop rates for the empirical context this threshold is meant to calibrate against.partition_filter— OptionalPartitionFilterADT ({column, op, value}) scoping every sample query. Required by the warehouse adapter for tables withnum_rows >= 100M; otherwise optional. Pydantic recursively validates the YAML mapping into the typed shape.
Unknown keys under prune: raise PruneConfigError (Pydantic
extra="forbid", DEC-015). Typos like scop: or
total_budget_secnds: fail loud at load time rather than silently
no-op'ing.
Drop-reason taxonomy
Every kept and dropped test ships with a structured PruneDecision
carrying a one-line why. The reasons are a closed DropReason literal
so the diff renderer (#8) can branch on the value.
| Reason | Decision | Why |
|---|---|---|
always-passes |
dropped | Zero failing rows on the sampled or full set; no signal worth shipping. The load-bearing case for Architectural Commitment #1. |
requires-future-data |
dropped | A relationships test references a to: parent model not in the loaded manifest, OR a custom_sql test's {{ ref() }} / {{ source() }} target is absent from the manifest (issue #116). No warehouse call issued — the compiler returns a _RequiresFutureData sentinel and the orchestrator routes it directly to this reason (DEC-026). |
failed-on-known-clean-data |
dropped | Test failed AND model.unique_id is in prune.trusted_models; the test is presumed buggy. Symmetric noise-direction split with always-passes — both directions of noise need pruning per CLAUDE.md. |
kept |
kept | Test failed on an untrusted model with non-zero failures. Reviewer should evaluate. |
kept-without-evidence |
kept | Could not evaluate — warehouse error (typed WarehouseError subclass), total budget exceeded (DEC-011), or a custom_sql test whose SQL carries unsupported Jinja / an ambiguous ref() / a SQL-safety rejection (issue #116; see custom_sql evaluation). Ship conservatively; reviewer decides. |
Conservative bias: when in doubt, keep. Architectural Commitment #1 penalises always-pass tests (no signal, consumes reviewer attention) but does not penalise kept tests with ambiguous evidence — those land in front of a human reviewer who can make the final call.
custom_sql evaluation
The drafter's fifth test variant — custom_sql, the free-form singular
SQL business-rule test (issue #116; see
docs/draft-ops.md)
— is pruned through the same orchestrator and routes to the same five
DropReason literals as the four built-ins. There is no new drop
reason; what differs is how the test compiles and gets sampled.
Jinja resolution first. custom_sql.sql may reference {{ this }},
{{ ref('<model>') }}, and {{ source('<src>', '<table>') }}. The
compiler resolves these via the bounded resolver
(signalforge.manifest.template.resolve_template_refs) before any
warehouse call. The resolution outcome decides the routing:
- Resolved cleanly → the test is sampled / full-scanned and routes
to
always-passes/kept/failed-on-known-clean-dataexactly like a built-in. {{ ref() }}/{{ source() }}targets a model/source absent from the manifest →requires-future-data(mirrors therelationshipsmissing-target precedent — the referenced model simply isn't built yet; revisit when the dependency lands). No warehouse call.- Control-flow Jinja (
{% if %},{% for %},var(),env_var(), macros), an ambiguousref()(matches multiple packages), or a SQL-safety pre-flight rejection on the resolved SQL →kept-without-evidence. We cannot evaluate it, so we ship it for the reviewer rather than silently dropping it. No warehouse call.
Single-table vs. multi-table sampling. Once the SQL resolves, the
compiler decides how to bound the scan with a cheap heuristic — does a
word-boundary JOIN keyword survive string-literal stripping?
- Single-table (no
JOIN) — the resolved SQL references only the model's own table. Inscope="sample"the model's table is substituted with the deterministic-sample CTE alias (identical to the built-ins, so per-test bytes stay bounded). Inscope="full"a partition filter is applied when one is configured. - Multi-table (a
JOINsurvives literal-stripping) — runs full-scan (unsampled), because sampling only one side of a join is semantically wrong: an orphan-detection join against a sampled child would report false orphans for parents that are simply absent from the sample. A partition filter is still applied to the model's own table when one is available.
The bytes cap is the only guardrail on a multi-table full-scan. A
multi-table custom_sql test reads every row of the joined tables —
there is no sample CTE to bound it. The adapter's
maximum_bytes_billed cap (default 100 MB, DEC-005; raise via the
profile-level maximum_bytes_billed field — see
docs/warehouse-adapter-ops.md) is what
stops a runaway scan. Tuning note: if a multi-table business rule
spans large fact tables, either raise the cap deliberately (and accept
the per-test cost) or scope the rule with a partition_filter so the
model's own side is bounded. When the resolved query's pre-execution
byte estimate exceeds the cap, the warehouse rejects the query before
execution; the typed WarehouseError is caught and the test routes to
kept-without-evidence (why carries the warehouse error class) — the
test ships, unevaluated, for the reviewer.
In the expected-drop-rate framing below,
custom_sql tests behave like the built-ins: a business rule that the
warehouse data never violates is always-passes (dropped, no signal); a
rule the data does violate is kept (real signal — exactly the rows a
reviewer wants to see). The one categorical difference is the higher
kept-without-evidence / requires-future-data fraction: free-form SQL
has more ways to be unevaluable (unsupported Jinja, unbuilt refs) than a
generic schema test. That is the conservative-bias contract working as
designed — an unevaluable business rule is shipped, never silently lost.
Row-count cost model
The sixth test variant, row_count_between (issue #169; see
docs/draft-ops.md),
is pruned through the same orchestrator and routes to the same five
DropReason literals as the five other variants. There is no new
drop reason; what differs is the SQL shape and the cost profile.
Compiled SQL. The compiler emits a failing-rows CTE wrapping a
single COUNT(*) (DEC-014):
SELECT n
FROM (SELECT COUNT(*) AS n FROM <table> [WHERE <where>]) AS rc
WHERE n < <minimum> OR n > <maximum>
The bound-violation predicate adapts to which bounds are set
(n < <min>, n > <max>, or the conjunction). The adapter wraps the
output in the standard SELECT COUNT(*) AS failures FROM (<sql>) AS t
contract — zero rows from the inner SELECT (the bound holds) means
failures=0 → engine routes always-passes and the test drops; one
row (the bound was violated) means failures=1 → engine routes kept
(or failed-on-known-clean-data on a trusted model). The
CTE-then-WHERE shape is load-bearing: a bare SELECT COUNT(*) FROM <table>
wrapped by the adapter would always emit failures=1 regardless of the
bounds, because the inner COUNT(*) always returns exactly one row.
The CTE pushes the bound check into the inner SELECT so the outer
failures count reflects the real verdict (US-007a corrected this
shape after #169 first landed).
Sample-mode behaviour — engine routes past the materialised sample.
The compiled SQL is identical regardless of prune.scope. A sampled
COUNT(*) is semantically wrong — a sample-bucket-mod'd subset can't
be compared against the full-table bounds, and a materialised sample
counted directly would return the sample size (typically 100K
rows), not the model's true row count.
prune_tests therefore overrides table_ref to the source table
for every row_count_between candidate, regardless of
sample_strategy (materialised or oneshot) and regardless of
whether the rest of the run uses the sample. The
materialised-sample-substitution contract
from issue #116 still applies to the other five test types (which
read row-level data the sample faithfully represents); only
row_count_between is the exception. When every candidate in a run
is row_count_between, the engine also skips the
materialise_sample / get_row_count pre-work entirely — there's no
sample to set up, so adapter errors on that path can no longer route
the bypassing tests to kept-without-evidence.
Cost guidance. A row_count_between query is a single aggregate
COUNT(*) on the source table — cheap even on petabyte tables on both
BigQuery and Snowflake (a few seconds, scan billed on the bytes the
analyzer touches; not a metadata-only operation but bounded by the
size of the columns the aggregate references). A where-filtered
COUNT(*) is partition-aligned at best, full-scan at worst — if
the filter aligns with the partition column the scan reads only the
matched partitions; if it doesn't, the warehouse reads the whole table
to evaluate the predicate.
The adapter's maximum_bytes_billed cap (default 100 MB; raise via the
profile-level maximum_bytes_billed field if needed) plus
prune.total_budget_seconds are the safety nets — a row_count_between
query that exceeds the cap is rejected by the warehouse before
execution and the test routes to kept-without-evidence per the
conservative-bias contract (the why field carries the warehouse error
class name).
Empty-table → kept (DEC-010). An empty warehouse table evaluated
against minimum=100 produces n=0, which violates the bound, which
emits one failing row, which routes to kept. This is the intended
behaviour, not a degenerate edge case: catching "the table is empty
when it shouldn't be" is exactly what the test exists to do — a
broken upstream pipeline is real signal, and the bounded-cardinality
assertion is the canonical way to surface it. There is no special-case
in the engine; the routing follows the standard decision matrix. If
you're reading a kept decision against an empty table and wondering
whether the test "fired correctly," the answer is yes — the bound was
violated and the diff is telling you the upstream is broken. An
operator who wants "empty table is fine" semantics for a particular
model should either not declare row_count_between on that model or
add it to exclude_tests in signalforge.yml.
In the expected-drop-rate framing below,
row_count_between tests behave like the built-ins: a model whose
warehouse rows fall comfortably within the bounds is always-passes
(dropped, no signal); a model whose row count violates the bound is
kept (real signal — exactly the case a reviewer wants to see). The
one categorical difference is that a single row_count_between
candidate exercises the whole table (or the whole where-filtered
slice), not a per-column sample — so its cost is a COUNT(*) scan
rather than the per-column sample CTE. Plan budget accordingly on
projects ingesting many existing expect_table_row_count_to_be_between
declarations via prune-existing — the per-test cost is small but
N-many COUNT(*)s adds up.
unique_combination — same engine routing, GROUP BY shape (issue #170)
The seventh test variant, unique_combination (see
docs/draft-ops.md
and docs/drafter-catalogue.md), is pruned
through the same orchestrator and routes to the same five DropReason
literals. The compiler emits a multi-column GROUP BY identical in shape
to the single-column unique test:
SELECT <col1>, <col2>[, ...] FROM <table> [WHERE <where>]
GROUP BY <col1>, <col2>[, ...]
HAVING COUNT(*) > 1
The shuffle cost is the same as single-column unique — a GROUP BY
over the same row count produces the same intermediate row count
regardless of key cardinality (bounded by maximum_bytes_billed).
Sample-mode behaviour — engine routes past the materialised sample.
A sampled GROUP BY is semantically approximate: uniqueness violations
in the full table may not surface in the sample (false-negative). The
prune engine therefore routes unique_combination past the
materialised-sample substitution to the source table, mirroring
the row_count_between metadata-bypass pattern (DEC-006 of #170).
This applies under both sample_strategy="materialised" and
sample_strategy="oneshot". Plan cost accordingly — a
unique_combination candidate always full-scans the source (bounded
by maximum_bytes_billed), never the sample.
row_count_anomaly_by_period
The eighth test variant, row_count_anomaly_by_period (issue #171; see
docs/drafter-catalogue.md § row_count_anomaly_by_period),
is pruned through the same orchestrator and routes to the same five
DropReason literals as the seven other variants. There is no new
drop reason; what differs is the two-query evaluation shape, the
time-bound reproducibility carve-out, and the partition-filter cost
mechanics on date-partitioned source tables.
Two-query split — stats query first, then violation query (DEC-008).
Unlike the single-statement variants above, row_count_anomaly_by_period
compiles to TWO queries that the engine runs sequentially:
- Stats query (Query 1) — returns one row of method-specific
statistics from the model's
lookback_periodsof history:(median, MAD, n)formethod=mad,(μ, σ, n)formethod=zscore,(p_lo, p_hi, n)formethod=percentile,(min, max, n)formethod=min_max. Underseasonality="dow"the result is one row per day-of-week bucket. PopulatesAnomalyTestStatson thePruneDecision. - Violation query (Query 2) — the actual band-violation check for
the most-recent period (the
as_ofbucket). Returns failing rows when the bucket'sCOUNT(*)falls outside the band; the adapter wraps in the standardSELECT COUNT(*) AS failures FROM (<sql>) AS tcontract.
If Query 1 reports n_periods < min_samples_per_bucket (cold-start),
the engine skips Query 2 entirely and routes the candidate to
kept-without-evidence with structured why="insufficient history:
<n>/<min> periods". The DropReason literal stays at five values —
cold-start re-uses the existing kept-without-evidence slot per the
conservative-bias contract (see Drop-reason taxonomy).
Time-bound reproducibility carve-out — --as-of YYYY-MM-DD (DEC-001).
Every other SignalForge primitive satisfies Architectural Commitment #5
("same input → same prune decision"). row_count_anomaly_by_period
cannot: a per-period anomaly check evaluated on Monday and again on
Tuesday may produce different decisions because the underlying band
shifts as history accrues and the "most-recent period" moves forward.
Reproducibility is restored at the (model, as_of) granularity via
the --as-of CLI flag (signalforge generate and
signalforge prune-existing).
When omitted, the engine resolves to date.today() at prune time and
emits one INFO log line naming the resolved value (lazy-format JSON);
the resolved date lands on every PruneEvent.as_of audit field for
after-the-fact reproducibility (re-run with --as-of <recorded value>
to reproduce the prior decision). In a multi-model --select batch
the same --as-of applies to every model — resolved once at the
orchestrator. See docs/cli-ops.md § --as-of for the
flag reference.
Sample-mode behaviour — always routes to source, regardless of
strategy (DEC-002, DEC-009). A hash-mod sample over a date-partitioned
table does not preserve per-period counts (a 1/N sample shrinks the
"yesterday" bucket the same way as every other bucket, so per-period
anomaly detection on a sample reports the sample's own anomaly
profile — useless). The engine's _test_requires_source_table helper
returns True for row_count_anomaly_by_period under any
sample_strategy (materialised OR oneshot), tighter than
row_count_between / unique_combination (which bypass under
materialised only in pre-#171 builds; #171 graduated both to also
bypass under oneshot for the same semantic-correctness reason). One
INFO log line names the per-test source override.
Partition-filter cost mechanics (DEC-012) — load-bearing for cost. The compiled SQL must include a partition-pruning WHERE clause:
<date_column> >= <as_of> - INTERVAL <lookback_periods> <period>
AND <date_column> < <as_of> + INTERVAL 1 <period>
(or the dialect-equivalent form). Without it, the warehouse scans every partition; with it, BigQuery and Snowflake prune to the lookback window only.
Worked example. A 1-billion-row event table partitioned daily
(PARTITION BY DATE(event_ts)) with ~11M rows / day. A 90-day-lookback
row_count_anomaly_by_period test:
- Unfiltered — scans all 1B rows. At ~10 bytes / row for the
partitioned-date column alone, that's ~9 GB scanned per test.
Over 50 candidate anomaly tests across a project, ~450 GB of
warehouse cost per
signalforge generaterun. - Partition-filter pruned — scans 90 partitions of ~11M rows each (~990M rows narrowed). With BigQuery's partition pruning the metadata-only date scan reads ~300 MB. ~30× reduction; ~15 GB across 50 candidates instead of 450 GB.
The --as-of value drives the partition-filter literal; choosing
--as-of 2026-01-15 with lookback_periods=90 period=day prunes to
the [2025-10-17, 2026-01-16) partition range, regardless of which
date you run the command.
There is no new opt-in flag for the partition filter — the
compiler always emits it (DEC-012). The existing
maximum_bytes_billed cap remains the safety net: a query that
exceeds it surfaces as BytesBilledExceededError → routes to
kept-without-evidence per the conservative-bias contract.
DOW degrade WARNING (US-011). Under seasonality="dow" + thin
per-DOW samples (any DOW bucket below min_samples_per_bucket), the
engine degrades to non-seasonal: recomputes the stats query
without DOW partitioning and proceeds with the test. The degrade
emits one operator-actionable WARNING log line per test naming the
model, test column, the affected DOW bucket(s), and the per-bucket
counts. The candidate's decision then proceeds normally (typically
kept or dropped); the WARNING is informational so operators see
when seasonality="dow" is asking more of their history than they
have data for. Tune by lowering min_samples_per_bucket, widening
lookback_periods, or switching to seasonality="none" in
signalforge.yml.
Non-BigQuery adapter degrade — StatsQueryNotSupportedError. v0.7
ships the BigQuery override of WarehouseAdapter.run_stats_query (the
new vendor-neutral seam for the two-query split). Non-BigQuery
adapters (Snowflake, Postgres) inherit the ABC default, which raises
StatsQueryNotSupportedError — the prune engine catches this as any
other WarehouseError and routes the anomaly test to
kept-without-evidence. Operators on non-BigQuery warehouses see all
row_count_anomaly_by_period candidates land in kept-uncertain with
the typed error name in the why field until each adapter grows its
own run_stats_query override. Mirrors the
MaterialisationNotSupportedError / EstimateNotSupportedError /
RowCountNotSupportedError graceful-degrade pattern (see
docs/warehouse-adapter-ops.md).
Cost-and-budget guidance. A row_count_anomaly_by_period candidate
issues 1 (cold-start) or 2 (warm) warehouse queries per test. With the
partition filter active the per-test cost is small (single-digit
seconds, ~300 MB on a billion-row daily-partitioned table); without
the partition column populated the cost grows ~30× and the
maximum_bytes_billed cap becomes the actual ceiling. Plan
prune.total_budget_seconds accordingly when the project carries N
anomaly candidates: budget ~2-5s per warm candidate plus the
materialisation step's own time when other variants share the run.
Expected drop rates
A high drop rate is the working state, not the failure state. The
LLM is intentionally drafting broadly — it proposes not_null on every
column, unique on every column that looks like a primary key, etc.
The prune layer trims the candidates that always pass on warehouse
samples (no signal) and the ones that fail on known-clean data (likely
buggy test). What survives is what a human reviewer should actually
look at. A run that drops ~60-80% of the drafted tests on a typical
staging model is doing exactly what it's supposed to.
Reference numbers — Austin bikeshare staging fixture. Run captured
2026-05-09 against
bigquery-public-data.austin_bikeshare.bikeshare_trips (~2.27M rows,
7 columns), safety.mode: aggregate-only,
prune.sample_strategy: materialised, claude-sonnet-4-6 drafter:
- Total candidate tests drafted: 8
- Dropped (
always-passes): 5 (62.5%) - Kept (
reason="kept", non-zero failing rows): 3 (37.5%)
Per-test-type breakdown for this run:
| Test type | Drafted | Dropped | Kept | Notes |
|---|---|---|---|---|
not_null |
7 | 4 (57%) | 3 (43%) | Dropped on natural NOT NULL columns (trip_id, bike_id, start_time, duration_minutes); kept on subscriber_type (249 nulls — walk-up users), start_station_id (199 nulls), end_station_id (1408 nulls — stations decommissioned mid-trip) |
unique |
1 | 1 (100%) | 0 | trip_id is the natural primary key — unique always passes |
The three kept tests are exactly the signal the differentiator
promises: real nullability that a reviewer should think about before
shipping a not_null test. The five dropped tests would have been
review-noise — they pass deterministically against the source data.
Calibration heuristics (based on internal testing across staging models):
- Wide fact tables with mostly-NOT-NULL surrogate keys drop more
aggressively — most
not_nullcandidates trivially pass; the kept set concentrates on FK columns and optional dimensions. - Narrow dimension tables tend to have a higher kept rate — fewer drafted tests overall, and the columns under test are usually declared-nullable business attributes.
uniquecandidates on declared primary keys are almost always dropped as always-passes (the warehouse data matches the model's contract). When auniquecandidate is kept, that's high-signal: the surrogate-key contract is broken in production data.relationshipscandidates are usually kept (referential integrity is rarely perfect at warehouse scale); when they drop asrequires-future-data, the target model isn't in the manifest yet (likely an unimplemented downstream).
A run that drops everything is the failure mode to watch for — it
suggests the LLM proposed nothing the warehouse data contradicted, or
that the model under draft has so little data that every test is
trivially passing on the sample. Set
prune.min_kept_rate_warn
to surface this case at run time.
Cost model (US-003 verification)
The deterministic-sample predicate
WHERE MOD(ABS(FARM_FINGERPRINT(TO_JSON_STRING(t))), bucket) < 1
serialises the entire row into the predicate. BigQuery cannot column-prune through a function argument, so sample-mode reads all columns of the table, not just the column under test. The Phase-1 estimate that pruning 30 candidate tests against a 100k-row sample would cost approximately 24 MB / approximately one tenth of one US cent assumed only the column under test would be read; the worst case is 50–500x that figure.
Verified figure (US-003): 9,924,771,840 bytes (≈9.92 GB), run 2026-05-01
against bigquery-public-data.iowa_liquor_sales.sales (~30M rows, ~24
columns), 100k-row deterministic sample. AR-B1 confirmed: the
TO_JSON_STRING(t) predicate triggers a full-row scan, and the actual
cost is ~99× the Phase-1 estimate (24 MB) and ~2× the probe's 5 GB
sanity ceiling. The figure is BigQuery's pre-execution analyzer estimate;
the adapter's 100 MB maximum_bytes_billed cap (DEC-005) blocked the
query before execution, so this is the cost the user would pay if the
cap were lifted, not a measured total_bytes_billed off a completed
job. The pre-execution estimate matches what BQ would bill on a real
prune run (the analyzer reads the same statistics the billing pipeline
uses).
To reproduce:
gcloud auth application-default login
SF_RUN_BQ=1 pytest -m bigquery tests/warehouse/test_sample_cost_probe.py -s --no-cov
The probe currently fails (rather than xfails) on the
bytesBilledLimitExceeded path because the assertion ceiling and the
adapter cap are decoupled. Refining the probe to detect the BigQuery
reason code bytesBilledLimitExceeded (which appears in the error
message regardless of HTTP status) and xfail cleanly is tracked as a
follow-up. Note the SDK exception class is unstable on this path: the
adapter's map_bq_exception (adapters/_client.py) catches
google.api_core.exceptions.BadRequest (HTTP 400), but the live run
on 2026-05-01 with google-cloud-bigquery==3.41.0 raised
google.api_core.exceptions.InternalServerError (HTTP 500). The reason
code is the durable identifier; match on substring rather than the
exception class. The 9.92 GB figure is captured directly from the
error message: Query exceeded limit for bytes billed: 100000000.
9924771840 or higher required.
Q4=A is NOT adequate for v0.1 sample-mode on wide tables. Issue #22
tracks Q4=C escalation (temp-table-materialised sample) for v0.2. In the
meantime, sample-mode prune runs on tables wider than ~10 columns will
either trip the adapter's 100 MB cap and fail, or — if a maintainer
raises the cap via the profile-level maximum_bytes_billed field
(load_profile, see docs/warehouse-adapter-ops.md) — bill at roughly
(rows × bytes_per_row) for every test in the candidate set.
Schema-only mode remains the v0.1 default precisely because the cost
model for sample-mode is not where we want it.
Probe thresholds (constants in the test, kept for the post-Q4=C run):
_BYTES_WARN_AT = 500_000_000(500 MB) — soft WARNING fires above this._BYTES_CEILING = 15_000_000_000(15 GB) — assertion fires above this; the test fails. Raised from 5 GB during the 2026-05-08 maintainer probe-run after AR-B1's 9.98 GB measurement was confirmed to genuinely exceed the original 5 GB ceiling (probe self-inconsistency).
Post-Q4=C: temp-table-materialised sample (v0.2, issue #22)
Issue #22 lands sample_strategy: materialised as the v0.2 default.
The materialise-once pattern amortises the full-row scan across every
candidate test by pre-computing the deterministic sample into a
_SESSION._sf_sample_<run_id> temp table; per-test queries read from
the materialised sample (post-LIMIT, narrow) rather than re-running
MOD(ABS(FARM_FINGERPRINT(TO_JSON_STRING(t))), <bucket>) < 1 against
the source table for every test.
Maintainer-run figures (recorded 2026-05-08 against
bigquery-public-data.iowa_liquor_sales.sales, ~30M rows, 100k-row
deterministic sample, billed to duenow-nest):
- Materialisation query bytes_billed: ~9.98 GB (one-time CTAS;
scans every column once with the deterministic predicate
MOD(ABS(FARM_FINGERPRINT(TO_JSON_STRING(t))), <bucket>) < 1). Effectively the same scan as the v0.1 oneshot path, but paid once perprune_testsinvocation rather than once per candidate test. - Per-test bytes_billed (representative
IS NULLtest): 10,485,760 bytes (~10 MB). Two orders of magnitude under the 100 MB acceptance gate. - Total run bytes_billed (1 materialise + 30 per-test): ~9.98 GB + 30 × ~10 MB ≈ 10.3 GB.
- Cost ratio vs. v0.1 oneshot baseline (~9.98 GB × 30 = ~299 GB): ≈29× cheaper end-to-end on a 30-test run; ratio scales linearly with N as the materialisation cost amortises across more tests.
The two regression-guard tests (@pytest.mark.bigquery, DEC-007 of
issue #22):
test_sample_rows_cost_baseline_oneshot— pins the AR-B1 9.92 GB baseline forsample_strategy=oneshotso a regression in the oneshot path stays visible after the materialised default takes over.test_sample_rows_cost_materialised— asserts per-test bytes_billed drops below 100 MB undersample_strategy=materialised.
The 9.92 GB AR-B1 figure remains the v0.1 oneshot reference and the oneshot fallback's cost story for non-BQ adapters in v0.2.
Audit JSONL schema
Consumer guide. For cross-stage joins,
jq/ pandas worked examples, the forward-compat policy, and the redaction surface, seedocs/audits.md. This section is the prune-layer production contract.
Every PruneDecision produces exactly one JSONL record at
audit_path (default <project>/.signalforge/prune.jsonl). One record
per line; atomic concurrent appends via O_APPEND | O_CREAT | 0o600 and
a single os.write (DEC-016). The third instance of the convention
across the codebase — mirrors signalforge.safety.audit (DEC-011 of
safety) and signalforge.draft.audit (DEC-006/008/013 of llm-drafter).
PruneEvent fields (~19 total):
| Field | Type | Meaning |
|---|---|---|
audit_schema_version |
integer (Literal[2]) |
Audit shape version. Currently 2 (bumped 1→2 by issue #55 when config_hash migrated to blake2b-8). Bump only on shape change; extra="ignore" handles additions. |
signalforge_version |
PEP-440 version string | Package version that produced the record. |
record_id |
32-hex-char string | Fresh uuid4().hex per record; gives reviewers a stable handle for a single decision. |
timestamp |
ISO-8601 UTC, microsecond, Z |
When the decision was finalised. |
config_hash |
16 hex chars | blake2b(canonical_config_json, digest_size=8). Migrated from SHA-256[:16] by issue #55 so the audit corpus reads one hash recipe across every writer. Mirrors safety's policy_hash (DEC-005). |
model_unique_id |
string | dbt unique_id of the pruned model. |
test |
discriminated-union object | The original CandidateTest from the drafter (typed; not a loose dict — DEC-004). |
test_anchor |
string | "column.<name>" for column-scoped tests; literal "model" for model-level tests. |
decision |
"kept" | "dropped" |
Top-level verdict. |
reason |
DropReason literal |
One of the five reasons in Drop-reason taxonomy. |
failures |
integer | Failing-row count from the warehouse. 0 for always-passes and requires-future-data. |
sampled_rows |
integer or null |
Sample size the test ran against. null for full-scope or no-warehouse-call decisions. |
scope |
"sample" | "full" |
Mirrors PruneConfig.scope. |
elapsed_ms |
integer | Per-test wall-clock cost. 0 for budget-exhausted (test never ran). |
compiled_sql_hash |
16 hex chars | blake2b(sql.encode(), digest_size=8).hexdigest(). Stable empty-string hash for no-SQL outcomes. |
compiled_sql |
string | The exact SELECT issued to the warehouse. Empty for requires-future-data and budget-exhausted. |
why |
string | One-line human-readable rationale. Architectural Commitment #5. |
sample_failures |
array of object or null |
Up to capture_failure_rows failing rows. null when capture is disabled or no failures. |
Fail-closed semantics. OSError / PermissionError / encoding
failures from os.write / os.fsync propagate raw; the orchestrator
wraps them as PruneAuditWriteError and aborts the run.
PruneAuditRecordTooLargeError (size cap, raised before any file open)
also aborts the run. Don't wrap audit-write calls in defensive
try/except — propagation IS the defence (mirrors
safety-layer.md DEC-011).
Schema-drift gate. tests/fixtures/prune/prune_event_v1.jsonl is
the canonical schema fixture; tests/prune/test_drift_detector.py
pairs the production model (extra="ignore") with a one-off
extra="forbid" strict model and validates against the fixture.
Adding a field to PruneEvent without updating the strict model OR
the fixture breaks the test loudly. Don't bypass.
Audit reading guide: spotting materialised vs. oneshot runs (issue #22)
The compiled_sql field on every PruneEvent is the durable signal
that distinguishes a materialised-strategy run from a oneshot run:
- Materialised (v0.2 default): every test's
compiled_sqlreferences the temp table — look forFROM \SESSION._sf_sample` (two-partSESSION._sf_sample; no. prefix because the adapter returnsTableRef(project=None, ...)— BigQuery rejects the three-part.SESSION. <16-hex>form even inside the owning session). The_SESSIONdataset and the_sf_sampletable name are load-bearing —_SESSIONis BigQuery's session-scoped namespace, and the 16-hexrun_idderives deterministically fromblake2b(table.qualified_name + signalforge_version + str(n) + canonical_json(partition_filter), digest_size=8).hexdigest()(inputs joined with NUL separator). Same input → samerun_id→ samecompiled_sql_hash` (DEC-001 of #22). - Oneshot (v0.1 fallback / non-BQ adapters):
compiled_sqlreferences the source table directly — no_SESSIONprefix.
Joining a prune.jsonl line to the materialisation query in
INFORMATION_SCHEMA.JOBS_BY_PROJECT is the operator's path for
post-mortem cost attribution; see
docs/warehouse-adapter-ops.md § Session cleanup & manual recovery
for the query template.
A kept-without-evidence decision whose why field starts with
"sample materialisation failed: " is the conservative-bias signal
that materialisation raised at orchestrator entry — every candidate
in the run shares the same why shape, and the operator should
inspect the orchestrator-level WARNING (see
docs/cli-ops.md § Stderr shapes)
for the materialisation error class and message.
Audit log sensitivity
prune.jsonl contains the model's compiled SQL and (when
capture_failure_rows > 0) up to N rows of failing data per test.
Treat the file at-rest the same way you treat the safety audit:
- Gitignore
.signalforge/(already configured in this repo's.gitignore). - Restrict at-rest permissions. The writer creates the file at
0o600on first call; the parent directory is created viamkdir(parents=True, exist_ok=True)(Python'smkdirdoes not tighten an existing directory's permissions, so verify the existing.signalforge/mode is0o700on shared hosts). - Don't ship as a build artifact. Strip from container images and CI uploads.
- Set
capture_failure_rows: 0for PII-laden models if the safety layer's redaction policy isn't enough — the prune layer captures real warehouse rows for failures, which can include PII not flagged for the LLM redactor.
Running real-warehouse tests
The prune layer's integration tests share the warehouse adapter's
gating discipline. Default CI excludes them via the -m 'not bigquery'
filter; opt-in requires both the marker and an SF_RUN_BQ=1 env var.
gcloud auth application-default login
SF_RUN_BQ=1 pytest -m bigquery --no-cov
The prune-layer integration test (tests/prune/test_integration_bigquery.py)
requires ambient gcloud auth (matches the warehouse adapter's
tests/warehouse/test_bigquery_integration.py).
The diagnostic cost probe (US-003) at
tests/warehouse/test_sample_cost_probe.py runs under the same gate.
It is a documentation-grade probe — a soft WARNING fires at 500 MB; the
test fails only above the 5 GB sanity ceiling. See
Cost model.
Debugging
Logger name: signalforge.prune.engine (and sibling modules under
signalforge.prune).
import logging
logging.getLogger("signalforge.prune").setLevel(logging.DEBUG)
Levels:
- WARNING — One line per
kept-without-evidencedecision routed by a typedWarehouseError. Lazy-format JSON per DEC-017 (signalforge_version,model_unique_id,test_anchor,error_class). Never f-string-interpolate user-controlled strings into a logger call — a column name or model id containing ANSI escapes (\x1b[31m...) would inject into log viewers. - INFO / DEBUG — reserved for future budget-loop / batching observability; v0.1 emits no INFO/DEBUG from the engine.
The prune layer never logs full row data. The audit JSONL is the single durable record of decision-level detail; logger output is a hint that the decision happened, not what was in it.
Reading a fail-closed PruneAuditWriteError. The cause is exposed
as .cause and on __cause__. Common causes:
- Parent directory not writable (no
+wfor the user, or.signalforge/is a symlink to a read-only mount). - Disk full (
ENOSPC). - Oversize record (raises
PruneAuditRecordTooLargeErrorinstead — reducecapture_failure_rowsor trimcompiled_sqlsize by simplifying the candidate test; the cap is 4000 bytes for POSIX-atomic concurrent appends).
Snowflake compiler dialect (v0.2, issue #121)
The prune compiler emits valid Snowflake SQL purely from the SNOWFLAKE_DIALECT
value object — no branching on warehouse name, no warehouse SDK import under
signalforge/prune/. Everything warehouse-specific is read from Dialect
fields: the identifier quote char, per-component vs whole-path qualified-name
quoting, the deterministic-sample row-hash expression, the date/timestamp
literal cast form, the sample-CTE alias, and identifier_case.
Identifier case-folding. Snowflake folds unquoted identifiers to
UPPERCASE and matches quoted identifiers verbatim, so a conventional
CREATE TABLE … (customer_id …) stores the column as CUSTOMER_ID. The
compiler therefore folds every identifier (columns and each qualified-name
component) to UPPER before quoting ("CUSTOMER_ID", "DB"."SCHEMA"."T"),
which resolves correctly against conventionally-created Snowflake tables while
keeping the always-quote injection-safe posture. Residual: a table
genuinely created with quoted-lowercase DDL (CREATE TABLE … ("customer_id" …))
would not match "CUSTOMER_ID" — accepted as the rare case; the conventional
majority is the right default. BigQuery's identifier_case="preserve" is a
no-op, so BigQuery output is byte-identical to v0.1.
Sampling reproducibility caveat. BigQuery's FARM_FINGERPRINT is stable
across time, so the deterministic sample is reproducible indefinitely.
Snowflake's HASH() is deterministic only within a Snowflake release —
Snowflake documents that it may change across versions. This satisfies
SignalForge's reproducibility commitment (same input → same prune decision
within a run) but is a weaker cross-time guarantee than BigQuery's. Prune
decisions made in one run are internally consistent regardless.
QUALIFY not used. Snowflake supports QUALIFY, but the unique test
keeps the dialect-portable GROUP BY … HAVING COUNT(*) > 1 (works on both
warehouses); supports_qualify stays forward-compat metadata.
Validation. Snowflake SQL is pinned by byte-exact snapshot fixtures under
tests/fixtures/prune/compiled_sql/snowflake/. A maintainer-only gated suite
(uv run pytest -m snowflake --no-cov) executes the four built-ins through
fakesnow (asserting failing-row shape by rule semantics, never HASH()
values) and parses every fixture through sqlglot's Snowflake dialect.
Real-Snowflake HASH(*) semantics, true case-folding, and sampling behaviour
are validated by the live harness in issue #124.
v0.2 deferrals
The prune layer is intentionally narrow in v0.1. The following concerns are explicitly deferred:
- Per-decision
bytes_billedrecording (DEC-027). The adapter does not surface job stats in v0.1; the diagnostic probe (US-003) reads them viaINFORMATION_SCHEMA.JOBS_BY_USERout-of-band rather than through the adapter API. v0.2 extends the adapter's seam to return job stats so thePruneDecisioncan carry the figure natively. - Per-test
timeout_msthreading.PruneConfig.test_timeout_secondsis documented but not yet threaded throughWarehouseAdapter.run_test_sqlper call. The plumbing exists inmake_query_job_config(DEC-013, AR-B2 of issue #6); surfacing it through the public adapter signature is a v0.2 task. - Test batching — Q4=B / Q4=C optimisations. The Phase-1 plan catalogues two cost optimisations (per-column
COUNTIFbatching; temp-table-materialised sample). v0.1 does not adopt either. US-003 produces the data needed to evaluate the temp-table option in v0.2. - Multi-warehouse adapters. Postgres, Databricks, Redshift adapters slot in behind
WarehouseAdapterwithout prune changes once their adapters land. The prune compiler is fully dialect-driven (DEC-025), reading all warehouse-specific SQL from theDialectvalue object, never branching on dialectname. Snowflake compiler support landed in issue #121 (see § "Snowflake compiler dialect" above); its live warehouse harness is #124. Databricks compiler support landed in issue #223:DATABRICKS_DIALECTemits Spark/Databricks SQL (backtick quoting,xxhash64sign-bit-masked sampling hash, Spark date-arithmetic fragments), pinned by byte-exact snapshot fixtures undertests/fixtures/prune/compiled_sql/databricks/and certified for syntactic validity by an ungatedsqlglotdatabricks-dialect parse-guard over every fixture (tests/prune/test_compiler_databricks.py, runs in the default suite — no marker — becausesqlglotis a base dep and Databricks has no offline execution fake); real-Spark execution semantics are deferred to the live harness in #226. A new vendor populates aDialectand the compiler emits correct SQL with no compiler change. - Confidence intervals on
always-passes. Surfacing "less than or equal to 3/N upper-bound failure rate at 95 percent confidence" (rule of three) on the decision record so reviewers can calibrate the always-pass verdict. Also covers great-expectations-stylemostly:thresholds. - Historical always-pass evidence. Running candidate tests against multiple
run_results.jsonsnapshots to assert "never failed in last N runs." The Phase-1 plan considers this for thefailed-on-known-clean-dataevidence channel and defers to v0.2. - dbt-utils test types.
dbt_utils.unique_combination_of_columns,dbt_utils.accepted_range,dbt_utils.expression_is_true, etc. The drafter'sCandidateTestunion has six variants — the four generic schema tests plus thecustom_sqlbusiness-rule escape hatch (issue #116) plusrow_count_between(#169); the prune compiler compiles all six. Onedbt_expectationsmacro graduated in #169:dbt_expectations.expect_table_row_count_to_be_betweenis recognised byprune-existingand promoted to the structuredrow_count_betweenvariant (seedocs/ingest-ops.md§ "Recognition ofexpect_table_row_count_to_be_between"). Other namespaced dbt-utils / dbt-expectations macros remain v0.2+ territory (acustom_sqltest can express many of them by hand in the meantime). where:test modifier andseverity: warn/mostly:. dbt-core supports awhere:predicate on every test plusseverityandmostlyknobs; v0.1 prune does not consume any of these.prune_decision_id-keyed checkpoint / resumption. Long-running prune runs that resume from disk after a crash. v0.2.- LLM-generated rationale on
keptdecisions. The grader (#7) produces rubric-scored rationale; prune writes only the structured drop reason plus failure count plus scope.
CLI integration note
Tracked in issue #9.
The signalforge generate CLI will load the prune config via
load_prune_config(...) and invoke prune_tests(...) after the LLM
draft completes; the diff renderer (#8) consumes the returned
PruneResult to emit kept/dropped artifacts with their per-decision
why lines (Architectural Commitment #5 — explainable diffs).