Featurizer 0.7.0
Performance release: the two root causes found by EXPLAIN (ANALYZE) on the
live triage databases (correlated two-window drift → ADR-0012; no-stats
as_of_dates cardinality → ADR-0013) plus conservative planner tuning as an
executor default. Full-aggregator materialization on every live DB dropped from
10–357s to ~6–8s; values proven unchanged by the golden gate throughout.
Known issues
- The
widevariant (all 65 aggregators × 14 transformers) on the widest
configs can OOM the PostgreSQL backend during query planning. Diagnosed
on live donorschoose (2026-07-10): ~14.9k output columns shard into 27 group
queries of up to ~979 CTEs / 1.8 MB SQL each; planning a single group takes
30–45s and spikes backend memory until the kernel OOM killer fires (observed
at a plainEXPLAIN, with a 3000-row cohort — data volume is irrelevant).
Wide-everything is an extreme, atypical config; mitigation directions
(CTE-bounded sharding, TEMP-materialized shared pre-passes, per-group
connections) are recorded in the project TODO.
Changed
-
Conservative PostgreSQL planner/memory tuning is now an executor default.
Every generated query is a wide multi-way CTE join, which starves under
PostgreSQL's stockwork_memand collapse limits. The executor now issues
SET LOCAL work_mem = '64MB',join_collapse_limit = 20,
from_collapse_limit = 20(measured ~1.4× on dirtyduck all-agg; a supporting
lever on top of ADR-0012/0013).geqodeliberately stays ON — the aggressive
variant (256MB / collapse 30 / geqo off) crashed the backend by exhaustively
planning a 38-way join. The tuning is applied only to connections featurizer
opens itself: a caller'sconnection=is never touched, becauseSET LOCAL
would stay in force for the remainder of the caller's open transaction. On the
records fast path the SETs share one held connection (and transaction) with
the query; on the psycopg paths they are savepoint-isolated and best-effort,
like the ANALYZE. NewPLANNER_TUNING/tuning_statements()/
apply_planner_tuning()infeaturizer.executor; covered by
tests/test_executor_tuning.py. -
Executor ANALYZEs
as_of_datesbefore running (ADR-0013). The caller's
freshly-createdas_of_dateshas no statistics, so PostgreSQL assumed its
~2550-row default and planned the lateral-join body for the wrong cardinality —
a single Merge Join was 99% of donorschoose all-agg's runtime. The executor now
issues a best-effort, savepoint-isolatedANALYZE as_of_dateson its working
connection first, in every path (to_dataframe,to_arrow,to_tables).
donorschoose all-agg 293.6s → 7.5s, dirtyduck 27.6s → 7.0s (~40–50×); values
unchanged (ANALYZErefreshes stats, not data — golden gate passes). -
Two-window drift aggregators migrated to set-based pre-aggregation (ADR-0012).
kl_drift/wasserstein_drift, which ADR-0010 deferred as a non-goal, were the
entire cost of full-aggregator materialization on real data: liveEXPLAIN (ANALYZE)showed 9 correlatedSubPlans over the child stream atloops=18909
(kl_driftfiring on ordinary categorical columns × intervals, O(target×children)).
Rewritten as companion CTEs — recent/baseline counts viacount(*) FILTER(KL,
no self-join) and per-windowpercentile_cont … FILTER(Wasserstein). dirtyduck
all-agg 356.8s → 27.6s (~13×), all 272 features retained, values proven identical
by the golden-value gate (now 29 migratable aggregators / 232 frozen cases; P3M
cases added since drift is degenerate under P1M). Output column names unchanged
(ADR-0007). Companion-CTE budget guard 132 → 144. -
ln/log/sqrttransformers are now domain-guarded (ADR-0011). They
rendercase when x > 0 then ln(x) end(>= 0for sqrt) instead of a bare
ln(x), so an out-of-domain row becomes SQLNULLrather than aborting the
whole materialization withcannot take logarithm of a negative number. This
hard-broke any wide/all-transformer config the moment a transformer landed on a
signed feature (z-score, difference, deviation) — surfaced on the live-DBwide
variant. Output column names/labels are unchanged (ADR-0007). New
DomainGuardedTransformerbase; guards covered by
tests/primitives/test_transformations.py.
Fixed
- Companion pre-aggregation CTE name over 63 bytes emitted an invalid bare
~. A set-based companion CTE (ADR-0010) whose<child>_<family>_<interval>_preaggs_for_<target>
name exceeded PostgreSQL's 63-byte identifier limit was hash-capped by
pg_identifierwith a~separator (safe only inside quotes — output columns
are always quoted), but_build_preagg_ctestrips the quotes to interpolate
the name bare, leaving a~that PostgreSQL parses as an operator
(syntax error at or near "~"). This hard-broke the full-aggregator config on
any data with long categorical column names — invisible to the DB-free tests
and surfaced only by running the integration suite against the live
food-inspections / dirtyduck data (8 failing realistic tests). The cap
separator is now folded to_for the bare CTE identifier; CTE names are
internal-only, so the ADR-0007 output-column naming contract is untouched.
Regression guard:tests/test_preagg_shape.py::test_preagg_cte_name_over_63_bytes_is_a_valid_bare_identifier.