Architecture
The contributor/assistant condensation of the system as it exists in code. Reflects: M1–M13 shipped and BOTH
0.6.xinterstitials released — theabk planone (PLAN-1 as0.6.1, PLAN-2 as0.6.2),0.6.3(thepaths.experimentsselection fix), and the cockpit/perf one as0.6.4: UI-1 (the dashboard’s YAML editor), UI-2 (abk ui) and PERF-1 (the additive read path made discoverable; the scaffold flipped toincremental_reads: true). All tagged and on PyPI. M13 shipped as0.8.0— STAT-1c, STAT-2, STAT-1b, STAT-1, STAT-3a, STAT-3, STAT-4 and the STAT-6 exit gate are merged and released, STAT-5 was dropped (D13), and no default moved: every new estimator and scheme is opt-in, so a project that changes nothing reproduces0.7.0row for row. M3’s WP9 testcontainers hardening deferred to a Docker-equipped environment. M12 shipped as0.7.0(notifications): NTF-1…NTF-6 are merged and released — the send seam (abk run --notify), the urgent half (srm/error), the dedup state machine, four more channels (nine in total), the two recurring signals (stale,calibration_red— the latter behindabk validate --notify), and the exit gate +verdict_change. The milestone’s implementation record is m12-implementation-plan.md §7. Design contracts for what is being built next (the M14–M17 polish track) live in docs/specs/ + ROADMAP.md; this file must never claim unbuilt code exists. Keep in sync withdocs/and the packagedinit-claudepayload (abkit/cli/assets/claude/) on every release.
The shape
Section titled “The shape”abkit is detectkit’s twin with one organ transplanted: the detect stage
becomes a statistical compute stage; the primary entity flips from metric
to experiment. Declarative YAML + SQL run through load → compute → readout.
experiment (YAML) ──▶ load ──▶ compute (t/z/CUPED/bootstrap) ──▶ readout └ references reusable metrics (YAML + SQL)Donor codebase: /home/aleksei/wsl_analytics/detektkit (import package
detectkit) — components marked ⟲ in
architecture.md §4 port near-verbatim
(dtk→abk, detectkit→abkit).
Package layout — what exists today
Section titled “Package layout — what exists today”abkit/ __init__.py # __version__ (single source; numpy-free import path) cli/ # ✅ M2: main (lazy Click group), _output (tree style), commands/ # init/run/unlock/clean (M2), explore (M3), validate (M4), # ✅ M5: plan (read-only pre-launch power/sizing); # ✅ M11 DASH-6: dashboard (the project-level cockpit # launcher; DASH-4a added `run --metric`) core/ # ✅ M2: interval (N{s,m,h,d,w}), models (TableModel + # version_column LWW), period_planner (THE grid — one # enumeration for validator gates AND the anti-join); # ✅ M10 WP1: date|datetime anchors + interval_anchor # (cutoffs = anchor + k·cadence, kept after start_ts); # reachable ONLY via ExperimentConfig.grid() (AST gate) config/ # ✅ M2: project/profile/experiment/metric/method models, # validator L1+L2 (§8 matrix), discovery/selector database/ # ✅ M2: generic CH/PG/MySQL managers + try_acquire_lock internal_tables/ # + the greenfield _ab_* schema & mixins (see below) loaders/ # ✅ M2: query_template (ab_* built-ins, StrictUndefined, templates/ # incl. ab_cohort_source — M8 WP3), the packaged # abkit_assignment.jinja macro, metric_loader; # ✅ M8: exposure_source (build_cohort_backend — the ONE # copy-vs-direct switch every cohort reader uses, WP2/WP4) # + exposure_copy (the append-only incremental engine, # WP5); exposure_loader's full-reload path is dead from # the driver since WP5 (external callers only); # ✅ M9 WP3: state_loader (per-day moment extraction) compute/ # ✅ M2: recompute_backend (v1 full-window strategy; # ✅ M9 WP3: load_window — the STATE day render); # ✅ M9 WP4: incremental_backend (the opt-in # additive read path over _ab_unit_state); # ✅ M9 WP5: reconcile (the verify-incremental # cross-backend diff engine) pipeline/ # ✅ M2: driver (lock→load→SRM→plan→compute→persist), # analyze, enrich, _types; worker pool; # ✅ M9 WP3: state (the write-only STATE stage) reporting/ # ✅ M3: builder (the §5.3 terse payload + verdicts), assets/report.js # html_report (hardened bake), the committed bundle; # ✅ M4: calibration.py (the payload calibration block) tuning/ # ✅ M3: session (bounded Tier-S cache), recompute assets/explore.js # (Tiers E/α/S/R + D3 calibration), config_writer assets/dashboard.js # (Apply seam + .history + orphans), server (WP6: # ✅ M4 POST /validate Auto mode), payload, html; # ✅ M11: jobs (the subprocess registry, DASH-1), # overview (the one-row-per-experiment shaper, # DASH-2), dashboard_server (the launcher server — # DASH-3 page/stats routes + DASH-4 job routes), # html.render_dashboard_html, the committed # dashboard.js bundle (DASH-5); # ✅ UI-1: config_files (the editor's CRUD seam — # validate both levels → archive verbatim → atomic # write; owns the archive/atomic primitives # config_writer now imports) + the editor routes # and reload_selection in dashboard_server validate/ # ✅ M4: the pure A/A engine (panel/resample/inject/ # scoring), load (placebo panel + denser-early grid # subsample), runner (cell enum + effective alpha + # select + verdicts), persistence/result/run_id # (per-cell _ab_aa_runs rows, D4), _types; # ✅ M5: family (D9 composed FWER/FDR union-cohort sweep); # ✅ M7: vector_resample (block-streamed GEMM engine) + # score_cell/sweep_family dispatchers w/ verbatim scalar # fallback; opt-in --family-sweep; per-cell auto-N planning/ # ✅ M5: sizing (pure required-N/MDE/power over stats.power) — # the `abk plan` engine; read-only, refuses ratio/bootstrap notify/ # ✅ M6: BaseChannel/ReadoutData/factory + 5 channels # (✅ M12 NTF-4: +discord/teams/googlechat/ntfy = 9) # (`abk test-report`'s synthetic smoke test); # ✅ M12 NTF-1: dispatch (the `abk run --notify` seam — # persisted rows → readout.evaluate → one payload per # verdict) + factory.ROUTING_KEYS; ✅ NTF-3: cooldown # (the pure dedup rule); ✅ NTF-5: the recurring half # of both (dispatch_stale / dispatch_calibration_red # + should_announce_recurring). `dispatch` is NOT # re-exported from the package __init__: it pulls in # config+pipeline, and `test-report` must resolve a # channel without either stats/ # ✅ M1: the pure numpy core (details below); # ✅ M7: supports_vectorized + from_suffstats_array # (5-method roster) + effects._libm_pow batch kernels; # ✅ M10 WP5: the bootstrap _resample/_finalize split # + supports_resample_memo (6 classes; pure refactor) sequential/ # ✅ M5: the always-valid confidence sequence # (confidence_sequence, mixture τ², apply.to_always_valid; # ✅ M7: *_array siblings) utils/ # stdlib-only: json_utils (canonical hash path), # datetime_utils (naive-UTC), env_interpolation; # ✅ M10 WP4: warn_scope (thread-scoped warning # capture — catch_warnings is process-global)web/ # ✅ M3: the dev-only TS toolchain (never wheel-shipped) src/shared/ # chart.ts (canvas primitives + TOKEN_FALLBACKS — # THE brand-token layer), payload.ts (lockstep types) src/report/ src/explore/ # the renderers → committed assets (build.mjs) src/dashboard/ # ✅ M11 DASH-5: the third renderer + its payload # types → abkit/tuning/assets/dashboard.js test/ # jsdom smoke suites + type-checked fixturestests/ stats/ golden/ # M1 (incl. test_purity.py; golden rel-1e-9) core/ config/ database/ loaders/ pipeline/ cli/ e2e/ # M2 reporting/ tuning/ # M3 (+ cli/test_explore_command.py, the report/ # explore e2e gates in tests/e2e/) validate/ # M4 (+ cli/test_validate_command.py, the validate- # matrix exit-gate e2e in tests/e2e/) stats/sequential/ planning/ # ✅ M5 (+ validate/test_family_sweep.py, # pipeline/test_correction_rule.py, cli/test_plan_command.py, # the sequential-matrix exit-gate e2e in tests/e2e/) # ✅ M7: stats/test_vectorized_parity.py + test_normal_path_golden.py, # validate/test_vector_{resample,parity,perf}.py, # validate/test_family_vector_parity.py (exact-only) # ✅ M10: core/test_grid_factory_is_the_only_entry.py, # core/test_period_planner.py::TestIntervalAnchor, # database/test_tables_contract.py (the _ab_experiments # catalog contract), tuning/test_session_cache_lock.py, # docs/test_no_stale_window_keys.py, and the exit gate # e2e/test_sub_day_anchors_and_explore.py (+ its # fixtures/window_golden_pre_m10.json, captured from # the pre-M10 code — regenerate ONLY from f85371d) # ✅ M11: tuning/test_{jobs,overview,dashboard_server}.py, # cli/test_dashboard_command.py, the exit gate # e2e/test_dashboard_session.py (a real server over # live HTTP + a real `abk` child), and web/test/ # smoke-dashboard.mjs # ✅ UI-1: tuning/test_config_files.py + the editor # route class in test_dashboard_server.py + the # editor legs of the dashboard-session exit gate _helpers/fake_db.py # in-memory manager with SQL-backend semantics _helpers/synthetic_ab.py # SyntheticWarehouse (3 metric kinds, shuffle mode, # seed_null_events — the exact-null A/A fixture)Every module in the map above exists; M3’s WP9 (PG/MySQL testcontainers + the two-process lock race) is deferred to a Docker-equipped environment.
M2 pipeline facts an assistant must know
Section titled “M2 pipeline facts an assistant must know”- Anti-join, not a cursor: a cutoff is pending iff
end_ts ≤ now_utc − data_lag(watermark computed ONCE per run in Python) and not inlist_computed_cutoffs()(a SET — holes re-plan). - Locks:
_ab_tasksat(experiment, "pipeline", "run"); PG/MySQL claims are single-statement atomic, ClickHouse is advisory (read-back tie-break); failures are recorded on the lock row before propagating. - SRM is blocking-but-non-dropping: rows are always written with
srm_flag/decision_blocked; the CLI prints the red gate line. - CUPED covariate = a second render of the same metric SQL over the fixed
pre-period window with
ab_apply_exposure_filter=false(declarative-config §3 as amended); loaded once per run, absent units default to 0. - Bootstrap rows are byte-stable: per-row
seed = derive_seed(exp, metric, name_1, name_2, end_ts, n_samples), identity-excluded. ci_kindis always"fixed"in M2 (sequential lands M5); paired methods are notebook-only. (The STATE stage, “deliberately not wired” through M8, is wired write-only since M9 WP3 — see the M9 facts below; the read path stays recompute until WP4.)
M3 reporting/explore facts an assistant must know
Section titled “M3 reporting/explore facts an assistant must know”- Two point vocabularies, never mixed: the baked report series uses TERSE
keys (
t/ed/e/lo/hi/p/rj/s1…/hz/blk/ins—web/src/shared/payload.ts);/recompute+/reloadreplies use FULL names (server._result_json). Timestamps are ms-epoch ints everywhere; NaN/±inf → null. - Explore reads persisted rows (D2): one lock-free session-load pass fills
the bounded Tier-S cache (
EXPLORE_CACHE_BUDGET); over budget ⇒ honest suffstats-only degradation, never a partial cache. Recompute tiers: E exact suffstats, α-inversion (approx), S from the cache, R = warehouse reload viaPOST /reload(its own manager, serialized). Since m10 WP4 the serialization is scoped:heavy_lockcovers/reload+/validate+/applyonly,/recomputeruns concurrently, the Tier-S cache is reached ONLY throughExploreSession’scache_lock-guarded accessors (AST-gated), and/recomputere-checks staleness AFTER computing. Warning capture in_compare/analyze/A-A scoring goes throughutils/warn_scope— nevercatch_warnings, which is process-global. - Tier-S bootstrap draws are memoized (m10 WP5). Every bootstrap method
splits into
_resample(the replicates — alpha-free) and_finalize(CI + verdict at ONE alpha); the base class composesfrom_samplesfrom the two andsupports_resample_memodeclares the capability (the M7supports_vectorizedpattern — the engine falls back to the verbatim_compareotherwise). The engine memoizes the outcome on the session underBootMemoKey(metric, name_1, name_2, end_ts, generation, method, resolved params)— compose it ONLY throughExploreSession.boot_memo_key()(AST-gated, the m9state_series_keydiscipline). Alpha is absent on purpose; dropping any other field collides — across metrics, across arm pairs, across the identity-EXCLUDEDseed(which IS the draw), and across two methods that share a param set (bootstrap/post-normed-bootstrap).max_block_bytesandpvalue_kindride along as belt-and-braces only: both are draw-invariant (measured), so they cost at most a missed hit — narrowing the key behind a declarativeParamSpecflag is a named follow-up.generationisinstall_cutoff’s per-cutoff counter, returned bycached_entry()in the same critical section as the entry, so a resample that lost the race to a/reloadis unreachable rather than stale.boot_memois reached only through the session’sboot_memo_lockaccessors (same AST gate as the cache), and the two locks are never nested. - The client mirrors
analyze.effective_alphasoverpayload["explore"]["experiment"](raw alpha/correction/counts baked bytuning/payload.py) — keepexplore.ts#effectiveAlphaand that block in lockstep (pinned bytests/tuning/test_explore_bundle.py). - The D3 calibration gate keys by
(metric, method_config_id, EFFECTIVE alpha); on an empty_ab_aa_runsevery Apply takes theconfirm_uncalibratedpath — server-enforced, client-mirrored.abk validate/ Auto mode (M4) populate the rows that flip the chip tocalibrated. - Committed bundles are build artifacts: edit
web/src/**, runcd web && npm run build, commit the changedabkit/*/assets/*.jsin the same PR (CI diffs freshness, greps the §4 marker classesabk-prehorizon/abk-insufficient/abk-srm-fail, and asserts the wheel ships both bundles). All colors go throughTOKEN_FALLBACKS— the CI hex loop rejects a page-shell hex missing from the token layer. - request_id stale-drop: ids are a single global on the server; the client
seeds from
Date.now()(and re-seeds after a two-tab 409) — never restart the counter at 0/1.
M4 validate facts an assistant must know
Section titled “M4 validate facts an assistant must know”abkit/validate/is I/O-pure like the runner: the engine (panel/resample/ inject/scoring) touches onlyabkit.stats; the CLI (cli/commands/validate.py) resolves the cohort throughbuild_cohort_backend(M8 — the persisted_ab_exposuresin copy mode, the live assignment source in the no-copy default) and handsload.pythe resulting backend;load.pynever writes (a placebo split is in-memory only — in copy mode a persisted shuffle would clobber the real cohort; in the default there is no persisted cohort at all); the CLI takes the lock and persists.- Placebo source = the experiment’s own pooled cohort, label-permuted (D1) over
the real one-enumeration grid (
generate_grid— same as driver/explore). Permuting unit→arm labels destroys any true effect ⇒ an exact null. Seeds arederive_seed("aa", experiment, metric, method_config_id, iteration)— byte-repro, no wall-clock (D13); FPR numbers are a deterministic, golden-style invariant. - Peeking FPR is the optional-stopping hazard, NOT the readout rule (D3): the
share of placebos whose CI excludes zero at any look (readout
_build_sig_mapsignificance, pre-horizon refusal OFF, horizon included ⇒ peeking ≥ single-look). The stabilized-with-persistence readout rule is the defense and is deliberately not what this column measures;pipeline/readout.pyis untouched. The single-look FPR (horizon only) is reported beside it. - One row per cell at the EFFECTIVE alpha (D4/D16):
run_id = "{run_stamp}:{cell_hash}"(noReplacingMergeTreecollapse); the persistedalphaiscomparison_alpha ∘ effective_alphas(the SAME resolver the chip/Apply use) — a re-derivation would failfind_calibration’siscloseand readalpha_mismatch.--scoringsets only the Recommended-row objective (themodecolumn); FPR always computes so the chip can light. Two-tier: main vs secondary metrics land at different alphas. - The matrix report reuses the report bundle (D10) — no third JS bundle: the
payload
calibrationblock (reporting/calibration.py, guarded byaa_runs_table_exists()) fills the reserved slot;report.ts#buildCalibrationSectionrenders it; band colors reuse the--abk-st-*status tokens (no new hex). Rebuild + commitreport.json anyweb/src/report/**edit (CI freshness gate — pathspec:(glob)abkit/*/assets/**). - Auto mode mutates
session.aa_rowsin place (D11):POST /validate(tuning/server.py, own manager under an OUTER try/finally,'validate'lock, request_id stale-drop, reduced N) greens the live chip without an explore restart; the Apply gate is unchanged. Bootstrap A/A stayed an opt-in follow-up (D7); sidedness/winsorization are arbitrated-not-implemented (D14).
M13 STAT-1 facts an assistant must know (Holm + the FWER claim)
Section titled “M13 STAT-1 facts an assistant must know (Holm + the FWER claim)”- A correction scheme is compute-time or read-time, and the classification
lives in ONE place —
stats.correction.READ_TIME_CORRECTIONS/COMPUTE_TIME_CORRECTIONS. Never test a scheme by NAME. Three modules did (readout._build_sig_map,validate/runner’s family-budget anchor, andcomposed_significanceitself). Two of them would have handedholmthe per-row CI rule — a scheme that appears to work while controlling nothing; the third (the A/A anchor) would have judged it against the Bonferroni composition ≈Σα instead of ≈α, i.e. the instrument would have called a miscalibrated Holm family green. The roster gate (tests/pipeline/test_correction_rule.py::TestSchemeRoster) asserts the union of the two sets EQUALS theCorrectionKindliteral, that every read-time scheme has an adjuster that is actually reached, and that every one has an operator-facing LABEL — the one per-scheme map the gate itself first forgot, and the only one whose absence CRASHED a readout rather than degrading it. - BH and Holm share one body in
composed_significance; only the adjuster differs (_FAMILY_ADJUSTERS). The step-up/step-down arithmetic is the entire difference between controlling the FDR and controlling the FWER. An unknown scheme name still takes the compute-time branch — the config literal is the gate that rejects a typo, and raising here would turn a stale persisted string into a crashing report. - The FWER item moved no number, because the defect was in the CLAIM. The
two-tier levels are exactly those of a valid serial-gatekeeping procedure whose
gate the readout does not enforce (deliberately — it would suppress a secondary
metric exactly when it is most diagnostic). Main tier at α per main
comparison, secondary tier at α, whole-experiment bound
(M+1)·α—2αat the single-main-metric default, flat ingand in the secondary count and linear only inM, the number of independent ship decisions declared.M > 1is legal (the validator asks for at least one main metric) and the blind re-derivation did not consider it — the review did. That is now stated in statistics-changes.md §4.3; the unqualified “FWER ≤ α” is gone. - Holm is NOT uniformly more powerful than abkit’s two-tier scheme. Its first
step is
α/mover the whole family; the two-tier main tier isα/P. The two-tier looseness is what the2αabove pays for. Holm’s claim is the honest α, and it is uniformly more powerful than a one-step Bonferroni at that α. - Fork B (D7) is ratified AND disclosed. Under a read-time scheme the verdict
and the interval stored beside it may legitimately disagree; abkit had been in
that position under BH since M3 without saying so. The divergence is
one-directional (a family rule is never looser than the member’s own raw
alpha — pinned by
test_a_family_rule_never_rejects_more_than_the_stored_interval), so the observable case is an interval excluding zero under a refusing verdict.readout.evaluate()attaches a caveat AND sets the structuredPairVerdict.family_divergence: the report and the dashboard rendercaveatsverbatim, notifications render their own sentence off the flag (a message has no report to click through to, and sniffing a caveat STRING is how prose becomes API), andabk explorerenders neither — it never callsevaluate. The “three renderers all show caveats” shortcut was wrong in both directions and the review caught it. - The caveat is gated on the latest row being
_informative. A demoted row’s reason is the small-sample gate; blaming the correction for it would be a different lie. (Mutation-probed: removing the gate turns the demoted-row test red.) - The readout’s rationale is scheme-aware (
_sig_phrase/_quiet_phrase). Saying “CI excludes zero” under a family rule names a per-comparison fact as the reason for a family-level decision — the two can part company in both directions once the family moves. _ab_results.rejectis the PRE-family flag (D12) — redocumented, never renamed (published BI contract). The composed decision is not persisted at all: under a read-time scheme it exists only at read time, and a stored copy would go stale the moment a metric was added or the contrast set narrowed. Two documents had called it “abkit’s composed decision”, which is the Grafana-disagrees-with-the-product failure this WP existed to prevent.- Under a read-time scheme FLAT is withheld when the pair’s own interval excludes zero, and its power claim carries an “optimistic” caveat. “Nothing rejected” stops implying “the interval covers zero” the moment a family rule decides, and FLAT is the one verdict that asserts absence rather than declining to call — the divergence caveat excuses a WITHHELD call, never an opposite one. The MDE behind it is solved at the row’s raw alpha, which the family threshold is never looser than.
- A
guardrail_correction: noneguardrail leaves the READ-TIME family too (readout._untiered_metrics→_build_sig_map). D8’s declaration is two halves — raw alpha, and out of the divisor — and at read time the family IS the divisor, so honouring only the first made D8 a silent no-op under BH/Holm while the docs promised it loosens the level for the metrics that remain. Resolve the declaration the wayanalyze.effective_alphasdoes (experiment, then project), never a second way. - A read-time family whose rows carry MIXED alphas is warned about. The rule
compares each member’s adjusted p to that member’s OWN stored alpha, so the
family is controlled at the loosest of them. Reachable because alpha is outside
method_config_id: lowering it never re-plans, and a scopedabk run --metric … --full-refreshrewrites one metric’s rows at the new level. abk run/abk validateprint their alpha block through the one_output.alpha_lines. Both used to printsecondary alpha: … (÷P×k non-main metrics)under a read-time scheme — a divisor beside a level nothing was divided by. The rule that a level-printing surface must know the scheme applies to all three surfaces, not just the one the plan named.plan._correction_notetakes the resolved scheme as a REQUIRED argument. Under a read-time scheme every level it prints is the raw alpha, so a caller that forgot to pass it would print the most misleading header of the three.
M13 STAT-1b facts an assistant must know (the declared contrast set)
Section titled “M13 STAT-1b facts an assistant must know (the declared contrast set)”ExperimentConfig.contrast_pairs()is THE arm-pair factory, m10’sgrid()discipline applied to the family: nothing underabkit/may enumerate variant pairs itself (AST gatetests/config/test_contrast_pairs_is_the_only_entry.py, allowlisting only the factory and stats-core’s experiment-agnosticcompare(groups)). Four modules had each carried their owncombinations(variants, 2)— the analyze stage that WRITES the rows plus the report / dashboard / notify filters that decide which persisted rows are still declared — andnotify/dispatch.pyhad predicted the extraction in a comment. Four copies of a constant are style; four copies of a knob-dependent set are correctness.contrasts: vs_controlis one declaration with two halves. The Bonferroni divisor becomesg−1instead ofC(g,2)(that is the ≈ +10 points of power at four arms) AND the treatment-vs-treatment pairs stop being computed. Shipping only the first hands levels bought forg−1contrasts to a family ofC(g,2)— a false FWER claim in the dangerous direction; only the second leaves the experiment needlessly conservative.tests/pipeline/ test_contrast_set.pyexists to make either half-implementation fail, and every fixture in it uses 3+ arms (at two armsC(2,2) = 1 = g−1and every assertion would pass against a no-op).- The control is the first declared variant — the positional convention
name_1, the readout’s verdicts and the SRM rollup already use. D15: the family declaration and M14’scontrol:field are different declarations, so this did not wait for M14. - No project-level default, deliberately (D16). The family a surface reads
must never depend on whether that surface resolved one, so the factory takes
no
ProjectConfig— unlikecorrection/guardrail_correction, which are policy.contrastsalso stays OUT of the m9 state identity (it moves which pairs are compared, never which units/days are materialised — theinterval_anchorprecedent) and out of the_ab_experimentscatalog row. - A narrowed family leaves rows nothing can rewrite, and two places had to
learn it:
driver._sequential_mode_changednow judges declared pairs only (a stale pair’sci_kindcan never be superseded, so it would have forced a full-series re-plan on every run, forever), and the stale-pair warning namesabk run --full-refresh --from … --to …—abk cleanprunes series bymethod_config_idand never touched pair-orphaned rows, which was already wrong for a renamed arm (and--full-refreshwithout its window bounds is aBadParameter, so naming the bare flag would have been unrunnable advice). - The anti-join is complete at (cutoff × declared pair), not merely “was
this
end_tstouched” (list_complete_cutoffs). WIDENING the family — back toall_pairs, or by adding an arm — otherwise leaves every historical look touched-but-incomplete: the new contrasts exist only from the flip onward while the surviving pairs keep an alpha bought for the narrower family, which is the anti-conservative direction and silent. Re-planned cutoffs rewrite all declared pairs by LWW, so the alpha re-homogenises and the next run plans zero; the narrowing direction leaves the old rows tighter, never looser (--full-refreshre-homogenises those). readout.evaluate()filters undeclared pairs itself. The three surface copies stay (each owes its own loud line), but the read-time BH family is built INSIDE the readout, so a direct caller — a notebook, a future surface — would otherwise score a family ofC(g,2)for an experiment that declaredg−1. The explore cockpit was the fourth reader and the one without a filter; it has one now, at session load.- A project-level
statistics: {contrasts: …}is a loud error. Every neighbour in that block has a project default, so the mistake is natural, and pydantic’sextra="ignore"would have accepted the key and changed nothing — a silent no-op reads as a broken engine, which is the opposite of what D16 decided. - Every surface that PRINTS a level must name the family
(
cli/_output.pairs_phrase,plan._correction_note, the HTML report’s arms line, and_ab_experiments.contrastsfor BI): at three arms the two families print different alphas off the same arm count, and a divisor the operator cannot reconcile reads as a bug. The explore client mirrors the rule (not a resolved number) because alpha/correction stay draggable — a page dividing byC(g,2)against a server dividing byg−1would contradict its own rows._correction_noteis gated on the level having actually MOVED (silent undercorrection: noneand at two arms) whilepairs_phrasestays loud: one explains a division, the other reports a family.
M13 STAT-2 fact an assistant must know (the A/A sign column)
Section titled “M13 STAT-2 fact an assistant must know (the A/A sign column)”fpr_negative_shareis the only column that can identify an ESTIMATOR. Several relative-effect formulas share an identical rejection set at the null, so their measured FPRs agree to the last false positive — the FPR column is structurally blind to them. The share of false positives falling BELOW zero is not, and the lean it detects grows as α shrinks, i.e. it is worst in the corrected tier. Denominator is the HITS, not the iterations;Nonewhen nothing was significant (0.5 there would be a claim about data that does not exist).- It is an EXACT parity field, not a continuous one: a ratio of two
block-invariant mask counts. It is classified as such in
tests/validate/test_vector_parity.py, whose roster gate forces every newCellScorefield to be consciously classified. - It reaches the operator through the VERDICT (
runner._sign_lean_note), never the decision log — the M7 WP6 lesson. It is silent unless the departure clears two gates (≥100 false positives, ≥3 standard errors ofsqrt(0.25/hits)) because a noisy claim about the estimator trains the operator to ignore it. The sigma form is deliberate: a fixed percentage would fire constantly on small cells and never on large ones.
M13 STAT-3a facts an assistant must know (the asymmetric_ci guard)
Section titled “M13 STAT-3a facts an assistant must know (the asymmetric_ci guard)”- Nothing may recover an SE from a CI without saying whose CI it is.
sequential.se_from_ci_length, its array sibling andto_always_validall take a required, keyword-onlymethod, and call the one gatesequential.require_symmetric_ci. That is a structural forcing function, not a convention: a new call site does not run until it names its method. The premise being enforced is that the interval iseffect ± z·SE— for anything else the “SE” is the mean half-width overz, whichsequentializethen centres a symmetric sequence on, with no NaN and no exception. BaseMethod.asymmetric_ciis deliberately NOT aClassVar, unlike its four capability siblings. STAT-3 ships Miettinen–Nurminen as an identity-flagged param onz-test, so the class default stays symmetric and a class-level flag would sail straight through the configuration it exists to catch — a guard that cannot fire. A subclass narrowsself.asymmetric_cifromself.paramsaftersuper().__init__(); every entry takes an INSTANCE and handing a class in raisesTypeErrorrather than being answeredFalse.driver._sequential_tau2held only a class and now binds the method the wayanalyze_cutoffdoes.- There are TWELVE entry points, not the design’s eleven.
tuning/recompute._alpha_inverted_bounds(explore’s Tier α) never calls the helper — it open-codesse = (right − left) / 2zand re-derives a symmetric normal CI at the new α from persisted numbers, in a tier already labelled “approx” so the drift would not read as a fault. It takes the same refusal.tests/stats/sequential/test_ci_inversion_is_the_only_entry.pyfails on any new open-coded inversion (a CI width divided by a quantile — a/2half-width is not one, andreadout’s FLAT check and explore’sci_halfchip legitimately do that) and on any guarded call missing its method; both rules are derived from the source and both are proven to bite on hostile fixtures. - An asymmetric method is not blocked — it declares
supports_sequential = Falseand its series stays fixed with no error, because that flag has always meant a symmetric CI and every eligibility gate already tests it. The refusal fires only for a method claiming both. - The refusal reaches the operator on each surface:
AsymmetricCIErroris aStatsError, so validate’s per-cell isolation reports a FAILED CELL carrying its reason (not a quietly missing sequential column),abk runfails the experiment with the message on the outcome, and explore raises out of the recompute. - A test that flips a method capability must patch
get_method_class("t-test"), never an importedTTest.tests/stats/test_registry_factory.pyreloads the ttest module, after which the imported symbol is an object the registry no longer resolves to — invisible when the test file runs alone, a failure two suites later.
M13 STAT-3 facts an assistant must know (the score proportion interval)
Section titled “M13 STAT-3 facts an assistant must know (the score proportion interval)”interval: pooled | scoreonz-testchanges the INTERVAL and nothing else. The p-value branch is untouched code, because dropping MN’sN/(N−1)factor (Farrington–Manning, D11) makesZ(0)the pooled z the p-value already computed. So “no p-value moves” is an equality assertion in the tests, not a tolerance one — and it must stay that way: applying a variance correction factor to the interval alone breaks the coherence at a relative1/(2N)from the boundary, which is the one failure the whole construction exists to prevent.- The math is ONE pure module (
abkit/stats/proportion_score.py), array-in / array-out; the scalar entry wraps its four counts in a length-1 batch. That is why scalar↔batch parity is an equality and not an rtol — there is no second transcription to drift.test_ztest_parityis parametrized onintervalfor exactly that reason; do not “optimise” the scalar path into its own arithmetic. - The closed-form constrained MLE is a SEED, not the answer. The published
trigonometric cubic root loses ~1e-12 to cancellation, and it does so on the
sparse tables the score interval is FOR (the root is small relative to
coefficients of order
N). Newton ondℓ/dp̃₁— ratios, not differences of large numbers — brings it to ~1e-16.MLE_NEWTON_STEPS = 0is a live mutation probe: five tests go red. - The reference for the MLE is the LIKELIHOOD, never a second root-finder. Two
wrong references were tried first and both are instructive: a bisection on the
score equation converges to the wrong endpoint whenever the maximum sits ON a
feasible boundary (any empty cell), and a golden-section search over the
likelihood is derivative-free, so it cannot beat
√ε ≈ 1e-8— it looked like evidence against the closed form. The objective test (the root beats its neighbours and both endpoints) is also the ONLY one that catches a transposed cubic coefficient, because that error vanishes atδ = 0and the coherence and null tests sail past it. - Endpoints come from a fixed-iteration bisection over the FEASIBLE range, never
a tolerance loop and never an expansion scan. Fixed work is what gives byte
reproducibility (the M7 D13 discipline). The ratio scale searches
v = θ/(1+θ) ∈ (0,1), which maps the whole positive line into a bounded bracket, soθ → ∞needs no cap. A root-find that finds no crossing lands on the feasible boundary — that IS the derivation’s required fallback, not an error branch, and it is what makesθ_L = 0(an empty treatment arm cannot exclude a −100% lift) and[−1, 1]real answers instead of NaN. supports_sequentialis the WRONG vehicle for a param-switched refusal, and the reason is narrower than it first looks. Of its eight readers, FIVE take theClassVaroff the class (plan,recompute’sav_pairs,analyze,driver×2) and would be blind to a per-instance narrowing; the three invalidate/scoring.pyread the bound INSTANCE and would see it. Do not repeat the overbroad form of this claim — the three that would have seen it are exactly the ones that mattered, and believing otherwise is what leftabk validateunguarded. The refusal therefore ships where the contradiction is STATIC:sequential.enabled+ an asymmetric interval is a level-2 config error naming both knobs, refused with the SAME sentence at the explore knob state and at the explore Apply seam (one helper,config.validator.asymmetric_interval_conflict— a rule spelled differently per surface is a rule an operator cannot learn once). The explore refusal is decided off the experiment’ssequential.enabled, never off the baked rows: a toggle flipped but not yet re-run leaves every rowfixed, so a row-based test would let Apply write the pairabk runrefuses.AsymmetricCIErroris the backstop under all three, and its explore test is now a DIRECT call on_alpha_inverted_bounds— the caller skips the tier, so a guard nobody can reach is a guard nobody notices deleting.abk validateDEGRADES, it does not refuse — STAT-3a’s contract is amended here._cell_tau2is the first substantive statement of BOTH scoring engines and runs unconditionally (the D8 peeking column is measured side-by-side even withsequential.enabledoff), so a refusal there fails EVERY cell of the comparison. A failed cell carries no FPR,find_calibrationcounts only successful rows, and explore’s D3 chip would sit atuncalibratedforever — with the command it names being the one that cannot clear it. The gate is thereforenot supports_sequential OR asymmetric_ci → no sequential column, exactly what bootstrap already gets, and the skip NOTE names which of the two reasons applied (one helper for both engines; “τ² could not be anchored” is false here and would send the operator to look at their data).- Explore’s α tier answers with a GAP for an asymmetric method, not an “approx”
point, and the gap is REPORTED in
engine_warnings— a chart that quietly loses points is the one degradation this engine must never do silently. Tier E is tried first and reconstructs an ordinary fraction row exactly, so in practice the gap is rare; it is NOT universal, because_invert_fractionrefuses a degenerate row (p ∈ {0, 1}⇒std = 0) — which is precisely the boundary table STAT-3 made reportable. The guard also sits BELOW the NULL-row pass-through, whose contract is that an H5-NULLed row rides along under ANY same-identity knob state. - The
±CIchip renders[low, high]for an asymmetric interval. The server sends the SHAPE (ci_symmetric+ the bounds), not a formatted string;ci_halfis half the interval’s WIDTH and is a±radius only when the interval is centred. The report, the dashboard and the notifications already rendered bounds — explore was the one surface that would have contradicted the rule this WP itself wrote into the packaged operator docs. abk planis the third surface that must know the estimator (STAT-1’s rule about levels, applied to intervals): it suppresses the ASN, becauseabk runrefuses that mode, and says its sizing is Wald-based. §6(b) is measured, not assumed: the score and Wald half-widths differ byC·z²/n_armwith C stable in n to three digits — 4.01 at a 5% baseline, 0.060 at 30%. The bind that resolves the interval shape also makesabk planthe first surface to validate method params at all.- The relative branch keeps H5. A lift over a zero baseline is undefined
whatever the interval method, so the double-empty table’s headline improvement
(
p = 1beside the Wilson zero bound±z²/(n+z²)instead of a NaN row) is visible undertest_type: absoluteonly. - The identification rule WARNS and never suppresses, and it is stated in
CONVERSIONS: the half-width law
z·√(1/x₁ + 1/x₂)reads counts, so ten times the traffic at a tenth of the rate buys nothing. It fires only underinterval: score— a warning is a persisted cell, and0.8.0’s byte-compatibility claim covers the whole row, not just the numbers in it.
M13 STAT-4 facts an assistant must know (the Fieller relative interval)
Section titled “M13 STAT-4 facts an assistant must know (the Fieller relative interval)”interval: delta | fielleris one sharedParamSpecadopted by FIVE methods (t-test,cuped-t-test,paired-t-test,paired-cuped-t-test,ratio-delta) and dispatched in ONE place —stats/relative_interval.py’srelative_normal_test/relative_normal_test_array, which replaced therelative_delta_effect+normal_testpair each method used to compose itself.z-testis deliberately NOT an adopter: STAT-3’s ratio-scale score interval is the exact analogue for proportions, and Fieller would be a normal-theory approximation of it. The roster is DERIVED from the registry in the test, so a sixth adopter cannot go untested.- The p-value moves under
fieller, and that is the change. It becomes the ABSOLUTE comparison’s p-value bit-for-bit (same expression, same operand order — the test asserts==), because “θ = 0” and “μ₂ − μ₁ = 0” are one hypothesis and Fieller inverts that test:0 ∈ set ⟺ C ≤ 0 ⟺ |a| ≤ z√V_a. Keeping the Wald p beside an inverted-test interval would have rebuilt the incoherence the WP removes. The reported LIFT is untouched — Fieller’sR̂·g/(1−g)centre shift belongs to the confidence set’s geometry, not to the estimator. - The defect delta has is ONE-SIDED, and that is why STAT-2 shipped first.
Two-sided coverage is nominal (0.0495) while the tails are 0.0168/0.0327 at a
control-mean CV of 5% — and every abkit verdict is a one-sided claim. The
imbalance does not depend on the true effect, so an A/A run at the null measures
the live experiment’s error faithfully and still reports “calibrated”: the
FPR column reads 0.0498 for delta and 0.0499 for Fieller.
fpr_negative_shareis the column that can tell them apart (0.66 vs 0.50, matching the derivation’s0.5 + φ(z)z²·CV₁√w₁/αto 0.005). - An unbounded answer is reported as MISSING BOUNDS, never as a wide interval.
g = z²V̂_b/b² ≥ 1means no bounded confidence set for a ratio exists at that level (Gleser–Hwang: guaranteed coverage requires unbounded sets with positive probability, so delta’s always-finite interval has guaranteed coverage zero). The effect and the p-value still ride;readout._informativealready treats NULL bounds as a gap. The disclosed cost: such a comparison can reject on the absolute scale and not be called a WIN. - An unbounded row is the first row that carries a valid p-value with NULL
bounds — before STAT-4 the two were always NULL together.
_informativekeys on the bounds, so it is skipped; under a COMPUTE-time correction that is right (it cannot exclude zero), but under BH/Holm it also leaves the family and shrinksmfor its siblings, which is the anti-conservative direction. Pinned as behaviour (tests/pipeline/test_fieller_interval_end_to_end.py), not fixed: relaxing_informativeis a readout-wide semantics change — the stabilization scan reads the same predicate — and is STAT-6’s to weigh. - Five causes of missing bounds, five sentences. H5-undefined denominator,
H5-unstable, degenerate variance, unbounded (
A ≤ 0), and EMPTY (A > 0with no crossing — reachable only through a non-PSD moment triple, i.e. the same mixed-ddof anomalynormal_testreports as a negative variance). The unbounded-vs-empty split is decided through the exportedrelative_interval.leading_coefficient, never by re-derivingb² − z²V_bat the call site. interval: fiellerbesidetest_type: absoluteis REFUSED at construction. Declared on the spec (ParamSpec.relative_only) and enforced once inBaseMethod._validate_shared_params. The param is identity-bearing, so an inert acceptance would forkmethod_config_idand split a published series for no numeric reason — the STAT-1b silent-no-op failure in miniature.ParamSpec.asymmetric_valuesis whereasymmetric_cinow comes from. STAT-3 resolved the flag inZTest.__init__; with a second param-switched interval across five classes that becomes five copies of a knob-dependent fact.BaseMethod.__init__folds every spec’s declaration into the bound instance, so the whole STAT-3a surface (level-2 config error,abk validate’s omitted sequential column, explore’s α-tier gap, the[low, high]chip) follows with no new surface code — verified by adding a real-config leg totests/validate/test_asymmetric_ci_refusal.py, whose earlier probes could onlysetattrthe flag because no value method could declare it.abk plan’s sizing is CLOSER under Fieller, not further.get_ttest_mde’s relative branch sizes the absolute difference and divides by the control mean — the null-variance rule, which is Fieller’s own rejection boundary. So the planner has disagreed with the DEFAULT all along. The asymmetric-interval note therefore claims a difference in half-widths (O(z²/N), true for both inverted intervals) rather than “the two rules differ”, which was never true for Fieller.- The textbook
s = B + sign(B)√discroot pairing is deliberately NOT used, and the discriminant’s cancellation-free form deliberately IS. Both were measured: the pairing buys nothing (2.3e-15 vs 3.3e-15 relative to the interval width, and it is the worse of the two in three of four probed regimes), while the naiveB² − ACcosts a factor of 30 and would cross rel-1e-9 at|z_stat| ≈ 10⁴. The keeper has a Decimal-referenced gate; the dropped one had no test that could justify it, which is why it is gone rather than commented.
M13 STAT-6 facts an assistant must know (the exit gate)
Section titled “M13 STAT-6 facts an assistant must know (the exit gate)”- A byte-compatibility gate must compare against the RELEASE, not against
HEAD.
tests/e2e/_m13_baseline.pyruns unmodified in av0.7.0worktree — which is only possible because the scaffold assets andtests/_helpers/are byte-identical between the two, a fact the file states and that was checked, not assumed. Regenerating the golden from HEAD would make the gate compare HEAD with itself (the M10 window-golden discipline). The comparison is discrete-exact / continuous-at-rel-1e-9 / JSON-parsed even though the two surfaces are byte-identical on one machine: byte reproducibility holds only under a fixed BLAS configuration (M7 D13), and CI is not that machine. Rows are ordered by a DISCRETE key for the same reason — sorting by whole-row content would let a last-ULP difference reorder the list and compare mismatched pairs. - Adding a column to
_ab_experimentsis THREE edits, and the catalog writer is the one that gets forgotten. The model (tables.py), the emitter (ExperimentConfig.catalog_record), and_ExperimentsMixin._EXPERIMENT_FIELDS— whose projection is a whitelist, so a missing field is dropped in silence. STAT-1b did the first two;contrastsshipped unwritten and the docs describing it were ahead of the code.upsert_experimentnow refuses a record carrying fields it would drop (the checksave_resultshas always made in the other direction), andtest_every_catalog_record_key_is_a_columnis a real three-way EQUALITY — its previous form asserted one containment while its docstring claimed all three. - An additive column must be nullable OR defaulted, and a
Stringcolumn is neither by default.ensure_columnsrefuses a NOT-NULL/no-default addition on every backend, soColumnDefinition("contrasts", "String")would have made the first0.8.0run of every installed project fail with a “drop and recreate” instruction — for a column whose own comment promised it needed none.default="all_pairs"fixes it and is factually right for historical rows (the knob did not exist before0.8.0). The in-memory fake now models DEFAULT read-back, so a test cannot pass against semantics no backend has. - A defaulted
Stringcolumn MUST carrymax_length. MySQL maps an unsizedStringtoTEXT, and MySQL rejects a literal DEFAULT on TEXT (error 1101) — so the fix above would have turned a broken migration into a brokenCREATE TABLE, on the one backend this repo has no container for. Sized ⇒VARCHAR(n)⇒ legal. PostgreSQL has no such restriction, which is why the gate (tests/database/test_sql_managers.py::TestDefaultedColumnsAreDialectSafe) is dialect-keyed rather than banning TEXT everywhere, and derives its column list fromINTERNAL_TABLES. - A complete-null family sweep cannot separate Holm from one-step
Bonferroni — “at least one rejection” is
min p ≤ α/munder both, since Holm’s first step IS the one-step level and no later step is reached (measured identical to the last digit at 20 000 iterations). The power claim is only measurable with true effects present, where Holm’s family error over the surviving nulls reaches α and the one-step rule stops at half of it. The exit-gate sketch’s own item (“Holm’s FWER ≈ α”) would not have caught this: a one-step rule satisfies it too. - The new-number goldens anchor on a DIFFERENT ALGORITHM
(
tests/golden/m13_reference.py):brentqon the constrained likelihood and on|Z| = cfor the score interval,numpy.rootsfor Fieller, the step-down definition for Holm — each case asserted against both the reference and a literal, because a reference living in the test tree can be edited alongside the engine. Boundary tables are deliberately absent: both references are root-finders, valid only where the constrained maximum is interior, and the objective-function KATs intests/stats/are what covers those. - The batch A/A revalidation is a committed, re-runnable artifact
(
docs/research/2026-08-m13-revalidation/): the script, its raw JSON, and the report. Its two instrument-level readings:interval: scoreis invisible to the FPR column by construction and shows up in COVERAGE (relative scale 90.7% → 93.3% against a nominal 95%), andinterval: deltais invisible to BOTH two-sided columns while two thirds of its false positives fall below zero — the blindness STAT-2 exists to fix, reproduced end to end.
M7 vectorization facts an assistant must know
Section titled “M7 vectorization facts an assistant must know”score_cellandsweep_familyare dispatchers onmethod.supports_vectorized: the vectorized bodies block-streamvector_resample.iter_blocks × build_arm_batch × from_suffstats_array; the scalar bodies are verbatim code moves — a method without a batch kernel (all bootstrap, any new plugin) automatically takes the scalar path. A lying flag (Truewithout a kernel) raisesValidateError, caught per cell.- Batch-kernel pow terms route through
effects._libm_pow— numpy**is 1 ULP off C-librarypowand the cancelling delta-method variance sum amplifies that to ~1e-4 rel at CI bounds; with libm routing the scalar↔batch parity is bit-exact by construction (parity tests demand exact for all 5 opted-in methods; roster-pinned: t-test, z-test, cuped-t-test, paired-t-test, ratio-delta). - Float aggregates are byte-reproducible only under FIXED blocking + a
fixed BLAS configuration (D13 as restated in M7) — block-size and
thread-count bit-invariance is unachievable in principle (GEMM and even
np.sum(axis=1)round per buffer height). Masks/counts/flags are exact under ANY blocking; continuous columns get rtol-1e-12 across blockings. Never write a byte-equality assertion on continuous columns across block sizes or BLAS thread counts. - The parity gates are the milestone’s safety net —
tests/validate/test_vector_parity.py(8 shapes × 50 seeds, envABKIT_PARITY_SEEDSraises it; exact counts/curves/warnings, continuous rel-1e-9) andtest_family_vector_parity.py(exact-only — every family column is a count fraction/exact sum/passthrough);test_vector_perf.pyis the executable perf gate (<10 s reference under coverage). At an exactly solved CI boundary (|bound| ≲ 1e-15) the engines may legitimately flip one decision — pinned, not a bug. - Iteration policy (WP6):
ValidateSettings.iterations=None→ per-cellmax(2000, ⌈200/α⌉)at the cell’s EFFECTIVE alpha (family sweep sizes at the tightest member alpha); auto-N warns above 100 000, never hard-caps; persisted rows record the RESOLVED N.--family-sweepis opt-in (default off; with--metricit is logged-and-skipped; explore Auto mode never opts in — the D3 chip keys on per-cell rows only). decision_logentries do NOT reach the CLI user — their only other consumer is the Auto-mode JSON reply; any user-facing warning must be explicitly echoed as a CLI line (the WP6 round-2 lesson, pinned bytest_auto_n_warning_reaches_the_terminal).
M8 cohort facts an assistant must know
Section titled “M8 cohort facts an assistant must know”_ab_exposuresis OPTIONAL — the no-copy default writes nothing. Withassignment.cohort_copy.enabled: false(the default) no run ever creates the table: metric SQL joins a liveMIN(exposure_ts)-deduped subquery over the rendered assignment SQL via theab_cohort_sourcebuiltin, re-rendered- re-validated on every invocation (the documented cost/freshness tradeoff — a late-arriving row is never missed; a render + validation query is paid each time).
build_cohort_backend(manager, experiment, project_root, grid, with_snapshot=...)(loaders/exposure_source.py) is the ONE copy-vs-direct switch every cohort reader goes through — driver,abk planarrival rate,abk validateload, explore session-load, reporting SRM counts. The binding M8→M9 contract (§0.5(e)): no caller, present or future, hand-rolls cohort SQL. Read-only callers in copy mode stay query-free (with_snapshot=False⇒ snapshotNone); direct mode renders + validates once (cross-variant corruption fails loudly at every surface).- The incremental copy engine (
loaders/exposure_copy.py, copy mode) is append-only: grid-anchored closed-interval buckets (grid.start_ts + k·batch_interval; the open bucket + rows younger thanmaturity_delayare withheld), watermark resume from the FINAL-dedupedMAX(exposure_ts)snapped to its bucket floor, round trips ofbatch_intervals_per_round_tripintervals with bounds injected through the EXISTING{{ ab_added_filters }}hook (required in copy mode — config-lint and the engine prove the reference is LIVE via a rendered sentinel; a token in a comment cannot pass). A customupdate_columnhas no persisted cursor and re-scans from the experiment start every run. A routine run never deletes;abk run --resync-cohort(copy mode only, no-op in direct) deletes- rebuilds through the SAME engine — the recovery for the documented limitation: a row backfilled into an already-scanned closed bucket is silently missed by the watermark.
- SRM always measures the LIVE validated source (both modes); in copy mode
the persisted metrics join trails it by the open bucket +
maturity_delay, andabk runwarns when a computable cutoff exceeds the copy’s coverage (aligndata_lag >= maturity_delay + batch_interval). - The cross-mode parity gates (
tests/e2e/test_cohort_mode_parity.py,tests/pipeline/test_pipeline.py::TestCohortModeParity,tests/e2e/test_first_run_copy_enabled.py) pin_ab_results/_ab_aa_runs/ the baked explore payload identical across modes (watermark_tsis the one legitimately differing column) — zero statistical numbers moved in M8.
M9 facts an assistant must know (shipped: WP1–WP6)
Section titled “M9 facts an assistant must know (shipped: WP1–WP6)”- WP1 (shipped):
_ab_resultscarries the 4 persisted CUPED covariate moments (cov_std_1/2,corr_coef_1/2, nullable) + theensure_columns()additive ALTER-ADD-COLUMN migration primitive (the project’s first post-release schema change; idempotent, never drops). - WP2 (shipped):
cuped-t-testis Tier E in explore — covariate suffstats reconstruct from the persisted moments for every knob exceptcovariate_lookback(unconditionally Tier R); pre-migration rows keep the old fallbacks. - WP3 (shipped): the STATE stage is wired, write-only.
PipelineStep.STATEsits between LOAD and COMPUTE (--steps statesupported; theabk rundefault isvalidate,plan,load,state,compute).pipeline/state.pyrenders each STATE-eligible metric per closed local day THROUGH the m8 factory backend (RecomputeBackend.load_window— never a hand-rolled cohort join, both modes parity-tested) and replaces the moments viareplace_day_state. Eligibility: closed-form (unseeded) comparison, non-stratified metric, no explicitcolumns.covariaterole (a snapshot covariate is not day-additive), SQL body free ofab_cov_*, and the metric DECLARINGstate_additive: true(m9 WP5) — additivity cannot be read off SQL (a dead CTE, an outer re-aggregation, a UNION branch, an identitysum()over a renamedmax()all look additive), so the author promises it and_role_projections_are_additiveis a VETO-ONLY filter that refuses visibly contradicting projections (max(...), a constant,DISTINCT,OVER, multi-branch SQL). The scaffoldedexample_signup_cris exactly the hazard shape — caught by WP5’sverify-incremental, which is the empirical oracle. Identity:source_table = "{experiment}/{metric}"(compute_state_source_id— the §5.3 sharing ideal deliberately narrowed: the render is cohort-filtered, so cross-experiment sharing would clobber) +column_set_id = compute_metric_state_id(role_map, whitespace-normalized SQL, cohort_config)wherecohort_configfolds in the cohort-shaping experiment config (assignment-SQL hash, added_filters, unit_key, variants, timezone, start_ts; horizon_ts only when the assignment SQL referencesab_end_*;interval_anchordeliberately EXCLUDED — it moves cutoffs, never day boundaries) — compose the key ONLY throughpipeline/state.state_series_key(). Any such edit orphans the series and the next run sweeps the stale ids. Every failure path TRUNCATES the tail (delete_state_days_from), preserving contiguity — every day<= get_last_state_day()is materialized, days past it are absent, not stale:--full-refresh --from/--todeletes from the first touched day BEFORE re-rendering through the end of the series (crash mid-refresh ⇒ a self-healing prefix); a non-finite moment truncates from the failing day (earlier days retained, one-render retry per run, a loud CLI warning). Copy mode clamps day-close to the copy’s coverage;--resync-cohortforce-rebuilds day state with the copy. WP4’sIncrementalBackendis the (opt-in) reader. - WP4 (shipped):
IncrementalBackend— the opt-in additive read path. Withcompute.incremental_reads: true(project-level, experiment override; default false until WP5’sverify-incrementalbakes), the driver routes each STATE-eligible comparison (the SAMEcomparison_state_eligiblepredicate the WP3 writer uses — bootstrap/ stratified/explicit-covariate always stay recompute) tocompute/incremental_backend.py: closed days come from ONEper_unit_cumulativeSUM over_ab_unit_state(cached per(series, required_last)— sub-day looks of one day share one read); a sub-day tail[last tz-midnight, end_ts)renders through the SAME m8 factory backend (load_window); the per-unit totals reshape into the UNCHANGEDMetricLoadResult → build_container → SufficientStatspath (no new numerical code); the CUPED covariate keeps the one cached recompute-side load. Safety net: any state gap (absent/trailing/ truncated series) falls back to full recompute for that cutoff with a per-(metric, reason) warning inRunOutcome.warnings; a non-finite tail falls back too;--full-refresh/--resync-cohortwithout thestatestep disable incremental reads for the run (stale-in-place state is undetectable by the gap check). Arm split: tail units carry the live tail render’s arm; state-only units join the LOAD snapshot (direct) /_ab_exposures(copy), with ONE quiet refresh-on-miss re-read (loaders/exposure_source.load_variant_map) for units enrolled between LOAD and the STATE render; still-unmapped units drop (the INNER JOIN mirror). Cross-path parity is rel-1e-9, never byte (summation order differs by design — the M7 lesson); flag-off behavior is untouched. Documented limitation (m8 copy-mode precedent): an event backfilled into an already-materialized day LATER thandata_lagfreezes in day state —data_lagis the declared SLA;--full-refreshre-materializes- recomputes; WP5’s
verify-incrementalis the drift detector.
- recomputes; WP5’s
- WP5 (shipped): the reconciliation gate + cost observability.
abk verify-incremental(compute/reconcile.py) loads every already-computed cutoff through BOTH backends and diffs theTestResultdicts at rel-1e-9 — whole-series, read-only, lock-free, non-zero exit on divergence, and never part ofabk run. A cutoff the incremental read fell back on is reportedunverified, NOT as a pass (both sides ran the same code) — the reader’s undedupedon_fallbackhook gives the per-cutoff resolution. Both the driver and the reconciler construct the reader through the ONEbuild_incremental_backendfactory, so the gate certifies the backend the pipeline runs.abk run --cost-reportprints per-stage cost from counters on the manager (QueryCost): queries/rows returned/seconds everywhere, rows+bytes SCANNED only where the backend reports them (ClickHouse progress; PG/MySQL printn/a).abk cleansweeps_ab_unit_stateseries no live(experiment, metric)claims — selection-INDEPENDENT, since state rows are not experiment-keyed. The §7 perf gate (tests/compute/test_incremental_perf.py) asserts fact rows scanned exactly:N·D(D+1)/2recompute vsN·Dincremental, zero inside COMPUTE at daily cadence. Default-flip criteria: cumulative-intervals.md §4.1. - WP6 (shipped): the exit gate.
tests/e2e/test_incremental_run.pydrives the whole cycle through the CLI over the scaffolded project: twice-run byte-stability (and a--full-refreshthat reproduces every number exactly), whole-seriesverify-incrementalwith zerounverifiedcutoffs, day state for the declared-additive metric only (one row per (unit, day)), CUPED Tier E on every knob but the lookback, a real drift →DIVERGED→ non-zero exit →--full-refreshheals, and the milestone’s №1 assertion: flag on vs flag off persists the same_ab_results— discrete columns exactly, continuous at rel-1e-9, with JSON payload columns PARSED before comparison (a CUPED θ differs in its last ULP; comparing the serialized strings would demand a property IEEE-754 does not offer). The ClickHouse leg (tests/e2e/test_first_run_clickhouse.py, Docker-gated) migrates a real pre-M9_ab_resultsin place and reconciles the additive path against real SQL — the two claims the in-memory fake cannot settle. - Identity normalization (WP6 R1 fix): compose state identity ONLY
through
state_series_key(), and hash SQL ONLY throughnormalize_sql_for_identity()— whitespace is formatting OUTSIDE quoted spans and DATA inside them ('Summer Sale'≠'Summer Sale'), and comments are scanned as spans so an apostrophe in-- don't sumcannot open a phantom literal. A blanket" ".join(sql.split())let a semantic literal edit reuse stale day state — reproduced as a real divergence at the exit gate. - Catalog lookups are dialect-folded (WP6 R1 fix):
table_exists/list_columnscompareinformation_schemaSTRINGS, while schema/table names reach the DDL unquoted — so PostgreSQL stores them lower-cased. Both go through_catalog_name(identity by default,.lower()on PostgreSQL; MySQL keeps the case deliberately). Without it a mixed-caseinternal_schemamade every lookup miss, soensure_columnsnever ran and an existing install silently skipped the M9 migration.
M10 facts an assistant must know (shipped: WP1–WP5)
Section titled “M10 facts an assistant must know (shipped: WP1–WP5)”(The WP4 lock-split and WP5 memo contracts live with the cockpit they change — see the M3 explore facts above. What follows is the window/schema half.)
- An experiment’s window is a pair of INSTANTS, and the config keys say so.
start_date/end_dateare gone, renamedstart_ts/horizon_tswith no aliases (D1) — an old key fails validation with a message naming the new one. Each accepts a bare date or a full timestamp (2024-07-01 14:30:00) and the union is type-PRESERVING (date | datetime) — which matters becausestr(field)reaches the m9 state-identity hash, so a re-parse that flipped the type would orphan every materialized series. Discrimination always testsisinstance(value, datetime)FIRST, becausedatetimesubclassesdateand thedatebranch would otherwise swallow both. A raw int/float is REJECTED rather than read as a Unix timestamp (start_ts: 20240101unquoted would be 1970-08-23). - A bare date is local midnight of THAT day for BOTH edges (D6), so
horizon_tsis the EXCLUSIVE right edge and the config value equalsgrid.horizon_tsexactly — one vocabulary, no+1 daytranslation anywhere. Porting a pre-m10 config shifts the horizon by one day (end_date: 2024-07-14→horizon_ts: 2024-07-15); every other number stays byte-identical (pinned by the exit-gate golden captured atf85371dover 22 window shapes) with two disclosed exceptions:horizon_seconds()(below) and — for astart_tson a local calendar day that never existed, which tzdata puts on exactly 3 dates between 1970 and 2036 (1993-08-21 Kwajalein, 1994-12-31 Enderbury/Kanton/Kiritimati, 2011-12-30 Apia/Fakaofo; 7 zone entries counting aliases, all historical) — the pre-m10 series’ ZERO-LENGTH opening look, which the m10 planner drops because it keeps cutoffs strictly after the start. interval_anchordecides WHERE the cutoff lattice sits (D2):midnight(the default the scaffold writes out — local midnight of the opening day, i.e. whole calendar days, the pre-m10 rule),start(count from the start instant), or an explicit local instant that MAY precede the start (the first window is then legitimately partial — config-lint notes it, never errors). One engine rule: cutoffs =anchor + k·cadence, kept strictly after the segment’s left edge. Day-or-coarser steps hold the anchor’s local wall-clock time across DST; sub-day steps stay absolute-duration. A whole-dayuntilbound is compared in DAY space only while the anchor sharesstart_ts’s wall clock — off-phase it reads as elapsed seconds.ExperimentConfig.grid()is THE factory — nothing underabkit/may callgenerate_griddirectly (AST gate:tests/core/test_grid_factory_is_the_only_entry.py). This is m8’sbuild_cohort_backenddiscipline applied to the planner, and it exists because the new knob reached NONE of the eight hand-copied call sites; one of them passedtimezonepositionally as the 4th argument, so any parameter inserted beforetzwould have silently re-bound it.horizon_seconds()is true elapsed time, not a nominal day count, so it now agrees withgrid.horizon_ts − grid.start_ts, which it contradicted pre-m10. The law, measured against the pre-m10 code across 19 window shapes: it differs from the old value by exactly the UTC-offset change between the window’s local edges, and by nothing anywhere else. Not “±1h across DST” — that is wrong in both halves: the delta is −30 min in Australia/Lord_Howe, −2h in Antarctica/Troll, −24h across Pacific/Apia’s 2011 line jump, and +1h with no DST on either side (Moscow’s 2014 permanent +4→+3 shift). Consumers: config-lint’s cadence gate — where a sub-day cadence sitting between the two lengths can flip accept↔reject — and the readout’s pre-horizon rationale line. No persisted column derives from it.- A sub-day start never sums pre-experiment facts: the STATE stage clamps
the opening day’s render window to
grid.start_ts, and the CUPED pre-period stays WHOLE-DAY ([midnight(D − lookback), midnight(D))) instead of gaining a partial trailing day.tz_midnight_utcnow REJECTS adatetimerather than silently dropping its time — but credit it with only the ONE M9 surface that actually reached it, the STATE stage’s day-loop seed. The other two failed LOUDLY:IncrementalBackendcompared adateagainst adatetime, which raisesTypeErroron every cutoff. - Both breaking schema changes of the whole track ship in
0.5.0, with one recreate instruction (§0.3):_ab_results.start_date/end_dateare dropped — group BI byend_ts, and derive the calendar day a look covers asend_ts − 1µsread in the EXPERIMENT timezone (both corrections matter:end_tsis exclusive AND stored in UTC —TestTimezoneDatestranscribes the recipe to Python and gates each correction separately against a real Moscow and a real New York run; the three dialect SQL forms indocs/reference/internal-tables.mdare documentation, executed by no test) — and the_ab_experimentswindow is renamed + widened toDateTime64(3)holding the RESOLVED window in naive UTC (the same frame as_ab_results.start_ts, so a BI join lines up), plus aninterval_anchorStringcolumn. - A type change is not auto-migratable, and the refusal is the upgrade
path.
ensure_columnsis ADD-only, so a pre-m10_ab_experimentsmakesensure_tables()raise aValueErrornamingDROP TABLE …+ the CHANGELOG. That failure now reaches the terminal as a CLI error line in bothabk runandabk unlock(it used to escape the driver’s handler as a traceback). Upgrading_ab_resultsis backend-asymmetric: PG/MySQL declare the dropped columnsDATE NOT NULLso an omitting INSERT errors loudly, while ClickHouse fills them with the type default and silently stamps1970-01-01— the one silent path, which is why the recreate note is explicit about it. interval_anchoris deliberately NOT folded into the m9 state identity (it moves cutoffs, never day boundaries); the window fields ARE, so the rename orphans every existing_ab_unit_stateseries once — the next run re-materializes andabk cleansweeps the stale ids.
M11 dashboard facts an assistant must know (shipped: DASH-1…DASH-7)
Section titled “M11 dashboard facts an assistant must know (shipped: DASH-1…DASH-7)”- The dashboard COMPUTES NO STATISTIC and TAKES NO PIPELINE LOCK — this is
the milestone’s binding invariant, as restated by UI-1. M11 wrote it as
“computes a statistic, turns a knob, writes a config or takes the pipeline
lock”; the fourth clause was never gated, and UI-1 (the YAML editor) drops
it — a config write is the operator’s own declaration, not a result, so no
number on the page derives from it and it cannot block a pipeline. What the
gates enforce is unchanged and is the real invariant. Gated twice, because
one gate cannot see the other’s hole: an AST scan proves
tuning/dashboard_server.pynever names the lock API (and the gate is proven to BITE on a hostile source), and a spy proves no helper takes it either — over every job route (TestLauncherOnly) and, since UI-1, over every editor route, which additionally provesreadout.evaluateis never called there. The read-onlycheck_lockprobe behind a row’slockedchip does run — that is the distinction. The token gate’s POST route list is now AST-checked against_route_posttoo; M11 checked only the GET list, which left the routes that MUTATE covered by a hand-maintained list nothing verified. - A verdict on the page is
readout.evaluate()’s over the FULL cumulative series._ab_resultsrows are cumulative looks from a fixed start, not a plain time series, so windowing them is not “a shorter series” — it is a truncated stabilization history: filtering the left edge read a 14-day daily WIN as INCONCLUSIVE and inverted a 6h-cadence series into a WIN the full readout refuses.?window=/--windowtherefore bounds only the sparkline’s x-range, never the verdict. Two more shape rules: rows for an arm pair the config no longer declares are dropped before the series lookup (they still enter the BH family and would tighten the threshold — thebuilder.pydiscipline), and an experiment with no rows of its own must never reachevaluate()— zero rows rendered INCONCLUSIVE, i.e. a verdict about data on a row nobody computed.verdict: null+error: nullis “no data — press Run”; witherrorset it is the error chip. insufficientis the HEADLINE look’s own persisted cell, read through the readout’s_flag(the report’s_flag01is a barebool()and disagrees on a"0"string cell) over the UNWINDOWED pair series, so no display window can move it and the chip cannot contradict the rationale beside it. The §4 markers ride the chip and a one-line note:abk-srm-fail/abk-insufficient/abk-prehorizon— a verdict taken early under an always-valid sequence is deliberately NOT marked.- The token gates EVERY request, GET included (unlike
abk explore, which gates only POSTs) —GET /enumerates the project andGET /api/stats/…reads the warehouse. Authorization runs before routing (a 403 is not a path oracle) and compares bytes:compare_digestrefuses a non-ASCIIstr, so?token=αwould raise before the handler’s wrapper and never be answered. The token is never baked into the page (the client readslocation.search). The gate’s own coverage is machine-checked: theparametrizelist is asserted against an AST extraction of what_route_getactually dispatches on (DASH-4’s review found that list rotted once already — a new file-serving route was simply missing from it and would have shipped ungated), so the list is only as honest as that extraction. - The server never shuts itself down (AST-gated over the module, the gate
itself proven to bite on the explore server’s copy-paste shape — and to
ALLOW
server.jobs.shutdown(), the registry teardown):abk explore’s Apply is terminal, the dashboard has no terminal action. It serves until Ctrl-C and then terminates every job it spawned. tuning/jobs.pyis the subprocess registry.JOB_KINDS/PIPELINE_KINDSare validated whitelists at both entry points (the donor’s blacklist let a typo fall UNDER the gate);spawn_pipelineis the one-at-a-time gate (400while a pipeline job runs),spawn_dedupedis the atomic per-experiment dedup (running_job_for()+spawn()is check-then-act — a double-clicked Explore started two cockpits each rewriting the YAML from its own snapshot);snapshot(offset=)counts absolute line indices, so a job chattier than the 5000-line buffer keeps streaming and discards are disclosed asdropped/truncated. A spawn racingshutdown()raisesJobManagerClosed(⇒ 503, “busy” is whatNonemeans) and kills and reaps the child it just created.- Every button spawns
sys.executablethrough a bootstrap that drops the CWD fromsys.path.-mputs the child’s CWD (the operator’s project root) onsys.path[0], where a strayclick.pybreaks every button and anabkit/directory runs a different abkit than the one serving the page — neither of which happens when you typeabk. Consequence: every spawned job needs an installed abkit;abk dashboardwarns once at startup, and only on the CONJUNCTION of two probes (asys.pathsearch with the CWD dropped, then thesys.meta_pathfinders that answer for a strict editable install) — dist-info metadata is NOT a signal, a checkout whose install was removed keeps it. --selectis the experiment’s YAML path (glob metacharacters*?[escaped, not abandoned), and every job route re-resolves it throughselect_experiments— the child’s own resolver — before spawning, answering 400 unless it lands on exactly the clicked experiment. Two reasons, both silent otherwise: a bare name resolves file-first, so a file named after another experiment shadows it; andabk run/unlock/cleanmeet an unmatched selector with “Nothing selected.” and exit 0, which would show a green, successful job that computed nothing.dashboard.jsis the third committed bundle and obeys the M3 build discipline verbatim (editweb/src/dashboard/**→cd web && npm run build→ commit the asset in the same PR; the marker/hex/freshness gates cover it by glob). It is named in two hardcoded wheel namelists —.github/workflows/ ci.ymlandtests/e2e/test_release_readiness.py— and a missing bundle now RAISES rather than degrading to a “run npm build” note (apip installuser cannot fix that).abk run --metric <m>(DASH-4a) is the CLI capability the per-metric Run button needs. The alphas are invariant by construction —effective_alphas()derives the two-tier scheme from the config’s comparison list, never from what a run computes — and that is the WP’s #1 pinned assertion. The one thing a narrowed run must still touch outside its filter is day state: a stale-but-contiguous_ab_unit_stateday is invisible to the M9 gap check (it detects ABSENCE only), so a scoped--full-refreshtruncates the withheld metrics’ series from the first touched day onward instead of leaving it; the cohort load, the SRM gate and copy-mode--resync-cohort’s rebuild stay experiment-level.
UI-1 / UI-2 facts an assistant must know (the dashboard’s editor)
Section titled “UI-1 / UI-2 facts an assistant must know (the dashboard’s editor)”tuning/config_files.pyis the editor’s seam, and it is NOTconfig_writer.py. Apply (explore) merges a structured edit and RE-EMITS the parsed document, so comments die and the archive is the recovery (D4); the editor round-trips the operator’s raw TEXT, so comments and layout survive (normalized only to end with a newline). The two share only the filesystem primitives —archive_config_text/atomic_write_bytes/stampnow live inconfig_filesandconfig_writerimports them, so both surfaces land in the SAME<dir>/.history/<name>/tree.- Order is validate → archive → write, and validation is BOTH levels.
Level 1 is
ExperimentConfig; level 2 isvalidate_experiment_level2, the §8 matrixabk run --steps validateruns (reference integrity, CUPED rules, the cadence/looks gates over the real grid, the no-DB SQL render smoke). The metric library is re-read from disk per save rather than taken from the boot snapshot, and leniently — a metric that fails to parse is simply absent, so a broken metric cannot block an unrelated experiment’s save. forceoverrides level 2 and NOTHING else. A file pydantic rejects cannot be served as a row, so level 1 is never forceable; level 2 is a statement about the whole PROJECT, and an editor that refuses until the project is coherent is unusable in the situation it is opened for. A forced save returns its findings asSAVED WITH AN ERROR — abk run will refuse this: ….- The digest is the concurrency token, and it is checked BEFORE the text is
parsed.
GET /api/experiment-sourcehands out a sha256 of the file;save/deleteecho it and are refused on a mismatch. Two writers make that ordinary: a second tab, and anabk exploreApply — which the dashboard can itself spawn on the experiment being edited. A truncated read (>512 kB,_MAX_SOURCE_BYTES— the same constant on both sides) returnsdigest: null, editable: false: a digest over a prefix would let a save write the prefix back and drop the tail. A stale buffer has to be reopened either way, which is why the digest is reported before a parse error. - A save/delete is refused while the cockpit has a running job on that
experiment (
_refuse_if_busy, over theJOB_KINDSwhitelist). Not a lock — a lock is exactly what this server may not take; it is the narrow check a launcher CAN make, over the jobs it started itself. A job started from a terminal is invisible to it, and the digest catches the explore half of that after the fact. - The boot snapshot is gone:
reload_selection()is the re-derivation seam. Every mutation route calls it,POST /api/reloadis the manual form (M11’s named follow-up), and it re-resolves the cockpit’s OWN--select/--exclude— which is whybuild_dashboard_servertakesselectors/excludesandabk dashboardpasses them: re-deriving them server-side would silently widen an edited page to the whole project. It never raises: the write has already landed, so a broken sibling YAML or a name collision keeps the previous selection and rides back inwarnings(“restart the dashboard”), rather than reporting a successful save as a 500.experiments/_by_name/metrics/htmlare written and read underselection_lockand re-baked together. - Delete removes the YAML only (
-deleted.ymltombstone in the archive);_ab_results/_ab_unit_staterows stay untilabk clean --orphaned-experiments, and the reply says so. A rename keeps the file’s path, archives under the OLD name, and warns that the persisted history does not follow. Name uniqueness is checked over the WHOLE project and across the ONE experiment+metric namespace (cli-and-dx §1), never against the served selection. - UI-2:
abk uiiscli.add_command(dashboard, name="ui")— the same callback object, so options and help cannot drift.dashboard/explorename the surface whereuidoes not, so the canonical name stays.
PERF-1 facts an assistant must know (the additive read path, made loud)
Section titled “PERF-1 facts an assistant must know (the additive read path, made loud)”AdditiveReadStatus(pipeline/_types.py) is the whole surface, and it is PURE —hint()takes no config and no warehouse, so the rules about when abkit nags are unit-tested without a DB. The driver fills it on every run that REACHES COMPUTE (eligibility is measured even with the flag off — that is the point; a--steps …,staterun that stops before COMPUTE reports nothing, since eligibility is resolved per comparison inside that loop) andabk runechoeshint()throughoutcome.warnings, because that is the channel which reaches the terminal (the M7decision_loglesson).- An ABSENT
compute.incremental_readsand an explicitfalseare different things, distinguished by pydantic’smodel_fields_set(the field is still a plainbool; nothing that reads it changed). They resolve identically — only the first is undecided, and only the first is nagged. Without the distinction the warning could never be answered, and an unanswerable warning is just a different silence. An experiment-level override counts as declared iff it is notNone. - The threshold is LOOKS, not days (
MIN_LOOKS_TO_MATTER = 6). cumulative-intervals §4.1 states it in days because it assumed a daily grid; the recompute scan is quadratic in looks and an hourly cadence re-reads the window 24× a day.series_looksislen(computed) + len(pending)— disjoint by construction — maxed over the eligible comparisons. _stage_costis variadic andcompute.additiveis a SLICE, not a sibling. An eligible look’s measured delta lands in both"compute"and"compute.additive"; summing the two printed lines double-counts. Every COMPUTE load for an eligible comparison goes through the samecost_stagestuple, the sequential τ² load included.- Fallback extent comes from
on_fallback, never from the warnings. The reader’s_warn_onceis deduped per (metric, reason), so it can name the cause but can never say how many looks paid for it;on_fallbackfires at most once perload_cutoff(the reader returns the recompute result immediately after), so counting calls counts cutoffs. abk initwritesincremental_reads: true; the library default is stillfalse. The two differ on purpose (§4.2): the scaffold’s seed data never backfills, and the flag guards exactly one thing — a backfill later thandata_lagfreezing in day state — which is the operator’s ingestion SLA.- The scaffold flip made the M9 parity gate vacuous and the suite stayed
green.
test_incremental_run’s “flag off” leg used to turn the flag ON by not appending acompute:block, so leg 4 compared the incremental path against itself. Both legs now calltests/_helpers/scaffold.py::set_incremental_reads, which asserts the edit landed AND re-parses to confirm, and each leg proves from--cost-reportoutput which path it actually took. Any new test scaffolding a project must state the flag rather than rely on silence.
M12 NTF-1 facts an assistant must know (the notification send seam)
Section titled “M12 NTF-1 facts an assistant must know (the notification send seam)”abkit/notify/dispatch.pycomputes nothing. It loads the persisted rows, callsreadout.evaluate()— the functionbuild_report_payloadand the dashboard already call — and copies eachPairVerdictfield onto aReadoutData. This is the M11 launcher discipline applied to a second surface: a message that re-derived a number could disagree with the report about the same experiment, and the operator would have no way to tell which was right.- Two
on:filters, INTERSECTED.NotificationChannelConfig.on(what a channel accepts) andExperimentConfig.notify.on(what an experiment sends) answer different questions;passes_filterrequires both, so neither can re-open what the other closed. Both accept all sixSignalKinds (abkit/config/signals.py— a leaf module, because both config models need the literal and neither may import the other’s dependency tree). Onlyreadoutfires until NTF-2. - A declared field on
notification_channelsreaches the channel constructor unless the factory strips it. The block isextra="allow", so every sibling key is a kwarg — and becauseon:is declared,model_dump()emitson: Noneeven for a config that never writes it. WithoutChannelFactory.ROUTING_KEYS, adding the field alone breaks every channel,abk test-reportincluded (18 tests red under the mutation probe). The gate istest_every_declared_field_is_classified: declared fields minustypemust EQUALROUTING_KEYS, so a future routing key cannot be added without classifying it. - D1 (signed off 2026-08-02):
--notifyis the switch,notify:is only routing. An experiment with no block — or a block with nochannels— sends to every configured channel.--notifywith no configured channels prints a loud line: silence there is indistinguishable from a broken flag. - Fail-soft is doubled on purpose (§0.4 point 1):
dispatchcatches per channel (one bad channel cannot block the rest) andrun.pywraps the whole call (a failure before any channel — a warehouse read, a config surprise — cannot fail the run). A simplify pass must not collapse them. - Nothing is sent about an experiment nobody computed.
load_experiment_readoutreturnsNonefor a missing results table, zero rows, or rows only for undeclared arm pairs — the m11 DASH-7 finding in message form (evaluate()over zero rows answers INCONCLUSIVE, a verdict about data). Acompletedoutcome notifies a readout; afailedone notifies an error notice instead (NTF-2), and that path is deliberately NOT gated on rows — the absence of a result is what it reports.lockedandskippedstay silent: neither produced a new look, and neither is a failure. - NTF-2: a notice is a
ReadoutDatawithkindset, not a second payload type.NOTICE_KINDS(error/calibration_red/stale) means nothing was measured, so every renderer branches once and omits the effect/CI/p block rather than printingN/A— a crashed run showing “Effect: N/A · Flat” is a claim about the experiment where the truth is that abkit never looked. The kind rides ON the payload becauseverdict_color()is called deep inside each channel’s payload builder, where asend_notice(notice, kind)argument could not reach it — the plan’s two-argument signature would have been a second source of truth.send_noticerefuses a verdict payload loudly. - No sixth brand hex. The five tokens in
docs/design/brand-tokens.mdare VERDICT tokens; a notice is not a verdict, so all three notice kinds reuse--srm#B23A6B— the one token that already means “no trustworthy result, look at this” — and the word + emoji carry the distinction (BaseChannel._NOTICE_PRESENTATION). A designer adding a real error token later changes that one map. - SRM is a RE-CLASSIFICATION, never a re-evaluation or a second message.
signal_kinds_for()answers("readout", "srm")for an SRM-failed readout and delivery asks “does ANY kind pass both filters”, so a channel accepting both still receives exactly one message. - NTF-3: the dedup signature is
(verdict, srm_flag), and it lives in_ab_notify_states.notify/cooldown.pyis the pure rule (no DB, no config): a change always announces, an unchanged value never re-announces (D2). Deduping on the verdict WORD alone is the trap — a pre-horizon pair says INCONCLUSIVE either way, so a newly broken SRM gate would be silenced on the experiments most likely to need the alarm.cooldown_secondsis not consulted here at all;is_in_cooldownexists for a future recurring kind, and the config field is deliberately NOT added until one needs it. - State is recorded only after a channel ACCEPTED the message —
_deliverreturns per-payload success counts for exactly this. An announcement that reached nobody must not become history: nothing re-derives what was never sent, so the flip would be lost permanently. _ab_notify_statesis inEXPERIMENT_KEYED_TABLES, soabk clean --orphaned-experimentspurges it — a deleted-and-reused experiment name would otherwise inherit the old announcement history and have its first verdict deduped away.statesis a REQUIRED keyword ondispatch_experiment_signals(explicitNonedisables dedup) so no caller can turn the quiet off by forgetting it.- NTF-4: nine channels, and three of the four new ones bend a rule the others
do not. Discord’s embed colour is a DECIMAL int and a mention inside an embed
never pings (it rides in top-level
contentwithallowed_mentions, and is stripped from the body so handles do not print twice); Teams takes a NAMED Adaptive Card colour (Good/Attention/…), so it is the one channel where the brand hex cannot be passed through, and its Workflows path posts as the flow’s identity (no per-message bot name/avatar); Google Chat Cards v2 ignore\nand need<br>, so every string is HTML-escaped THEN converted; ntfy caps the body in BYTES, not characters. All four still take their CONTENT frombuild_context()— never a donor-shaped alert object — so a notice renders as a notice everywhere for free. - The ntfy priority override is deliberately partial: it raises only LOSE/SRM/error/calibration_red. A WIN is good news, and a channel must not be configurable into buzzing a phone at 3am over one.
- The notify block sits BEFORE the report block in
run.py’s outcome loop, because the report block’sif report_path is None: continuewould skip everything after it. Both share one lazily-built manager, now honestly namedreadback_*rather thanreport_*. - NTF-5: the backlog warning was measured against the WATERMARK, and routing
it is what exposed that. The watermark is wall-clock −
data_lagand never stops advancing; an experiment’s cutoffs stop at its horizon. So a finished, fully computed series reported “now − horizon” of backlog, growing daily — invisible as a printed warning, an alarm about every finished experiment once notified.backlog_secondsnow takeslast_due_cutoff(grid, watermark_ts)(“the newest look this run could already have computed”). The §0.4 “zero new detection” clause has this limit: routing a signal is a test OF the signal. staleis RETROSPECTIVE and its wording is load-bearing. PLAN detects the gap, COMPUTE drains it in the same run, so by delivery it is closed — the news is that the SCHEDULE slipped (a run that never fired, was locked out, failed). A “still behind” flag is dead code by construction (the loop drainspending); the presentation word is “Schedule fell behind”, not NTF-2’s placeholder “Data is stale”. The condition ridesRunOutcome.backlog(BacklogEntry), not the warning string: the prose carries a lag that drifts every run.- The two RECURRING kinds (
stale,calibration_red) dedup on a SIGNATURE, not on the message.should_announce_recurringis D2’s shape for a condition that outlives the run: a changed signature always announces, an unchanged one waits fornotify.cooldown_seconds(the field NTF-3 withheld until something consulted it) — andNone≠0, becauseis_in_cooldownmutes NEITHER, so deferring to it alone would make the DEFAULT re-announce every run. The signature is WHICH things are wrong (metrics behind; red cells keyedmetric·method_config_id, never the method NAME — one metric can carry two cells of one method), never the lag or the FPR. - A cleared condition is written back as an EMPTY signature. Not a violation of NTF-3’s “only what a channel accepted becomes history”: that rule protects an ANNOUNCEMENT, and this records an OBSERVATION nobody had to receive. Without it the stored signature outlives the condition and the second outage — months later, same metric — dedups against the first and is never sent.
- Notice state rows share
_ab_notify_statesundernotice_state_key(kind)(metric='__stale__', empty arm pair; compose it ONLY there). The_ab_aa_runs__family__precedent — but the sentinel NAME is not the guarantee (MetricConfigaccepts underscores), the EMPTY arm pair is, since a variant name cannot be empty. abk validatehas its own--notify, best-effort on--report’s terms, inside the lock’s try; it dispatches even when nothing is red, because that is how a previously announced failure gets cleared. Explore-Apply calibration-red stays out of scope (tuning/server.pyhas no diff).- NTF-6:
verdict_changeis the NARROW view of a readout, not a synonym for “was delivered”. It rides the same payload (ReadoutData.verdict_changed, routing-only — no channel renders it) and is decided where the dedup state is READ, because that is the only place the previously announced verdict exists. False for a first-ever announcement and for a readout re-sent because its SRM gate moved — both are delivered without the word moving, and collapsing them intoverdict_changewould make the filter mean “readout” with extra steps. - The M12 exit gate is
tests/e2e/test_notify_pipeline.py: three experiments in ONEabk run --notify(healthy / 90-10expected_splitagainst 50-50 data / a warehouse raising only for the experiment whoseadded_filterscarry a marker), proving cross-invocation dedup through the real table, urgent-vs-routine routing, exit codes untouched by a raising channel, and one channel per kind. Its roster test DERIVES coverage from the file’s ownon: [...]configs — a hand-written set of covered kinds passes by being edited, which is exactly howverdict_changestayed unemitted. - Two
abk runs of one experiment cannot double-notify —_ab_tasksmakes the secondlocked, andlockednotifies nothing — and the notify block runs in the MAIN thread after the worker pool joins, so--workers Ndoes not reintroduce the race.runandvalidateCAN overlap (different locks, m4 D5) but write disjoint state rows.
The stats core (abkit.stats) — the implemented system
Section titled “The stats core (abkit.stats) — the implemented system”Purity invariant (hard): numpy/scipy/statsmodels + stdlib only; never
config/DB/Jinja/click. Sole intra-package import: abkit.utils.json_utils.
Enforced by tests/stats/test_purity.py.
Data model (samples.py)
Section titled “Data model (samples.py)”Sample(per-unit values, optionalcovariate,strata),Fraction(count/nobs),RatioSample(numerator/denominator pairs).SufficientStats,RatioSufficientStats,PairedSufficientStats,JointMoments— closed-form entry; mixed-ddof convention preserved from legacy:np.var-shaped terms use ddof=0,np.cov-shaped terms ddof=1. Merges are Welford/Chan-stable (accumulate.py).align_pairedaligns paired samples by unit.
Methods — a plugin registry (12 registered)
Section titled “Methods — a plugin registry (12 registered)”| Family | Registry names |
|---|---|
Parametric (from_suffstats + from_samples) | t-test, paired-t-test, z-test, cuped-t-test, paired-cuped-t-test, ratio-delta |
| Bootstrap (vectorised block-streaming engine) | bootstrap, paired-bootstrap, poisson-bootstrap, paired-poisson-bootstrap, post-normed-bootstrap, paired-post-normed-bootstrap |
- One method = one
BaseMethodsubclass +@register(+aliases). The pipeline/DB/CLI never special-case a method name. create_method(name, alpha=0.05, params={...})—alphais the effective post-correction per-comparison alpha; it is experiment-level and never entersmethod_config_id.- Param schemas are declarative
ParamSpecs (base.py): typed, defaulted, identity-flagged; validated at construction (MethodParamError). - Quarantined legacy-broken branches raise
QuarantinedMethodError(never silently substituted): PoissonPostNormed, PairedPostNormed relative, PostNormed absolute — see statistics-changes.md §3. - Entry points:
compare(groups)→ all pairwise,compare_pair(g1, g2), and the dual entryfrom_samples(s1, s2)≡from_suffstats(st1, st2).
Identity (method_config_id)
Section titled “Identity (method_config_id)”sha256(method_name + json_dumps_sorted(non-default identity params) + ALGORITHM_VERSION appended only when > 1) — byte-exact-tested. seed is
identity-excluded for all bootstrap methods; re-runs stay byte-stable via
deterministic per-row seeds (rng.derive_seed from row identity). Editing an
identity param orphans the prior results series.
Results & supporting modules
Section titled “Results & supporting modules”TestResult(result.py):method_name,method_params,alpha,pvalue,effect,ci_length,left_bound,right_bound,reject, plus per-arm stats, optionaleffect_distribution,warnings,diagnostics,to_dict().srm.py:srm_check(observed_counts, expected_split, alpha=0.001)→SrmResult(chi-square gate).correction.py:adjust_alpha,two_tier_alphas(the legacy two-tier Bonferroni keyed offis_main_metric, plus the m13 D8guardrail_alphapass-through), the read-time family adjustersbenjamini_hochbergandholm_adjustedbehindcomposed_significance’s_FAMILY_ADJUSTERSdispatch (m13 STAT-1),n_comparisons. The guardrail tier is two changes, not one (guardrail_correction: none, resolved inanalyze.effective_alphas): a guardrail is tested at the RAW alpha AND it stops counting towardsmetrics_count, which loosens alpha for the screening metrics remaining in the tier.two_tier_alphasdeliberately does not derive one from the other — it cannot see which comparisons are guardrails, so the caller that excludes them is the caller that passesguardrail_alpha.comparison_alphatests the guardrail tier BEFORE thesecondary is Nonefallback: an experiment whose only non-main comparisons are guardrails hasmetrics_count == 0, and the old fallback would hand it the MAIN (tightest) alpha. That ordering is invisible at two arms, wheremain == raw— the k=0 test uses THREE.power.py: power/MDE (t-test, CUPED-deflated, proportions).- Default p-value stays the baseline sign p-value;
(#extreme+1)/(n+1)is opt-inpvalue_kind: plugin(statistics-changes §2).
Gotchas that will bite you
Section titled “Gotchas that will bite you”- Never “fix” the mixed ddof, the sign p-value, or θ’s
np.covddof=1 — they are the captured baseline, golden-tested at rel-1e-9. - Never change a number silently: deviation ⇒
ALGORITHM_VERSIONbump + statistics-changes.md entry + CHANGELOG + A/A validation. - Stratification uses Hamilton apportionment; Poisson bootstrap is mean-only (guarded); zero denominators → NaN + warning (H5), never an exception.
M5–M10 as built (specs are canonical)
Section titled “M5–M10 as built (specs are canonical)”M5 shipped (the implementation record is
m5-implementation-plan.md): the always-valid
sequential engine (stats/sequential/, opt-in ci_kind='always_valid'), the readout under
sequential + weekly-cycle chip, the sub-day anytime-valid multinomial SRM (Lindon & Malek),
abk plan (planning/), and the two A/A columns deferred from M4 — the sequential.enabled
side-by-side peeking FPR (D8) and the composed FWER/FDR sweep over the multi-metric family
(D9, via the shared stats.correction.composed_significance).
M6 shipped (the record is
m6-implementation-plan.md): the DX / docs /
orchestration / release layer — abk init-claude + the packaged .claude assets
(abkit/cli/assets/claude/: the managed CLAUDE.md block, 9 operator rules, 7 skills), the
single-source docs site (website/ Astro, live at abkit.pipelab.dev), Prefect scaffolding in
abk init (runners/), BI reference (tool-agnostic SQL + one Grafana dashboard), abk test-report + the abkit/notify/ channel layer, abk plan runtime/ASN (WP-A, from
the cohort’s arrival rate + always-valid ASN; M8 later made the cohort source
conditional — see “M8 cohort facts”), the A/A sequential × composed family sweep
(WP-B, validate/family.py), and the release engineering (__version__ = 0.1.0, classifier
3 - Alpha, the wheel-namelist + pip install DoD gates, tests/docs/test_docs_single_source.py)
behind the WP10 exit gate (tests/e2e/test_release_readiness.py + ≥2 adversarial rounds).
Zero statistical-number changes across M2–M6 (no ALGORITHM_VERSION moved, goldens intact,
abkit.stats purity held). The sole remaining named future deferral (no version promise)
is alpha_spending/group-sequential (a scheme: alpha_spending config error names it); the
tagged PyPI publish is the maintainer’s G1 step.
M7 shipped (the record is
m7-implementation-plan.md — done
table, per-WP as-built notes, exit-gate log; released as 0.2.0 — tagged and
published to PyPI): the validate
vectorization + iteration-policy milestone — the WP0 live multi-arm
Review-mode fix, the WP1 scalar hot path + hardening bucket A1–A8 (~149× on
normal_test), the WP2 batch significance kernels
(supports_vectorized/from_suffstats_array, bit-exact via _libm_pow),
the WP3 vector_resample block-streamed GEMM engine, the WP4 score_cell
dispatcher (~10×/cell), the WP5 parity + executable perf gates, the stretch
WP7 vectorized family sweep (~18×), and the WP6 policy (opt-in
--family-sweep, per-cell auto-N, warn-never-cap). Zero statistical
numbers moved — no ALGORITHM_VERSION bump, both e2e matrix gates
byte-identical; see “M7 vectorization facts” above for the working contracts.
M8 shipped (the record is
m8-implementation-plan.md; PRs
#46–#51 + the WP7 docs-sync/release PR; released as 0.3.0 — tagged and
published to PyPI): the no-copy
assignment default + the opt-in incremental assignment.cohort_copy engine +
abk run --resync-cohort + the both-mode e2e legs + the three-way docs sync —
see “M8 cohort facts” above for the working contracts. Zero statistical
numbers moved (cross-mode parity gates; no ALGORITHM_VERSION bump).
M9 shipped (the record is
m9-implementation-plan.md; PRs
#53–#56 + #58 + the WP6 exit-gate PR #59; released as 0.4.0 — tagged and
published to PyPI): the additive compute engine + CUPED
Tier-E — see “M9 facts an assistant must know” above for the working
contracts. Zero statistical numbers moved (the flag on/off parity gate;
no ALGORITHM_VERSION bump). The library default of
compute.incremental_reads is still off, but since PERF-1 abk init writes
true and abk run will not stay quiet about an undecided project — the flip
criteria in
cumulative-intervals.md §4.1 have
been executed, with the numbers in §4.2 (facts below).
M10 shipped (the record is
m10-implementation-plan.md —
done table, per-WP as-built notes, the §3 exit-gate record, the §6 review log;
PRs #61–#64 + the exit-gate PR; released as 0.5.0 — tagged and published
to PyPI): timestamps + both track schema breaks +
explore polish — the renamed start_ts/horizon_ts window with
interval_anchor and the one ExperimentConfig.grid() factory (WP1–WP2), the
dropped _ab_results date columns + the renamed/widened _ab_experiments
window (WP2–WP3), the decoupled heavy_lock (WP4) and the bootstrap resample
memo (WP5). See “M10 facts an assistant must know” and the M3 explore facts
above for the working contracts. Zero statistical numbers moved (no
ALGORITHM_VERSION bump; the exit gate’s window golden was captured from the
pre-M10 code itself) — with one disclosed derived-number change,
horizon_seconds() across a DST transition.
M11 shipped (the record is
m11-implementation-plan.md —
done table, per-WP as-built notes, the exit-gate log; PRs #66, #68, #69,
#71–#75 + the docs-only decisions PR #67; released as 0.6.0 — tagged
and published to PyPI): abk dashboard, the
project-level cockpit — the job registry tuning/jobs.py (DASH-1), the row
shaper overview.py (DASH-2), the launcher server dashboard_server.py
(DASH-3 page/stats routes + DASH-4 job routes), abk run --metric (DASH-4a),
the third committed bundle dashboard.js from web/src/dashboard/ (DASH-5),
the abk dashboard command + docs + both wheel-namelist gates (DASH-6), and
the live-HTTP exit gate tests/e2e/test_dashboard_session.py (DASH-7). See
“M11 dashboard facts an assistant must know” above for the working contracts.
Zero statistical numbers moved (no ALGORITHM_VERSION bump; every verdict
the page shows is readout.evaluate()’s). CRUD config editing was explicitly
phase 2 — it shipped as the 0.6.x UI-1 interstitial (facts below).
M12 shipped as 0.7.0 — notifications wired to six routable signals behind
abk run --notify / abk validate --notify, nine channels, dedup in
_ab_notify_states, and fail-soft as the binding property (facts above; record:
m12 §7).
M13 shipped as 0.8.0 — five opt-in statistical options (correction: holm, contrasts: vs_control, guardrail_correction: none, interval: score
on z-test, interval: fieller on the five mean methods) plus the A/A sign
column fpr_negative_share, with no default moved and no
ALGORITHM_VERSION bumped (facts above; record:
m13).
Next — the polish track continues: M14–M17 → 0.9.0…0.12.0 (track
approved 2026-07-18; it absorbs the whole “Post-baseline hardening” backlog —
see the track section in ROADMAP.md;
m7,
m8,
m9,
m10,
m11,
m12 and
m13 are all implementation
records now; M14–M17 are contours, each opens with a design session). The 0.6.x
PLAN-1/PLAN-2 interstitial is closed (released as 0.6.1/0.6.2; design
contract: cli-and-dx.md “abk plan sizing
gaps”); the second 0.6.x interstitial is closed too, released as 0.6.4 —
UI-1 (CRUD YAML editing in abk dashboard; it restated the launcher
invariant it does not actually violate — facts above), UI-2 (abk ui
alias) and PERF-1 (the additive read path made discoverable; the scaffold
flipped to incremental_reads: true), tagged and published to PyPI.
One WP = one session =
one PR; M7–M12 moved no statistical number (parity gates + empty
ALGORITHM_VERSION grep) and M13 moved no DEFAULT — its numbers are opt-in,
the ALGORITHM_VERSION grep is still empty (D4), and the byte-compatibility
gate compares against a real v0.7.0 checkout; M13/M15 use full change control. Two binding
inter-milestone contracts: the M8→M9 one (honored — STATE/tail-scan SQL builds
ONLY through build_cohort_backend) and M10’s (the planner is reached ONLY
through ExperimentConfig.grid()). Only the second is AST-gated
(tests/core/test_grid_factory_is_the_only_entry.py); the cohort-factory
contract is still honor-system, which is exactly the shape that let a
decorative knob reach none of eight call sites — a gate for it is a named
follow-up.
Read before coding:
- The M5 as-built + the math → m5-implementation-plan.md, statistics-changes.md §4, cumulative-intervals.md §6
- The A/A matrix contracts (M4 + M5 + M6 + M7 as-built, incl. the §9 implementation note) → aa-false-positive-matrix.md
- The blocking must-fix checklist → quorum-review.md
- The cockpit & readout as-built contracts → data-contract-and-reporting.md §5, cli-and-dx.md §2; the dashboard’s own contract (launcher discipline, row shape, job routes) → m11-implementation-plan.md
- The implementation records → m2, m3, m4, m5, m7, m8, m9, m10, m11
Invariants (do not violate)
Section titled “Invariants (do not violate)”abkit.statsstays pure (numpy/scipy/statsmodels only).- Never change a number silently (version bump + changes entry + A/A).
- Methods are plugins; nothing special-cases a method name.
- The DB manager stays generic (
table_name-keyed);_ab_*semantics live ininternal_tables/only. - Greenfield storage — never copy the legacy
marts.*schema. - Renderer stays framework-free (baked payload + self-contained JS).
- Keep
init-claudeassets,docs/, and these rules in sync on release.