Changelog
All notable changes to ab-analysis-kit will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Once implementation begins, CHANGELOG.md is authoritative for behavior changes
— in particular every statistical deviation from the captured legacy baseline is
recorded here alongside an ALGORITHM_VERSION bump and a
statistics-changes.md entry (never a silent
number change).
[0.8.0] - 2026-08-05
Section titled “[0.8.0] - 2026-08-05”_ab_experiments.contrastsis now actually written, and an existing install can migrate to it (M13 STAT-6, found by the milestone exit gate). STAT-1b added the column to the table model and emitted it fromcatalog_record, but_EXPERIMENT_FIELDS— the catalog writer’s field whitelist — never learned it, so the value was dropped in silence and BI could not tell avs_controlfamily from an incomplete run, which is the one thing the column exists for. The column was also declared NOT NULL with no default, a shapeensure_columnsrefuses: the first0.8.0run of any already installed project would have failed with a “drop and recreate” error for a column whose own comment promised it needed none. It now carriesDEFAULT 'all_pairs'— additively migratable, and factually right for every pre-0.8.0row, since the knob did not exist before it — sized, because MySQL maps an unsizedStringtoTEXTand rejects a literal DEFAULT on it (error 1101), which would have replaced a broken migration with a brokenCREATE TABLEon that backend alone.upsert_experimentadditionally refuses a record with fields it would drop, the checksave_resultshas always made in the other direction.
-
STAT-6 — the M13 exit gate (
tests/e2e/test_m13_exit_gate.py), plus the batch A/A revalidation the change-control process owes every deviation (docs/research/2026-08-m13-revalidation/). The milestone’s №1 assertion is asserted against the released code rather than against itself: two surfaces captured from av0.7.0checkout — the scaffolded project as shipped, and a three-arm five-comparison experiment that reaches every default M13 touched — reproduce here row for row (discrete columns exactly, continuous at rel-1e-9, JSON payload columns parsed first), and the ONLY delta across all 89 persisted rows is the one added catalog column above. The gate also pins that opting into an interval param forksmethod_config_idwhilecorrection: holmre-decides the series in place, that narrowing the contrast set loosens the level without forking anything, and that no method bumped itsALGORITHM_VERSION. New goldens for the new numbers (tests/golden/test_golden_m13_new_numbers.py) anchor each estimator on an independent reference —brentqon the constrained likelihood for the score interval,numpy.rootsfor Fieller, the step-down definition for Holm — beside a literal pinned at rel-1e-9.The revalidation measures every knob pair on the SAME placebo draws, which is what makes its headline readable:
interval: scoreagrees withpooledto the last float on the FPR, the sign split and the power —Z(0)is the pooled z by construction, so they reject the same placebos — and differs only in relative-scale coverage, 93.4% against 90.6% (nominal 95%).interval: delta, by contrast, matches Fieller on both two-sided columns while 64% of its false positives fall below zero at a 5% control-mean CV (76% at 10%): the blindness STAT-2 shipped to fix, reproduced end to end. -
STAT-4 —
interval: fieller: the relative lift interval becomes the inversion of the test it already runs (M13). Opt-in and no default moves —intervalis a new identity-flagged param on the five mean-based closed-form methods (t-test,cuped-t-test,paired-t-test,paired-cuped-t-test,ratio-delta) whose defaultdeltais the legacy branch byte-for-byte, and noALGORITHM_VERSIONwas bumped.z-testis deliberately not among them: STAT-3’s ratio-scale score interval is the exact analogue for proportions. Full deviation record: statistics-changes.md §4.5.The defect it closes, and why nothing had caught it. The relative branch reports
θ̂ ± z·SEwith the variance taken at the observed lift — a Wald interval, and therefore a different test from the p-value printed beside it: “the lift is 0” and “the difference is 0” are one hypothesis, and a report could carry “significant” next to a lift interval covering zero. Worse, its two-sided coverage is nominal while its tails are not: measured over 200k draws, the interval misses low 1.68% of the time and high 3.27% at a control-mean CV of 5% (0.83%/3.93% at 10%), against 2.5% bought on each side. Every abkit verdict — WIN, LOSE — is a one-sided claim, so the directional error rate is up to 1.6× the configured one. The imbalance depends on the denominator’s noise and not on the true effect, which is why an A/A run measures the live experiment’s error faithfully and still reports “calibrated”: both estimators’ A/A false-positive RATES agree to the third decimal (0.0498 vs 0.0499). The column that tells them apart is STAT-2’sfpr_negative_share— 0.66 for delta against the derivation’s predicted 0.659, 0.50 for Fieller.What ships.
{θ : (a − θ·b)² ≤ z²(V_a − 2θV_ab + θ²V_b)}over exactly the five moments the delta path already had, so CUPED — whose numerator is adjusted and whose denominator is not — is covered by the same code rather than a special case. Underfiellerthe relative p-value IS the absolute comparison’s, bit-for-bit, which is what makes “the interval excludes zero” and “p < α” one event by construction. The reported lift does not change. Both one-sided error rates land on 2.5% at every control-mean CV up to 10%.The honest cost, stated rather than hidden. When the control mean is not clearly distinguishable from zero (
z²·Var(m̂₁)/m̂₁² ≥ 1), no bounded lift interval exists at that level — a theorem (Gleser–Hwang 1987), not an implementation limit: a procedure with guaranteed coverage must sometimes decline, and delta’s always-finite interval is why its guaranteed coverage is zero. abkit then reports the effect and the p-value with empty bounds and a warning naming the reason, and the readout treats such a row as a gap — so a comparison can reject on the absolute scale and still not be called a WIN. It fires on 0% of draws at a control-mean CV of 10% and 8.5% at 30%. An empty confidence set (a non-PSD moment triple, i.e. the mixed-ddof anomaly the delta branch reports as a negative variance) gets its own sentence: five causes of missing bounds, five messages.Consequences. The interval is asymmetric, so it inherits every STAT-3a refusal with no new surface code —
sequential: {enabled: true}beside it is a level-2 config error naming both knobs (also at the explore knob and its Apply seam),abk validatescores the fixed columns and omits the always-valid one, explore’s α tier answers with a gap, and every surface renders[low, high]rather than±. Writinginterval: fiellerbesidetest_type: absoluteis a construction error, not a silent no-op: it would compute nothing and still forkmethod_config_id. Andabk plan’s Wald sizing turns out to be closer under Fieller than under the shipped default —get_ttest_mde’s relative branch is the null-variance rule, which is Fieller’s own rejection boundary — so its caveat now claims a difference in half-widths rather than in decision rules. -
STAT-3 —
interval: score: the z-test’s confidence interval becomes the inversion of the test it already runs (M13, Miettinen–Nurminen in its Farrington–Manning form). Opt-in, and no p-value moves —intervalis a new identity-flagged param onz-testwhose defaultpooledis the legacy branch byte-for-byte, and noALGORITHM_VERSIONwas bumped. Opting in changesmethod_config_id, so it starts a new results series for the operator who asks for it and for nobody else. Full deviation record: statistics-changes.md §4.4.The defect it closes. The z-test computes its p-value from the pooled (null) variance — and built its interval from the same frozen number. That kept “the CI excludes zero” and “p < α” in exact agreement, which the readout relies on, at the price of an interval that is a valid confidence set at zero only. An SE mis-scaled by
rinflates the achieved error rate by(1/r)·exp(z²(1−r²)/2): at a 900/100 holdout the pooled SE is 24% too small — pooling is not the conservative choice it is widely believed to be — which is 2.7× at α = 0.05 and 30× at α = 1e-4. The damage grows as the multiple-testing correction shrinks α, so the two defects compound.What ships. One statistic used three ways: the p-value is
2(1 − Φ(|Z(0)|))(identically the pooled z, hence unchanged), the absolute interval is{δ : Z(δ)² ≤ z²}, and the relative interval is the same construction on the ratio scale, whose statistic at ratio 1 is again that sameZ. Coherence is therefore preserved by construction, on both scales at once, while the interval becomes valid everywhere, asymmetric in the direction the sampling distribution actually is, and confined to[−1, 1]. Boundary tables stop being special cases: withtest_type: absolute,x₁ = x₂ = 0— the first cutoff of any sparse metric — now reportsp = 1beside the Wilson zero bound±z²/(n+z²), where the pooled path returned a NaN p and NaN bounds, a row no reader can act on. That is the one table where a p-value moves (from absent to 1), and the one place this release’s “no p-value moves” needs its exception read aloud; a lift over a zero baseline stays undefined underrelativewhatever the interval method. Such a row is also newly informative, so under a read-time correction (BH / Holm) it joins the family and tightens the threshold its siblings are judged against — conservative, but a cross-metric consequence of a per-metric knob.Consequences, each recorded rather than assumed. The relative interval is the ratio-scale score construction, not the difference interval divided by
p̂₁(which drops the denominator’s sampling error and can return a lift below −100%); it carries a warning — never a suppression — when the lift is not pinned to better than ±50%, stated in conversions, because the width lawz·√(1/x₁ + 1/x₂)reads counts and not exposed units.abk planprints a caveat: it sizes on the normal power formula while the analysis inverts the score statistic, and the two half-widths differ byC·z²/n_arm(measured: C = 4.01 at a 5% baseline, 0.0595 at 30% — 0.15% at 10k units per arm). Inabk explore, the±CIchip renders[low, high]for such an interval instead of a half-width, and a cutoff that cannot be reconstructed exactly at a dragged alpha becomes a reported gap rather than an “approx” point — Tier α re-derives a symmetric normal CI, which for this interval approximates nothing. (Ordinary rows are unaffected: Tier E is exact for them and is tried first.) -
STAT-3a — the
asymmetric_ciguard: SE-by-CI-inversion refuses instead of mis-recovering (M13, decision D17). No number moves — no shipped method builds an asymmetric interval — true when it shipped, and superseded above by STAT-3, which adds the first configuration that does. Every refusal below was unreachable at the time and the suite was byte-identical. It is the prerequisite STAT-3 (Miettinen–Nurminen) and STAT-4 (Fieller) cannot ship without.The defect it closes. The always-valid transform never receives a standard error:
sequential.se_from_ci_lengthinfersSE = ci_length / 2z, which is the SE only for aneffect ± z·SEinterval. Handed a score/Fieller-type interval it returns the mean half-width overz— a finite number that is not the SE — andsequentializethen centres a symmetric confidence sequence on it. No NaN, no exception, silently wrong, and worst exactly where the new intervals are worth having (the recovery degrades as asymmetry grows).What ships.
BaseMethod.asymmetric_ci(defaultFalse— today’s truth for all twelve methods, pinned by a roster gate) andsequential.require_symmetric_ci, which every inversion entry calls. Themethodargument is required and keyword-only onse_from_ci_length,se_from_ci_length_arrayandto_always_valid: a caller may not invert a CI without saying whose it is. The flag is resolved per bound instance, not per class, because STAT-3 ships its interval as an identity-flagged param onz-test— a class-level flag would answer for the default params and miss the configuration it exists to catch; passing a class raisesTypeErrorrather than being answered.An asymmetric method is not blocked — it declares
supports_sequential: falseand its series simply stays fixed, which is what every eligibility gate already tests. The refusal fires only for a method claiming both.A twelfth entry point, which the design’s count of eleven had missed:
tuning/recompute._alpha_inverted_bounds— explore’s Tier α — open-codes the same premise (se = (right − left) / 2z) instead of calling the helper, and re-derives a symmetric normal CI at the new α from persisted numbers. It takes the same refusal; which UX an asymmetric method eventually gets there stays a named sub-task of STAT-3/STAT-4. An AST gate now fails on any new open-coded inversion and on any guarded call that omits its method. -
STAT-1 —
correction: holm, and the FWER claim stated precisely (M13, decisions D7/D9). Two things ship, and only one is code.The claim. Under the default two-tier Bonferroni, abkit tests each arm pair’s main comparison at
α/Pand that pair’sksecondaries atα/(P·k). Precisely: the main tier spends a full α per main comparison — with the usual single main metric that is “the probability of shipping on a spurious main-metric win is ≤ α”, true and unchanged — the secondary tier independently spends a second full α, and the experiment-wide bound is therefore(M+1)·αforMmain comparisons, i.e.2αat the default. It is flat in the arm count and in the number of secondary metrics, and linear only in the number of independent ship decisions the experiment declares; aguardrail_correction: noneguardrail (STAT-1c) adds its own raw-α test on top. Those are exactly the levels of a valid serial-gatekeeping procedure whose gate abkit does not enforce, deliberately: enforcing it would suppress a secondary metric exactly when it is most diagnostic (“the main metric is flat but retention dropped”). So the defect was in the claim, not the arithmetic — no number moved, noALGORITHM_VERSIONwas bumped (statistics-changes §4.3).The code.
correction: holmis a fourth scheme (project or experiment, opt-in, default unchanged): the step-down rule, FWER ≤ α under arbitrary dependence, uniformly more powerful than dividing α by the family size. It is read-time, beside Benjamini-Hochberg, over one cutoff’s informative rows — because no fixed per-comparison level can reproduce a step procedure (α=0.05, m=2, p₂=0.03: Holm rejects H₂ when p₁=0.001 and refuses when p₁=0.9). BH and Holm now share one body incomposed_significanceand differ only in the adjuster. Note that Holm is not uniformly more powerful than the two-tier scheme, whose main tier sits atα/P: that looseness is what the2αabove pays for.Fork B is now ratified and visible. A verdict and the interval stored beside it MAY disagree under a read-time scheme — abkit has been in this position under BH since M3 without documenting it. The divergence is one-directional (a family rule is never looser than the member’s own raw alpha), so the observable case is an interval excluding zero under a verdict that declines to call it;
readout.evaluate()now attaches an explicit caveat to exactly that pair and setsPairVerdict.family_divergence. The HTML report andabk dashboardrender the caveat; every notification channel renders its own sentence off the flag — a message shows an interval beside a verdict with no report to click through to, and the M12 rule is that a notification cannot disagree with the report about the same experiment.abk explorerenders neither, by design: it never callsevaluateand shows uncorrected per-comparison inference. The readout’s rationale also stops saying “CI excludes zero” when the family rule is what decided — that sentence named a per-comparison fact as the reason for a family-level decision.Three read-time-only behaviour changes the adversarial review forced, each conservative and each applying to
benjamini_hochbergas well. (1) FLAT is withheld when the pair’s own interval excludes zero: under a family rule “no cutoff was significant” no longer implies “the interval covers zero”, so FLAT — an affirmative claim of no meaningful effect — would have been called against the pair’s own interval. (2) FLAT’s power story is disclosed as optimistic: the MDE is solved at the row’s raw alpha while the family threshold isα/mat worst. (3) Aguardrail_correction: noneguardrail leaves the read-time family too — D8’s declaration has two halves, and at read time the family IS the divisor, so honouring only “raw alpha” made D8 a silent no-op under every read-time scheme. A family whose rows carry mixed alphas (reachable because alpha sits outsidemethod_config_id) is now warned about, since the rule then controls the error rate at the loosest of them._ab_results.rejectkeeps its name and meaning and is redocumented as the pre-family, per-comparison flag it has always been (it is a published BI contract); the composed decision is not persisted at all, because under a read-time scheme it exists only at read time and a stored copy would go stale the moment a metric was added.abk plannow says in its header that a read-time scheme sizes at the raw alpha. A scheme is classified compute-time or read-time in ONE place (stats.correction.READ_TIME_CORRECTIONS), with a roster test asserting the union equals the config literal — the!= "benjamini_hochberg"tests that used to be spread across the readout and the A/A runner would each have silently degraded a new scheme to the per-row CI rule. -
STAT-1b —
contrasts: an experiment declares the family it claims (M13, decision D15).contrasts: vs_controlon an experiment says the family is theg−1many-to-one contrasts against the control arm (the first declared variant) instead of allC(g,2)variant pairs. The Bonferroni divisor falls accordingly, which multiplies every tier’s level byg/2: ≈ +10 points of power at four arms, +6 at three — more than Holm gives, for a config field rather than new math. Defaultall_pairsis the pre-0.8.0behaviour, so a project that changes nothing reproduces0.7.0(m13 D1).The knob is one declaration with two halves, and shipping either alone is a defect. Under
vs_controlthe treatment-vs-treatment pairs are also not computed — no_ab_resultsrows, no verdicts, no read-time BH family members. Loosening the divisor while still writing those rows would hand levels bought forg−1contrasts to a family ofC(g,2), i.e. a false FWER claim in the dangerous direction; narrowing the enumeration alone would leave the experiment needlessly conservative. Both halves read the SAME source:ExperimentConfig.contrast_pairs(), now the only place inabkit/allowed to enumerate arm pairs (AST-gated). Before this WP four modules each carried their owncombinations(variants, 2)— the analyze stage that writes the rows and the three read filters (report, dashboard, notifications) — andnotify/dispatch.pyhad predicted in a comment that “a fourth copy should force the extraction”.There is deliberately no project-level default: the family a surface reads must never depend on whether that surface resolved one, and it is a statement about an experiment’s design rather than a project-wide policy.
Rows written for a pair the narrowed family no longer claims are ignored loudly by every read surface — the path a renamed arm already took. The remedy named in that warning is now
abk run --full-refresh(which deletes the window before rewriting the declared pairs);abk cleannever removed them — it prunes series bymethod_config_id. The CLI lines that printed a literalC(g,2)divisor (abk run,abk validate,abk plan’s header) now name the family they counted, and the explore client mirrors it — a page dividing byC(g,2)while the server it queries divides byg−1would disagree with its own rows.Four things the adversarial review added, each a hole the knob opened. (1) The planner’s anti-join is now complete at (cutoff × declared pair): widening the family back — or adding an arm — used to leave every historical look touched but incomplete, so the newly declared contrasts existed only from the flip onward while their siblings kept an alpha bought for the narrower family, which is the anti-conservative direction. (2)
readout.evaluate()filters undeclared pairs itself, so a direct caller cannot build a read-time BH family the experiment does not claim. (3) The explore cockpit — the fourth reader of persisted rows, and the one without a filter — no longer recomputes series for pairs that are not on the page. (4) A project-levelstatistics: {contrasts: …}is a loud error rather than a silent no-op, because every neighbour in that block does have a project default._ab_experimentsgained acontrastscolumn (additive) so a BI join cannot re-derive a divisor no row carries, and the HTML report names the family beside the arm list — otherwise a 4-armvs_controlreport shows four arms, three pair blocks and α = 0.05/3 with nothing reconciling the three.Opt-in, so no default moves and no
ALGORITHM_VERSIONis bumped (D1/D4). Sincealphais deliberately outsidemethod_config_id, flipping the knob writes new-alpha rows into an existing series (abk run --full-refreshre-homogenises it) and_ab_aa_runsrows keyed on the old effective alpha readalpha_mismatchuntilabk validatere-runs them. -
STAT-2 — the A/A matrix records WHICH SIDE each false positive fell on (M13).
abk validatenow reportsfpr_negative_sharebeside the FPR: the share of single-look false positives whose CI sat below zero. A correct interval is sign-symmetric under the null, so it is 0.5 up to Monte-Carlo noise.It exists because the FPR count structurally cannot do this job. Several relative-effect formulas share an identical rejection set at the null, so their measured FPRs agree to the last false positive while their false positives lean opposite ways — the sign is the only column that separates them, and the lean it detects grows as α shrinks, i.e. it is worst in exactly the corrected tier. The number rides in the persisted
detailsJSON (no schema change) and a verdict line names a lean when it clears two gates: at least 100 false positives, and a departure from 0.5 of at least 3 standard errors (sqrt(0.25/hits)) — a test rather than a fixed percentage, which would fire constantly on small cells and never on large ones. Silent otherwise, because a noisy claim about the estimator trains the operator to ignore it.Measurement only: no statistical number moves, and the scalar and vectorized engines agree exactly (the share is a ratio of two block-invariant mask counts, so it joins the parity gate’s exact class, not its continuous one).
-
STAT-1c —
guardrail_correction: guardrails can stop being corrected like growth metrics (M13, decision D8). A guardrail exists to catch harm, so correcting its alpha makes the engine less able to do the one job the metric was declared for — the error points the dangerous way. The new project- (and experiment-) level knob takesinherit(the default, the pre-0.8.0scheme: a guardrail shares the secondary Bonferroni budget) ornone(the guardrail is tested at the raw experiment alpha).noneis two changes, not one: the guardrail is re-routed and it stops counting towards the secondary divisor, which loosens alpha for the screening metrics that remain in the tier. One main + one screening + one guardrail over two arms moves frommain 0.05 / secondary 0.025tomain 0.05 / secondary 0.05 / guardrail 0.05.The flip is inert unless
correction: bonferroni(every other scheme already hands out the raw alpha at compute time), and it is opt-in because it moves persisted numbers. Two consequences worth stating: alpha is deliberately outsidemethod_config_id, so flipping the knob writes new-alpha rows into an existing series (abk run --full-refreshmakes a series homogeneous again); and the A/A calibration chip keys on the effective alpha, so existing_ab_aa_runsrows for guardrail metrics readalpha_mismatchuntil re-run. Documented in declarative-config.md §6.1.No default moved: a project that changes nothing reproduces
0.7.0exactly, and noALGORITHM_VERSIONwas bumped (M13 D4 — under opt-in, an operator who changes a level orphans only their own series, at the moment they ask for it).
Changed
Section titled “Changed”sequential.enabledtogether with an asymmetric interval is now a level-2 config error naming both knobs, instead of anAsymmetricCIErrorraised mid-run once the cohort had already been loaded. The same sentence refuses the combination at theabk exploreknob and at its Apply seam, decided off the experiment’s ownsequential.enabledrather than off the baked rows — a toggle flipped but not yet re-run would otherwise let the cockpit write the very pairabk runrefuses. The STAT-3a guard remains the backstop under all three.abk validateDEGRADES on an asymmetric interval instead of failing. STAT-3a shipped a failing cell, which was right while no method could declare the flag and wrong the moment one could: the τ² anchor runs unconditionally at the top of both scoring engines, so the refusal failed every cell — leaving the A/A matrix unable to measure the estimator the change-control process invokes it to certify, and explore’s calibration chip permanentlyuncalibratedwith no command able to clear it. Such a cell now scores its fixed columns and simply has no always-valid column (exactly what a bootstrap method gets), with a note naming that reason rather than “τ² could not be anchored”. The always-valid mode can be built on a score interval — by substituting the sequence’s critical value inside the root-find — butto_always_validcannot express it: it widens a finished interval, recovering an SE the method does not have. That is a named future extension, not a limitation of the score interval.abk plannow binds each comparison’s method to resolve its interval shape, so a comparison whose method params are invalid is refused by name rather than silently sized against defaults it never had.
[0.7.0] - 2026-08-03
Section titled “[0.7.0] - 2026-08-03”-
NTF-6 —
verdict_changefires, and the whole feature has an exit gate. The kind was declared in NTF-1 and emitted by nothing for four work packages, soon: [verdict_change]matched nothing and was silent for a reason no operator could guess. It is now the narrow view of a readout it always read like: a verdict whose WORD differs from the one last announced — not the first message about a comparison (news, but nothing flipped) and not one re-sent because its SRM gate moved while the word stayed put.tests/e2e/test_notify_pipeline.pyruns the whole feature through the CLI: three experiments (healthy / broken sample split / failing pipeline) in oneabk run --notify, with the four claims the milestone is worth nothing without — a scheduled run is quiet across separate invocations, the urgent signals reach an on-call channel while routine ones do not, a channel that raises cannot change an exit code, and every one of the six declared kinds is observed on a channel scoped to it alone.docs/guides/notification-channels.mdrewritten around what the commands now do rather than what was planned.- Zero statistical numbers moved across the whole of M12 (no
ALGORITHM_VERSIONbump; every number in a message is copied off aPairVerdictor aCellResult).
-
NTF-5 — the two signals about the machinery:
calibration_redandstale. Both are conditions abkit already detected and only ever printed; neither adds a detector. Both RECUR — the condition is still true on the next run — so they are deduped on WHAT is wrong rather than on the message.abk validate --notify(new flag) sendscalibration_rednaming every cell whose measured A/A false-positive rate exceeded its budget: the matrix’s “do not use” verdict, delivered to the people not watching the terminal. Best-effort on--report’s terms — a delivery failure never turns a successful validation into a failed one.abk run --notifynow also sendsstalewhen a metric’s computed series was more than three cadence steps behind the looks already due when the run planned it.- The
stalemessage is retrospective, and says so. The run that detects a backlog is the run that computes the missing looks, so what is behind is the SCHEDULE — a run that never fired, was locked out, or failed — not the warehouse. Its notice word changed from NTF-2’s placeholder “Data is stale” to “Schedule fell behind” for that reason. - Dedup is by signature, not by sentence: which metrics are behind, which cells are red — never the lag or the FPR, both of which drift on every run and would re-announce forever. A widened condition (a second metric behind, a second cell red) always sends; a condition that CLEARS is recorded as cleared, so the same one recurring next month is news again rather than a dedup hit against a months-old row.
notify.cooldown_seconds(new experiment-level field) re-announces an unchanged recurring condition after that many seconds. It applies to these two kinds only: D2 is unchanged, and a verdict flip never waits for a timer. NTF-3 deliberately withheld this field until a signal consulted it.verdict_changeremains the one declared kind nothing emits — NTF-6 either wires it or drops it from the vocabulary.
-
NTF-4 — four more channels:
discord,teams,googlechat,ntfy(nine in total). Thin adapters in abkit’s own idiom: each takes its platform’s wire format (and its limits) as fact, and its content from the samebuild_context()every existing channel uses — so a notice renders as a notice on all nine without any of them knowing what a signal kind is. None of the donor’s alerting semantics (severity, anomaly/recovery/no-data) came along; abkit still has none.discord— one embed per readout. Its colour is a decimal int (the one channel that will not take#RRGGBB), and mentions ride in the top-level message content withallowed_mentions, because a mention inside an embed renders as text and pings nobody. They are stripped from the embed body so a handle does not appear twice.teams— an Adaptive Card over a Power Automate “Workflows” webhook, not the retired O365 connector. Two Microsoft consequences: the message posts as the flow’s identity (no per-message bot name or avatar), and the status colour is a named card token, so this is the only channel where the brand hex is mapped rather than passed through.googlechat— Cards v2, where\nis not a line break: text is HTML-escaped and then newlines become<br>, or the message arrives as one run-on paragraph.ntfy— JSON publish to a topic, with the status cue carried by the tag emoji and the priority. Thepriorityoverride applies only to the urgent kinds (LOSE, a failed SRM gate, errors): a WIN is good news, and the channel must not be configurable into buzzing a phone over one. The body is capped on a byte budget, since ntfy’s limit is bytes and a multibyte body would pass a character count and be rejected by the server.abk test-reportnow exercises all nine types end to end — a type its smoke test cannot construct and send through is a type that does not really ship.- Zero statistical numbers moved (no
ALGORITHM_VERSIONbump).
-
NTF-3 —
--notifystops repeating itself. Until now every completed run re-announced the same verdict, which makes the flag unusable on the schedule it was built for. The new_ab_notify_statestable records what each comparison last ANNOUNCED, and only a change is sent.- The rule (maintainer-signed as D2 before implementation): a change always
sends, an unchanged value never re-sends.
cooldown_secondsis deliberately not consulted for verdict dedup — a timer that could swallow a WIN→LOSE flip would be a silence, not a crash, and silence is the failure mode a notification system cannot afford.is_in_cooldownships as the primitive a future recurring signal will need. - “Unchanged” means the verdict AND its SRM gate. A pair sits at INCONCLUSIVE for days before its horizon, so a newly broken sample-ratio gate keeps the verdict word identical — deduping on the word alone would have swallowed the SRM alarm NTF-2 just built, on exactly the experiments most likely to need it.
- A message nobody received is not recorded. If every channel was down or filtered out, no state is written and the next run tries again: an announcement that reached no one must not become history, because nothing re-derives what was never sent.
- The dedup key is the FULL comparison identity — experiment, metric, arm
pair and
method_config_id— so re-tuning a comparison starts a fresh announcement history instead of inheriting the previous method’s. abk clean --orphaned-experimentspurges the new table with the rest, which resets the dedup. That is not tidiness: an experiment name deleted and later reused would otherwise inherit the old one’s history and have its first real verdict deduped away.- Zero statistical numbers moved (no
ALGORITHM_VERSIONbump).
- The rule (maintainer-signed as D2 before implementation): a change always
sends, an unchanged value never re-sends.
-
NTF-2 — the urgent half: a failed run and a broken split now reach the on-call channel.
--notifyshipped able to say only one thing (“here is a verdict”), which is the half an operator is least likely to be woken for.- A pipeline error is a signal with no readout behind it. A
failedexperiment now sends a notice carrying the reason. Every statistical field stays empty on purpose and every renderer omits its statistics block — a crashed run rendered as “Effect: N/A · Flat” would be a claim about the experiment, when the truth is that abkit never got to look at it. This path is deliberately not gated on persisted rows the way the readout path is: the absence of a result is exactly what it reports. A notified failure still exits non-zero. - SRM is the same message, re-classified — never a second one. When the
sample-ratio gate fails, the readout abkit already built answers to both the
readoutand thesrmkind, soon: [srm, error]is a genuine on-call channel (broken splits and failures, no routine readouts) while a channel accepting both kinds still receives exactly one message. Nothing is re-evaluated to produce it. ReadoutDatalearnedkind+noticerather than growing a second payload type, so all five channels support notices through the transport they already have;BaseChannel.send_noticeis the seam a channel overrides when its rich rendering assumes a verdict (onlyemail’s HTML card does). It refuses a verdict payload loudly rather than rendering one blank.- No sixth brand colour was invented. The five tokens are verdict
tokens and a notice is not a verdict, so all notice kinds reuse the
--srmtoken — the one that already means “no trustworthy result here” — and the word plus emoji carry the distinction. - Zero statistical numbers moved (no
ALGORITHM_VERSIONbump).
- A pipeline error is a signal with no readout behind it. A
-
NTF-1 —
abk run --notify: the readout finally leaves the terminal.abkit/notify/has shipped five channels since0.1.0, and the only thing that ever called them wasabk test-report, which sends a synthetic readout. A real verdict has never been deliverable. The newabkit/notify/dispatch.pyis the seam: after each completed experiment,abk run --notifyreads the rows the run just persisted, evaluates them throughreadout.evaluate()— the same functionabk run --reportand the dashboard call, so a message cannot disagree with either — and sends one payload per verdict through every configured channel.- Opt-in twice over, and the two opt-ins mean different things. The flag
is the operator saying send; the new experiment-level
notify:block (channels/mentions/on) says to whom, and about what. With the flag and no block, every configured channel receives every kind — an operator who wired upnotification_channels:never has to touch experiment YAML to hear from them. - Two
on:filters that INTERSECT. A channel’son:(new onnotification_channels) narrows what that channel accepts; an experiment’snotify.onnarrows what it sends. Neither can re-open what the other closed. Only thereadoutkind fires in this WP; all six kinds are accepted from the start, so a filter written today does not silently widen when the remaining signals land. - Fail-soft is the contract. A channel that raises, one that cannot be
constructed (a rotated secret), one that returns
False, or a warehouse read that fails — each is one yellow line and the run’s exit code is untouched. The--reportprecedent, applied to the same loop. - Nothing is sent about an experiment nobody computed. No results table,
no rows of its own, or only rows for arm pairs the config no longer
declares ⇒ silence:
evaluate()over zero rows answers INCONCLUSIVE, which is a verdict about data, and delivering it would report a finding where there is not even an observation. - Zero statistical numbers moved (no
ALGORITHM_VERSIONbump) — every number in a message is copied off aPairVerdict.
- Opt-in twice over, and the two opt-ins mean different things. The flag
is the operator saying send; the new experiment-level
- A finished experiment no longer reports a growing backlog forever. The
abk runbacklog warning measured the computed series against the WATERMARK (wall-clock −data_lag), which keeps advancing after an experiment’s cutoffs stop at its horizon: a fully computed, past-horizon series reported a backlog of “now − horizon” that grew by a day every day, loudest when there was nothing left to do. It is now measured against the last cutoff that was actually DUE (last_due_cutoff). Found by NTF-5, which would otherwise have routed that warning as a notification to every experiment a team has ever finished. Cosmetic for the warning; load-bearing for the signal. - A
notification_channels:entry can now declare abkit’s own keys without breaking the channel. The block isextra="allow"and every sibling key was forwarded verbatim to the channel constructor, so the newon:routing key would have reachedSlackChannel(on=[...])and failed as “Invalid parameters” — including inabk test-report, which never asked about routing.ChannelFactory.create_from_confignow strips the routing keys it owns, and a test asserts every field declared on the config model is classified as either thetypediscriminator or routing.
[0.6.4] - 2026-08-02
Section titled “[0.6.4] - 2026-08-02”- PERF-1 — the M9 additive read path is no longer silent, and the flip
question is settled with numbers.
compute.incremental_readsshipped in0.4.0behind the note “default false until verify-incremental bakes”, which was true then and was never revisited; meanwhile nothing inabk runor--cost-reportever mentioned the fast path existed. Worse, the scaffold declaredstate_additive: trueonexample_arpuwhile leaving the read flag off, so a default project paid the STATE write and never took the read — the one configuration strictly worse than either endpoint.abk runnow warns when a metric is day-additive (so thestatestep is materializing its moments every run),compute.incremental_readsis written nowhere, and the series has reached six looks. The warning names both ways out. It also discloses the two mirror silences: the flag on with nothing declaringstate_additive(it is doing nothing), and how many looks an enabled read fell back to recompute for — the reader’s own warnings name the reason but are deduped per (metric, reason) and so could never report extent.- An absent key and an explicit
falseare now distinguished (via pydantic’smodel_fields_set; the field is still a plainbooland every reader is untouched). They resolve to identical behaviour, but only the first is undecided — without the distinction the warning could never terminate, and a nag that cannot be answered is just a different silence. abk run --cost-reportprints the counterfactual. Undercompute:it addsof which day-additive:— the same measured query deltas attributed to the eligible comparisons only — then what the other read path would do with it. The slice is part of thecomputetotal, never a sibling; adding them double-counts.abk initnow scaffoldscompute.incremental_reads: true, with a comment saying why and when to set it back. Its seed dataset never backfills, and M9 pickedexample_arpuas the additive demo deliberately — a scaffold that pays the write and demonstrates the read beats one that demonstrates neither.- The library default stays
false, now for the real reason. The flag is not a correctness gate:abk verify-incrementalreconciles both paths at rel-1e-9 and any state gap falls back to recompute unaided. It guards exactly one thing — an event backfilled later thandata_lagfreezes in already-materialized day state — which is a property of the operator’s ingestion SLA, not of abkit. The stale field text now says that. - The §4.1 criteria were executed and the evidence published in
cumulative-intervals.md §4.2: three
consecutive clean
verify-incrementalruns over the scaffold (zero divergences, zerounverified), and fact-row scans of 2.5× / 3.5× / 5.0× / 8.5× / 11.0× at 2 / 4 / 7 / 14 / 19 looks (total fact rows scanned; the COMPUTE stages behind them reproduceN·L(L+1)/2recompute vs zero incremental at daily cadence, exactly). The threshold is stated in looks, not days — §4.1 said days because it assumed a daily grid, and an hourly cadence re-reads the window 24× a day. - Test impact, deliberately pinned both ways. The scaffold flip changed
which path the e2e gates exercise by default: the milestone’s №1 assertion
(
incrementalvsrecomputepersist the same numbers) silently became a comparison of the incremental path against itself, because its “flag off” leg simply appended nothing. Both legs now set the flag explicitly through one helper that asserts the edit landed, and each leg asserts — from--cost-report’s output — that it really took the path its name claims. - Zero statistical numbers moved (no
ALGORITHM_VERSIONbump).
- UI-1 — the
abk dashboardcockpit edits experiment YAML. M11 shippedShow YAMLas a read of the file on disk and recorded CRUD as “phase 2”; this is that phase. The row’s button is nowEdit YAML, opening the raw text in a textarea with Save, Revert and Delete…, and the header gains New experiment and Reload configs. The write goes through the newabkit/tuning/config_files.pyin the house order — validate → archive → write: both validation levels run (ExperimentConfig, thenvalidate_experiment_level2— the §8 matrixabk run --steps validateruns, reference integrity and the SQL render smoke included), the previous file is archived byte-verbatim under<experiments>/.history/<name>/, and the overwrite is atomic. Routes:POST /api/experiment/{save,create,delete},POST /api/reload,GET /api/experiments.- The text round-trips verbatim — comments and layout survive. That is the
one thing
abk explore’s Apply cannot promise: Apply merges a structured edit and RE-EMITS the parsed document, so comments die and the archive is the recovery. An editor that silently reformatted the file it had just shown you would be worse than none, so the two seams stay different shapes and share only their archive/atomic-write primitives (both land in the same.historytree). - A save is refused, not merged, when something else wrote first. The read
route hands out a digest of the file; the editor echoes it back and a
mismatch refuses the write. Two writers make that ordinary rather than
exotic: a second browser tab, and an
abk exploreApply — which this dashboard can itself spawn, on the very experiment being edited. A save or delete is also refused while the cockpit has a running job on that experiment: a liveabk runhas already read the config it is executing, and a live cockpit will overwrite whatever is saved here on Apply. - Level 2 is overridable; level 1 never is. A config that pydantic rejects
cannot be served as a row, so it is refused outright. A config that fails the
§8 matrix is a statement about the whole project — a metric that does not
exist yet, an SQL file still being written — and an editor that refuses until
the project is coherent is unusable in exactly the situation it is opened
for. So the refusal names the findings, offers Save anyway (
force: true), and the saved file carries them back asSAVED WITH AN ERROR — abk run will refuse this: …. - A file too large to display cannot be saved. The read truncates at 512 kB
and now says so with
digest: null, editable: false— a digest over a prefix would let a save write that prefix back and silently drop the tail. - Deleting removes the YAML only. The experiment’s rows in
_ab_results/_ab_unit_statestay untilabk clean --orphaned-experimentsprunes them, and the reply says so rather than stranding a series quietly; the archive is a-deleted.ymltombstone, so the delete is reversible by hand. A rename (the YAML’sname:changed) is allowed, keeps the file’s path, and warns that the persisted history does not follow. - Configs are no longer read once at boot. Every mutation re-resolves the
cockpit’s own
--select/--excludefrom disk, re-bakes the page and returns the refreshed list, so a created row appears and a deleted one leaves without a restart;POST /api/reload(the Reload configs button) is the manual form, which also picks up an edit made by an editor, agit pullor an explore Apply. A reload that fails — a broken sibling YAML, a name collision — keeps the previous selection and rides back as a warning: the write has already landed, and turning it into a 500 would report a successful save as a failure.
- The text round-trips verbatim — comments and layout survive. That is the
one thing
- UI-2 —
abk uiis an alias forabk dashboard. The donor’s project-level cockpit isdtk ui(its per-metric siblingdtk tuneis ourabk explore); abkit renamed both and the rename was never arbitrated.dashboard/exploresay which surface you want whereuidoes not, so the canonical name stays and the alias is registered as the same callback object — the two names cannot drift in options or help.
Changed
Section titled “Changed”- The dashboard’s launcher invariant is restated: “computes no statistic and
takes no pipeline lock”. M11 wrote it as “computes a statistic, turns a knob,
writes a config or takes the pipeline lock”, which folded in a clause no gate
ever checked — the AST scan and the route spy have always been about
acquire_lock/release_lock. A config write is the operator’s own declaration, not a result: no number on the page comes from it, and it cannot block a pipeline. The gates now also run over the editor routes (no lock, andreadout.evaluateis never called), and the token gate’s hand-maintained POST route list is AST-checked against what_route_postdispatches on — it was GET-only, which left the routes that mutate covered by a list nothing verified.
[0.6.3] - 2026-08-02
Section titled “[0.6.3] - 2026-08-02”- A renamed
paths.experimentsnow reaches selection — it used to reach nothing.project.paths.experimentshas always been a config field, butdiscovery.select_experimentshard-coded"experiments", and all ten of its callers (run,plan,validate,explore,clean,unlock,dashboard,verify-incremental,test-report) took that default — so a project whose experiments live anywhere else answered “Nothing selected.” to every command, whileabk dashboard’s own derivation honored the setting and the two disagreed about which files exist. The directory is now resolved insideselect_experimentsfrom the project config, so every caller gets it without a signature change (passing it explicitly still overrides). Deliberately tolerant: discovery runs before the CLI’s config load, so an unreadableabkit_project.ymlfalls back to the default rather than turning selection into a parse error — the caller that needs a validated project reports that failure itself.
[0.6.2] - 2026-08-01
Section titled “[0.6.2] - 2026-08-01”- PLAN-2 —
abk plan --from-history <N d>: a baseline for an experiment that has never run. Sizing needs per-unit moments, and they came only from a previousabk runof the same experiment or a hand-typed--baseline— so the pre-launch case, the one planning is actually for, answeredSKIPPED: no baseline. The flag reads each sizable metric over the N whole days before the experiment’s start and derives its moments through the pipeline’s ownbuild_container→SufficientStatspath (a hand-rollednp.stdwould disagree with the persistedstd_1’s legacy mixed ddof, making two baseline sources quietly incomparable). No_ab_resultsnumber moves andabk planstays read-only — no lock, no writes.- The read is POPULATION-wide, and the plan line says so. The design contract
assumed the CUPED pre-period render was “cohort-free by construction” and could
simply be reused. It is not: the packaged macro always INNER JOINs the cohort and
ab_apply_exposure_filter=Falsedrops exactly one predicate — correct for CUPED (a covariate is per enrolled unit) and useless before launch, where that join returns zero rows. So the render is a new capability:ab_apply_cohort_join(default true) swaps the cohort for a one-row synthetic relation, which keeps the_abk_exposuresalias — and thereforeab.variant_col(),ab.stratum_col(), the loader’s macro-usage lint and an unchanged user metric SQL — working, whileGROUP BY variantstill groups by a relation’s column instead of a bare constant (PostgreSQL rejects the latter). Every default render is byte-identical to before. - Precedence is explicit
--baseline>--from-history> persisted rows, and whichever wins names itself on the line (baseline mean=… (history 14d @ …)). A planning number whose origin is invisible is the thing to avoid. - Two disclosures ride the line:
ncounts everyone the metric SQL yields, not the units this experiment will enroll; and whenassignment.added_filtersnarrows the real cohort, a population read cannot apply it, so the variance is indicative rather than this experiment’s own. - A render failure skips that comparison with the reason on its
SKIPPEDline instead of failing the command; the interval must be a positive whole number of days (the graincovariate_lookbackuses and the pre-period window is aligned to).
- The read is POPULATION-wide, and the plan line says so. The design contract
assumed the CUPED pre-period render was “cohort-free by construction” and could
simply be reused. It is not: the packaged macro always INNER JOINs the cohort and
abk plancould crash on a large standardized effect (numpy ≥ 2). statsmodels falls back tofsolvewhen brentq cannot bracket a solve and returns a shape-(1,)ndarray;_as_scalarwas introduced for the MDE path, butget_ttest_sample_size,get_fraction_sample_sizeandget_ttest_powerkept a rawfloat(...). numpy deprecated that in 1.25 and newer numpy raises outright, so an ordinary--baseline arpu:mean=999,std=1,n=42failed the whole experiment’s plan with “only 0-dimensional arrays can be converted to Python scalars” — while an older-numpy machine only warned. All three now extract through_as_scalar. Value-preserving: the same number the working path already returned, golden tests unmoved, noALGORITHM_VERSIONbump.
[0.6.1] - 2026-08-01
Section titled “[0.6.1] - 2026-08-01”Changed
Section titled “Changed”- PLAN-1 —
abk plansizes CUPED on the covariate correlation it already has. Acuped-t-testcomparison was sized on the raw persisted variance and flagged “ρ not persisted”; M9 WP1 made that false in0.4.0, which persistscorr_coef_1/2on every_ab_resultsrow, whilestats/power.pyhas shippedcuped_adjusted_std+get_cuped_ttest_{sample_size,mde,power}since M1. The planner now routes those three solves through the same deflationvalidate/scoring.pyuses, so required-N for a CUPED comparison drops to(1 − ρ²)×its previous value — a planner-output change, deliberate and documented (no_ab_resultsnumber moves, noALGORITHM_VERSIONbump;abk planremains read-only, takes no lock and writes nothing).- The ASN and the runtime consume the same deflated variance. A deflated required-N beside an ASN simulated on the raw variance is one line contradicting itself — an expected stopping size sitting above a requirement computed for a lower-variance estimator, with nothing saying why.
- Every line names the variance it was sized on, because a required-N whose
basis is invisible is the number an operator plans a launch around. Three cases
keep the raw bound, each with its own note: no ρ on the row (written before
0.4.0— still a conservative upper bound), ρ = 0 (a measurement: this covariate reduces nothing, not a missing value), and a ρ that leaves no usable residual variance. - A near-perfect correlation is refused, not obeyed.
1 − ρ² < 1e-12means the deflation factor is decided by rounding noise in a Float64 ρ rather than by the data — and it is the shape a leaking covariate takes. The projectabk initscaffolds is exactly that case (its synthetic pre-period value is collinear with the metric, so ρ rounds to 1); an ungated deflation printed “required 10/arm” for a series whose raw requirement is five orders of magnitude larger. A naive|ρ| ≥ 1gate does not catch it — the persisted value is a hair below 1. - A computable-but-implausible reduction is disclosed, not hidden. Above the
degeneracy floor a ρ can still imply a >100× drop in required-N (
1 − ρ² < 0.01); that is the measurement, so it is used — and the line says “check that the covariate is not derived from the metric”, because this is the number someone sizes a launch on. --baseline <metric>:mean=..,std=..,n=..,corr=0.6supplies ρ for an experiment that has never run. It is validated at the door (|ρ| < 1and a usable residual variance) and refused on a comparison whose method applies no covariate — deflating a plaint-testwould promise a variance reduction the analysis will never perform.
[0.6.0] - 2026-08-01
Section titled “[0.6.0] - 2026-08-01”M11 — abk dashboard, the project-level cockpit (the implementation record
is
docs/specs/m11-implementation-plan.md).
One row per experiment over the whole selection: headline verdict, effect + CI,
p/α, a sparkline of the cumulative series, and buttons that spawn real abk
subprocesses (Run — whole experiment or one metric — Unlock, Clean, Explore,
Open report) and stream their logs. The server is
a launcher — it computes no statistic, writes no config and never takes the
pipeline lock; every verdict on the page is readout.evaluate()’s, the same one
abk run --report bakes. No statistical numbers changed anywhere in this
release: no ALGORITHM_VERSION bump, no statistics-changes.md entry, the
golden tests untouched at rel-1e-9.
-
M11 DASH-7 — the dashboard exit gate: a real session, end to end.
tests/e2e/test_dashboard_session.pydrives the serverabk dashboardbuilds over live HTTP — token-gated on every route, a boot payload asserted metadata-only field by field, three distinct row states in one list (computed / warehouse-raises / never-computed) proving row isolation, the on-demand report route, a realabksubprocess spawned through/api/runand polled to a terminal status on absolute offsets, the one-at-a-time refusal against a long-lived occupant,/api/job/<id>/stop, and a whole-session spy proving no pipeline lock is ever taken. No statistical numbers changed.The gate found two real defects in already-merged code, both fixed here:
- A never-computed experiment claimed a verdict. An experiment with no rows
of its own — in a project where others had run — reached
evaluate()over zero rows and rendered INCONCLUSIVE. That is a verdict about data: on a row nobody computed it says “we looked and could not tell” where “nothing has run yet — press Run” is the truth, and it made the dashboard’s third row state unreachable per experiment. It now reads the same “no data” state a never-run project does; the lock probe still runs, since a crashed first run leaves a lock on an experiment with no rows at all. - The “abkit is not installed” warning was suppressed by stale packaging
metadata. A checkout whose install was removed keeps its dist-info, so
the probe reported “installed” while every spawned job died with
ModuleNotFoundError— exactly the case the warning exists for. It now asks the child’s own question through both import mechanisms (asys.pathsearch with the CWD dropped, then thesys.meta_pathfinders that answer for a strict editable install) and ignores metadata entirely.
Also hardened: the payload point caps were only ever tested under a monkeypatched small cap; a 50 000-look series now exercises the shipped constants, so the per-row reply stays bounded however long an experiment peeks.
- A never-computed experiment claimed a verdict. An experiment with no rows
of its own — in a project where others had run — reached
-
M11 DASH-6 —
abk dashboard: the project-level cockpit is a command. The DASH-1..5 server, rows and client get their entry point:abk dashboard [--select <sel>]... [--exclude <sel>]... [--window 24h|7d|30d|90d|all] [--no-open] [--profile NAME]. No statistical numbers changed (noALGORITHM_VERSIONbump; this WP adds a launcher, a CI gate and documentation — the compute path is untouched).- The whole selection, not one experiment.
--select/--excluderesolve exactly as they do onabk run(all experiments when omitted); the one-match restriction isabk explore’s alone.--windowsets the INITIAL sparkline window and is validated at startup, before a port is bound, where the operator typed it — the page switches it afterwards without a restart, and every verdict is always the full cumulative series’ either way. - Three deliberate divergences from
abk explore’s shell, each following from the dashboard being a launcher rather than a reader: a project that has never run serves (explore no-ops — here that is the first case, every row reads “no data — press Run”, and nothing creates internal schema); there is no startup orphan scan (onelist_method_config_idsper row would put N warehouse queries in front of a metadata-only boot); and an empty selection warns and exits 0 without serving, theabk run/abk validateidiom. - One startup warning, so N jobs cannot fail identically. Every button
spawns
python -cwith''and the project root dropped fromsys.path(deliberately — aclick.pyin the operator’s project must not shadow the real one), so an abkit that was never installed makes every job die with the sameModuleNotFoundErrorinside its own log drawer.abk dashboardnow says so once, before the page opens, and only when BOTH probes agree — installed-distribution metadata alone false-alarms on aPYTHONPATHinstall, and asys.pathprobe alone false-alarms on a strict editable install. It warns, never refuses: the read-only rows work either way. - The Open button’s two server fields are wired here, as DASH-5 left them:
the project’s
MetricConfigs become the on-demand report’s metric descriptions, and the raw manager (the same oneInternalTablesManagerwraps, used under the server’sdb_lock) lets the no-copy default snapshot the live cohort for the SRM chip’s observed counts. Without them the page still renders — with no descriptions and a silent “0 / 0” beside a green chip, which reads as a broken cohort. dashboard.jsis now asserted in the wheel. The CI packaging-DoD job’s bundle namelist is a hardcoded tuple (a source-tree glob would pass on a wheel that shipped none of them);abkit/tuning/assets/dashboard.jsjoinsreport.js/explore.jsthere and in the release-readiness e2e’s self-contained (zero external host) check. The marker-grep, hex-containment and freshness gates needed no edit — verified by re-running all three locally against the new file rather than assumed.- The pending-bundle degradation is gone.
render_dashboard_htmlused to fall back to a “runcd web && npm run build” note, because between DASH-3 and DASH-5 the bundle could not exist. Now that it is committed AND wheel-asserted — the exact conditionexplore.js’s undegrading read was always keyed to — both go through one reader that RAISES: a missing bundle is a packaging bug, and a page telling apip installuser to run a build step blames them for something they cannot fix. - Documented in docs/guides/dashboard.md (what it
is, the row anatomy, the buttons, the job discipline, and the URL-is-a-
credential warning) + the CLI reference, with the packaged
init-claudeoperator rules and the docs site nav in the same PR.
- The whole selection, not one experiment.
-
M11 DASH-5 —
dashboard.js: the dashboard’s client bundle (the third committed renderer).web/src/dashboard/dashboard.tsrenders the baked boot payload as one row per experiment — name, tags, verdict, effect/CI, p/α, elapsed, a canvas sparkline and the Open / Explore / Run buttons — and fills the statistics lazily. Authored fresh against the donor’s patterns (it ships no TypeScript for its cockpit) over abkit’s own primitives: the sparkline goes through the sameshared/chart.tsscale/line code the report and explore charts use, and every color resolves through the one brand-token layer, so no new hex enters the CI containment scan.- The boot render touches the network zero times. The metadata-only list
paints first with every verdict cell
pending; a fixed-concurrency-3 worker pool (the donor’sVn=3, JS-only — there is no server pool and no cache) then pulls names off one shared queue throughGET /api/stats/<experiment>. A rejected fetch or arow.errorfield paints THAT row’s error cell and the pool keeps going, so one unreadable experiment costs one row. A window change or Refresh supersedes the fill in flight by epoch and aborts it, so a reply that lost the race can never paint over a newer one. - The §4 peeking-honesty markers are per row now, on the verdict chip and
a one-line note under it (not hidden in the expand):
abk-srm-failfor a failed gate,abk-insufficientwhen the headline look was demoted, andabk-prehorizonwhen a fixed-CI verdict is withheld before the horizon — a verdict decided early under an always-valid sequence is deliberately NOT marked.verdict: nullreads as “no data — press Run” or as an error depending onerror, which is the only thing that tells the two apart. _ab_results.insufficient_datareaches the row as a newinsufficientfield on the DASH-2 shape: the chip needed the state and the alternative was matching English prose in the rationale. It is the HEADLINE look’s own persisted cell, read through the readout’s own_flag(the report’s looserbool(value)would disagree on a"0"string cell), so no display window can move it and the chip cannot contradict the rationale beside it.GET /experiment/<name>(new route) renders the report the Open button opens — the samebuild_report_payload→render_report_htmlpairabk run --reportwrites, on demand for one experiment, never read off disk (areports/file exists only if someone passed--report, and would be as old as that run). A cohort source that cannot be read costs the SRM chip’s observed counts and names itself in the page’s warnings rather than denying the page; the DB half runs underdb_lock, the bake outside it. Both HTML routes now sendCache-Control: no-storefor the reason the JSON ones already did: a report reopened after a Run must not be the pre-run render.- The expanded row carries the readout’s own rationale, caveats, warnings and
per-arm-pair verdicts, one Run per configured comparison (a secondary
metric never appears in
verdictsand still needs its own recompute — that is what DASH-4a’s--metricis for), Unlock, a confirmed Clean (it spawns the--executeform), and the read-only YAML. A repaint refreshes only the readout half: rebuilding the whole panel would collapse an open YAML pane, dismiss a confirm box mid-read, and drop an in-flight source reply into a detached node. - The job chip reads the server’s own
pipeline_activeflag (thekind ∈ PIPELINE_KINDS ∧ runningrule is never re-derived in JS) and names running jobs from their own status field; a click opens a log drawer that polls/api/job/<id>?offset=on the server’s absolute offsets, so a job chattier than the 5000-line buffer keeps streaming and discarded output is disclosed instead of leaving a hole. A job that finishes re-reads its OWN row — never the list.
- The boot render touches the network zero times. The metadata-only list
paints first with every verdict cell
-
M11 DASH-4 — the dashboard’s job routes: every button is a real
abksubprocess.POST /api/run(optionally onemetric, DASH-4a),/api/unlock,/api/clean,/api/exploreand/api/job/<id>/stop, plusGET /api/experiment-source/<name>for the read-only “open in your editor” affordance. The dashboard stays a launcher: no route computes a statistic, reads a knob, writes a config or takes the pipeline lock — the spawned child does all of that in its own process, exactly as if the command had been typed (pinned by a spy over every job route, not just the module-level AST gate). Run/unlock/clean are one at a time (the atomicspawn_pipelinegate → the donor’s400 a pipeline job is already running); explore is not gated but deduped per experiment, so a second click reopens the live cockpit’s tab.--selectis the experiment’s YAML path, and the route proves it resolves before spawning — a name alone is not safe to spawn.select_configsresolves a bare name by tryingexperiments/<name>.ymlbefore searching thename:fields, so a file named after another experiment shadows it and the cockpit would have run, unlocked, cleaned or explored something other than the row that was clicked, with nothing anywhere saying so. A path resolves to exactly one file (and satisfiesabk explore’s “exactly ONE” by construction), with*,?and[in a file name escaped rather than abandoned —experiments/star*.ymlleft raw would also match a sibling. Every job route then re-resolves that selector throughselect_experiments, the child’s own resolver, and answers 400 unless it lands on exactly the clicked experiment: the served selection is read once at boot, andabk run/unlock/cleanmeet an unmatched selector with a warning, “Nothing selected.” and exit 0 — so a renamed or deleted YAML would otherwise show a green, successful Run in the drawer that computed nothing.- The spawned command runs this interpreter’s abkit with the project
directory NOT importable. A bare
abkoffPATHwould be a different abkit than the one serving the page in an unactivated venv — but pinningsys.executable -m abkit.cli.mainhas its own defect:-mputs the child’s CWD onsys.path[0], and a job spawns in the operator’s project root. A file there named after anything abkit imports (click.py,yaml.py,statistics.py, …) then breaks every button with a traceback nobody can connect to the click, and anabkit/directory there runs a different abkit than the one just pinned — neither of which happens when you typeabkin a terminal. The child is therefore started through a bootstrap that drops the CWD fromsys.pathbefore the first import and names itselfabkin usage errors. Every spawned job consequently needs an installed abkit (pip install -e .or a wheel); a bare source checkout fails each job with aModuleNotFoundErrorin its own output. Every flag the argv builders pass is test-checked against the click command’s own declared options, so a renamed option fails a test rather than surfacing as an exit-2 job in the drawer./api/cleanspawns the--executeform (a dry run would be a button that does nothing) and never the prompting--orphaned-experiments; the job label is derived from the argv that actually ran, so the drawer shows--executeinstead of hiding it. - The explore URL scrape requires the scheme.
abk exploreprintsExplore: <experiment name>before the server printsExplore: <url>, so a literal port of the donor’s"Tuner:" in linepredicate matched the header and handed the client an experiment name as a URL. One function is both the wait predicate and the extractor. POST /api/exploreis a long request by design — it holds the response until the cockpit prints its URL (90 s cap), so a client needs a long fetch timeout there and nowhere else. A repeat click on a starting cockpit waits on the same job but only briefly (10 s), because every waiter holds a request thread; when no URL arrives the 400 distinguishes “exited without serving” from “our deadline lapsed” from “another tab’s cockpit is still starting”, and only a job that request spawned is stopped.- An unknown body field is a 400, not a silent drop. A client posting
{"full_refresh": true}to/api/runwould otherwise get a plain run and no hint that the flag went nowhere. Same discipline as?window=on the GET side: a blankmetricis refused. Anullvalue is the one exemption — it asks for nothing, so a helper that always emits{select, metric}and spells “the whole experiment” asmetric: nullworks on the routes that take no metric too. - Status codes extend DASH-3’s map positionally: a bad body is a 400 (an
unserved experiment, a selector that no longer resolves, an undeclared
metric, malformed JSON — including a body nested deeply enough to raise
RecursionError, which is aRuntimeErrorand would otherwise read as a server defect — and a bodyless POST), a bad path stays a 404 (unknown job id, unknown experiment source), a registry teardown racing a route is a 503 (Nonemeans “busy”, which a teardown is not), a job that is no longer running is a 400 while an unknown id is a 404 (the donor conflates both), and a spawn that cannot start at all is a 500 naming the project root it tried to run in.
-
abkit.tuning.jobs:spawn_deduped()+url_for(), and children no longer inherit the cockpit’s stdin. The per-experiment dedup is now atomic under the same gate the pipeline gate uses —running_job_for()followed byspawn()is check-then-act across request threads, and a double-clicked Explore button would start two sessions on one experiment, each writing the whole YAML from its own startup snapshot on Apply.stdin=DEVNULLbecause a child that ever prompts (onlyabk clean --orphaned-experimentstoday, which no route spawns) would silently consume the terminal the dashboard was launched from; on a closed stdin it aborts loudly instead, with the reason in its own output. -
M11 DASH-4a —
abk run --metric <m>: recompute ONE metric of an experiment.runwas the last per-comparison command without the metric axis (explore,validate,planandverify-incrementalall had it), and the dashboard is a launcher — it can only offer what the CLI can do, so the per-metric Run button DASH-4/DASH-5 wire needed this first. It filters comparisons by metric name — the grainabk validate --metricalready uses: inside one experiment that is exactly one comparison (a metric binds at most once per experiment), and across a broader--selectevery experiment declaring that metric. No statistical number moves (M7–M12 posture; noALGORITHM_VERSIONbump).- The alphas do not move — the milestone’s #1 assertion for this WP.
effective_alphas()derives the two-tier Bonferroni scheme from the config’s comparison list, never from what a given run happens to compute, so the filter is alpha-invariant by construction. Pinned: a filtered run’s rows are byte-identical (alphaincluded) to the same rows written by an unfiltered run, on a fixture where a leak would halve the divisor. --full-refresh --metric <m>is the real “reprocess this metric” after a SQL or method-param edit:delete_resultsis already metric-keyed, so the filtered loop deletes and rebuilds only that series and leaves every other series’ results untouched (not merely equal: never re-written).- Day state is the one thing a narrowed run may still touch elsewhere. A
stale-but-contiguous
_ab_unit_stateday is invisible to the M9 reader’s gap check, which only detects ABSENCE — so a scoped--full-refreshtruncates the withheld metrics’ day state (from the first day the window touches through the end of that series, the same tail semantics an unfiltered refresh has) rather than leaving it: not re-rendered (the operator scoped that cost away), contiguity preserved as a shorter prefix, reads past it falling back to full-window recompute, and the next run that includes the metric re-deriving those days from the current facts. Leaving it alone was a reviewed defect — withcompute.incremental_readson, healing a fact backfill for one metric only made a later routine run sum the stale day and silently persist an undercount (reproduced at 3334.5 vs 3434.5 before the fix; regression-pinned end to end). - The run says which of those outcomes applies, per experiment — rebuilt
(copy-mode
--resync-cohort), truncated (a refresh window), untouched, or “thestatestep is not selected, so day state is not touched at all” — and names the experiments when one selection mixes them. Both day-state effects live inside the STATE stage, so both follow--steps. - The cohort load and the SRM gate stay experiment-level (the gate must
still block), and so does
--resync-cohortin copy mode: it rebuilds the whole cohort and therefore re-materializes day state for every eligible metric even under--metric, because narrowing that would leave the other metrics’ day state derived from the copy the resync just declared poisoned — and stale-in-place state is exactly what the M9 gap check cannot detect. Only compute narrows, and the run prints a line saying so. In the direct (no-copy) default the flag is a no-op and day state narrows with everything else, so that line is not printed — the disclosure is mode-aware, not unconditional. - Selector semantics follow the repo idiom: an experiment in the selection
that does not declare the metric is skipped with a printed line; a value
matching no comparison in any selected experiment is a loud error
naming what is declared, never a silent exit-0.
--steps validatewith--metricis rejected (the config lint is project-wide by construction).
- The alphas do not move — the milestone’s #1 assertion for this WP.
-
M11 DASH-3 — the dashboard’s localhost server (
abkit/tuning/dashboard_server.py). The transport that joins DASH-1’s job registry to DASH-2’s row shaper: a stdlibThreadingHTTPServeron127.0.0.1with a one-shot token, serving the metadata-only boot page (GET /) plus one lazily-fetched row per experiment (GET /api/stats/<experiment>,?window=bounding the sparkline) and the job-poll routes (GET /api/jobs,GET /api/job/<id>?offset=). No command reaches it yet —abk dashboardlands in DASH-6, and the client bundle in DASH-5, so until then the page renders an honest “the dashboard client bundle is not installed” note — and no statistical number moves: every verdict is DASH-2’s, i.e.readout.evaluate()’s.- The dashboard is a launcher, never a worker. No route acquires the
pipeline lock, runs a pipeline step or computes a statistic; the only
mutation any button can cause is a real
abksubprocess (DASH-4). The oneInternalTablesManageris serialized by adb_lock, because a DB-API connection is not thread-safe. - The token gates every request, GET included — unlike
abk explore’s server, which serves its page to an unauthenticated GET and gates only the POSTs. HereGET /enumerates every experiment in the project andGET /api/stats/…reads the warehouse. Authorization runs before routing, so a 403 is not an oracle for which paths exist, and the token is compared as bytes (compare_digestrefuses non-ASCIIstr) so?token=αis answered rather than dropped. - The server never shuts itself down.
abk explore’s Apply is terminal and stops its server; the dashboard has no terminal action and serves until Ctrl-C, at which point it terminates every job it spawned — a source-level test gate keeps the pattern from being copy-pasted back in. - A row that fails to build is still a 200 carrying
error(DASH-2’s isolation contract), an unknown experiment a 404, and an unknown window preset a 400. A blank?window=/?offset=is a 400 too, rather than silently reading as “absent”, and every JSON reply carriesCache-Control: no-store— these are GETs at URLs that repeat between polls, and a heuristically cached row would show a verdict from before the run the operator just launched.
- The dashboard is a launcher, never a worker. No route acquires the
pipeline lock, runs a pipeline step or computes a statistic; the only
mutation any button can cause is a real
-
M11 DASH-2 — the dashboard’s row shaper (
abkit/tuning/overview.py). One row per experiment (the maintainer’s 2026-07-27 grain decision) off the persisted_ab_results: latest verdict, effect/CI/p-value/alpha, the SRM gate, and a sparkline bucketed to at most 160 points, plus the metadata-only boot listGET /will bake. No route or command reaches it yet — it is exported fromabkit.tuningand consumed only by its own tests until the dashboard server lands (DASH-3) — and no statistical number moves: every verdict isabkit.pipeline.readout.evaluate()’s, the same functionabk run --reportcalls, and a test now pins the row’s headline cells againstbuild_report_payload’s so the two surfaces cannot drift. Worth knowing about the shape it settled on:- The window preset (
24h/7d/30d/90d/all) is the sparkline’s x-range and nothing else._ab_resultsrows are cumulative looks from a pinned start, so filtering them byend_tsbefore the readout does not produce a shorter experiment — it produces a truncated stabilization history. Measured: it turned a 14-look daily WIN into INCONCLUSIVE at24h(i.e. every daily experiment), and inverted a 6h-cadence INCONCLUSIVE into a WIN. Verdict cells are always the full series’. - Rows for an arm pair the config no longer declares are dropped before
the readout (the report already filters this way). They do not reach
evaluate’s series lookup, but they do join the read-time Benjamini-Hochberg family and tighten every threshold — nine renamed-away pairs were enough to turn the report’s WIN into the dashboard’s INCONCLUSIVE on identical rows. - The SRM flag is read window-independently (through the report’s own
srm_summaryover all persisted rows), so a preset can never silence a red assignment gate; the sparkline is filtered to the comparison’s currentmethod_config_id, so an orphaned series left by an edited identity param cannot interleave a second generation’s points into one curve; the 20 000-look cap is display-only, like the window. - A verdict the readout qualified never renders as an unqualified one:
under
guardrail_policy: warna WIN is kept with a mandatory loud caveat, so the row carriesrationale,caveatsandguardrail_regressed— each listed pair carries its own two, and the row-level flag is ORed across all of them, because a regression on the second arm must not leave a green flag on the row that lists it. The readout’s ownwarnings(renamed arms, orphaned series — the two statesabk cleanexists for) ride along inwarnings, so a renamed arm no longer looks exactly like a never-run experiment. - The row’s per-pair list is named
verdicts, matchingExperimentReadout.verdicts;comparisonsstays the boot entry’s CONFIGURED list. The dashboard client merges the two payloads by experiment name, and one key holding two incompatible shapes is a trap. - Both surfaces carry the experiment’s
timezone: every instant on them is naive UTC, and since0.5.0“the calendar day a look covers” is a timezone-sensitive contract. projectis a required argument, not an optional one: without it the readout falls back to stored-alpha CI significance and mis-scores a project-levelbenjamini_hochberg— loudly in its own warnings, but a glanceable row is the wrong place to discover that.lockedprobes therunlock only (the out-of-bandvalidateclaim does not blockabk run), and the probe is isolated in both directions: it runs in afinallyso a failed read cannot report “unlocked” for the degraded row an operator is most likely to press Run on, and it swallows its own failure so an unreadable_ab_taskscannot blank a verdict and an SRM chip that_ab_resultscould answer perfectly well.- An unknown window preset raises
UnknownWindowPreset(aValueErrorsubclass), so a route can answer 400 for it and 500 for anything else — every other failure is swallowed intorow["error"]. - One bad experiment degrades to a full-shape row carrying an
errorstring (with the fields filled before the failure kept) instead of sinking the list. A secondary or guardrail metric never appears in a row’s per-comparison verdict list —evaluate()only crosses main comparisons with treatment arms — but it does appear in the boot entry’s configured-comparison list, so the per-metric Run button DASH-4a/DASH-5 add still has something to bind to.
- The window preset (
-
M11 DASH-1 — the dashboard’s subprocess registry (
abkit/tuning/jobs.py). Groundwork forabk dashboard(M11,0.6.0): an in-memory registry that spawns the realabkCLI as a subprocess and pumps its merged output into a pollable line buffer. No route or command reaches it yet — it is exported fromabkit.tuningand consumed only by its own tests until the dashboard server lands (DASH-3/DASH-4), and no statistical number moves (it reads no results and computes nothing). Deliberate deviations from the donor port it is based on: the job-kind vocabulary is abkit’s (run/unlock/clean/explore, withexploreoutside the one-at-a-time gate but deduped per experiment) and both spawn entry points validate against it rather than accepting any string; the dedup key is a purpose-builtJob.experimentfield;wait_for_linecounts absolute line indices, so a job chattier than the 5000-line buffer cap before it prints the awaited line is still matched instead of failing at its timeout; a spawn racingshutdown()now refuses (JobManagerClosed) and kills and reaps the child it just created, instead of leaving a subprocess the teardown’s registry snapshot will never touch; the job status vocabulary is honest on both termination paths (a clean exit reportsdoneeven if a Stop was requested — “stopped” was otherwise reported for runs that had already succeeded — and a job the teardown kills reportsstoppedrather than being indistinguishable from a crash); and a poll reply carriesdropped/truncated, so a log view can say that earlier output was discarded instead of inferring it from a hole in the offsets.
[0.5.0] - 2026-07-26
Section titled “[0.5.0] - 2026-07-26”Changed
Section titled “Changed”-
BREAKING — M10 WP1: an experiment’s window is a pair of timestamps, and the config keys say so.
start_date→start_ts,end_date→horizon_ts, and both now accept a bare date or a full timestamp (2024-07-01 14:30:00). There are no deprecated aliases: a config carrying an old key fails validation with a message naming the new one. Two things change with the rename, both deliberate:- A bare date is local midnight of THAT day, for both edges, so
horizon_tsis the EXCLUSIVE right edge and the config value equalsgrid.horizon_tsexactly — one vocabulary, no+1 daytranslation anywhere. Portend_date: 2024-07-14ashorizon_ts: 2024-07-15; the rename error spells this out. An unchanged window keeps every persisted_ab_resultsnumber identical (see below). - A sub-day start is no longer silently floored to midnight. The old
planner ran every edge through
datetime.combine(..., time.min), which accepts adatetimeand drops its time — so the “grid steps are sub-day but its anchors are not” contradiction was a silent truncation, not a limitation. Rejected loudly rather than coerced: an unquotedstart_ts: 20240101(pydantic would read it as a UNIX timestamp — 1970-08-23) and a UTC offset (the experiment’stimezoneis the only source of truth).
- A bare date is local midnight of THAT day, for both edges, so
-
BREAKING —
_ab_experimentswindow columns renamed and widened:start_date/end_dateDate→start_ts/horizon_tsDateTime64(3), holding the resolved window in naive UTC — the same frame as_ab_results.start_ts, so a BI join lines up instead of differing by the timezone offset. A newinterval_anchorStringcolumn records the knob below.ensure_tables()is create-if-not-exists-only andensure_columns()is ADD-only, so a type change is not auto-migrated — see the combined recreate step below, which covers this table and_ab_resultstogether. -
BREAKING — M10 WP3:
_ab_results.start_date/end_dateare removed. The window is a pair of instants (start_ts/end_ts) and nothing read the two derivedDatecolumns — not the pipeline, the readout, explore, the HTML report, or the shipped Grafana/SQL BI examples. They were also degenerate at sub-day cadence — first-class since M2, and shipped in the same M2 bullet as these columns — where every look on one day collapses onto oneend_date. What a BI query does instead: group and order byend_ts, which is exact at every cadence. If you genuinely want the calendar day a look covers, derive it — and keep both corrections, because dropping either moves the day:end_tsis the EXCLUSIVE edge (a daily cutoff carries the next day’s midnight) and it is stored in UTC (not the experiment’s timezone):-- ClickHousetoDate(end_ts - toIntervalMicrosecond(1), '<experiment timezone>')docs/reference/internal-tables.mdcarries the PostgreSQL and MySQL forms and worked examples;tests/pipeline/test_pipeline.py::TestTimezoneDatesexecutes the recipe against a real Moscow run and pins that it reproduces the dropped columns to the day. NoALGORITHM_VERSIONbump and nostatistics-changes.mdentry — schema-only, zero numeric change. -
Upgrading: recreate both internal tables, once. This release is where both breaking schema changes of the 0.2.0→0.12.0 polish track land deliberately, so there is one recreate step rather than a note per milestone.
ensure_tables()only ever creates what is missing andensure_columns()only ever ADDs, so neither change is auto-migrated. Before the firstabk runon0.5.0:DROP TABLE <internal_schema>._ab_experiments; -- renamed + retyped (WP1)ALTER TABLE <internal_schema>._ab_results DROP COLUMN start_date;ALTER TABLE <internal_schema>._ab_results DROP COLUMN end_date;(Or drop
_ab_resultstoo and re-run — it rebuilds from the warehouse, at the cost of recomputing the series.) Do not skip the_ab_resultsstep on ClickHouse. PostgreSQL and MySQL declare those columnsDATE NOT NULL, so an insert that omits them errors and you cannot miss it (MySQL under any strictsql_mode— the default since 5.7; abkit does not set it); ClickHouse instead fills an omitted column with its type default, so a stale table keeps accepting writes and silently stamps1970-01-01into both columns — a dashboard still grouping byend_datewould collapse every look onto one day rather than error._ab_experimentsis informational (the pipeline never reads it back for a decision); nothing is lost but the catalog row, which the next run rewrites. -
Materialized day state re-keys once.
_ab_unit_state’s series identity folds in the window fields by name, so this rename orphans every existing series; the nextabk runre-materializes it andabk cleansweeps the stale ids. No statistical number moves —abk verify-incrementalstays green across the re-materialization. -
M10 WP4:
abk exploreanswers knob turns while a Reload or Auto-validate is running. One coarse lock used to serialize all four POST endpoints, so an alpha slider drag queued behind a warehouse/reloador a several-hundred-split Auto/validate— the cheapest possible request waiting on the most expensive one. The lock is now scoped to what actually needs it (heavy_lock:/reload,/validate,/apply— own DB managers, the out-of-band_ab_taskslock, the YAML archive/rewrite seam), and/recomputeruns concurrently against a session whose Tier-S cache carries its own fine-grained lock. Three consequences worth knowing:- the accepted trade is that two recomputes racing may both run —
wasted CPU and, for bootstrap knobs, one resample block of memory each;
never a wrong number for the inputs each used (every input is immutable or
read under the cache lock). One caveat, reproduced by the exit-gate review:
a
/reloadinstalling cutoffs under a running pass makes one reply mix two renders of the same series, all points still labelledexact— the reply is not yet the unit of consistency, and making it one is a named follow-up; - staleness is re-checked after the compute, not just before it. A
request can now be superseded while it computes; it replies
409 {stale: true}rather than overwriting the fresher answer already in the rail. The two-tabrequest_idmachinery is otherwise untouched; - superseded work is now cancelled rather than queued. The old lock did double duty — it also dropped every queued request a newer knob turn had outranked, so a slider drag cost one compute. Without that, a 6-turn drag ran six full computes and the answer the user waits for took 3.40 s instead of 0.80 s, at 8.7× the CPU. The engine therefore polls the same staleness predicate between points and abandons a superseded recompute within one point (1.04 s / 1.14 CPU-s on the same drag) — the queue is gone, the cancellation is not. Polling alone cannot help a series with a single cutoff (a young experiment, a weekly cadence) and does not bound how many resample blocks exist at once, so a small admission semaphore (2 slots) fronts the compute and drops a request superseded while it waits before it computes anything.
- the accepted trade is that two recomputes racing may both run —
wasted CPU and, for bootstrap knobs, one resample block of memory each;
never a wrong number for the inputs each used (every input is immutable or
read under the cache lock). One caveat, reproduced by the exit-gate review:
a
-
M10 WP5: dragging the alpha slider over a bootstrap series stops re-drawing the replicates. A Tier-S bootstrap point is a resample (
n_samplesdraws — effectively the whole cost) followed by a percentile CI and a p-value at one alpha (microseconds). Only the second half depends on alpha, soabk explorenow memoizes the first: the draw happens once per (metric, arm pair, cutoff, cache generation, method, resolved params) and every later alpha reuses it. Measured over 4 000 units × 10 000 replicates × 4 cutoffs, a six-turn drag went 6.01 s → 1.01 s — the first answer is unchanged, each turn after it 1.00 s → 0.002 s. Bounded by a value-counted budget (≈16 MB of replicates) with oldest-first eviction, and dropped for a cutoff whenever/reloadre-renders it. The budget charges each slot a fixed overhead beyond its replicates (a value-only cap bounds the payload, not the number of slots), and when a knob state’s series does not fit — where an oldest-first policy degrades to no reuse at all — the reply says so instead of silently losing the speedup. The numbers do not move: the memoized outcome enters the same finalize step it would have, which is pinned per method class (from_samples==_resample+_finalize, bit for bit) and per engine path (five alphas against the same engine with the memo disabled). NoALGORITHM_VERSIONbump and nostatistics-changes.mdentry.
- M10 WP1 —
interval_anchor: where the cutoff lattice sits.cadencesays how far apart the cutoffs are; the new knob says where they land. Cutoffs areanchor + k·cadence, kept strictly afterstart_ts, with three forms:midnight(the default when the key is absent — local midnight of the day the window opens, i.e. whole calendar days),start(count from the start instant: a 14:00 start ⇒ 14:00 cutoffs), or an explicit timestamp to align to an external cycle. The anchor may precedestart_ts— the forward snap is what makes that well-defined, and the first window is then legitimately partial (config-lint says so as a note, never an error). Theabk initscaffold writes the default out explicitly with the alternatives in a comment. Day-or-coarser steps hold the anchor’s local wall-clock time across DST; sub-day steps stay absolute-duration. ExperimentConfig.grid()— the one factory composing window + cadence + anchor, mirroring m8’sbuild_cohort_backendcontract. Nothing underabkit/may callgenerate_griddirectly; an AST gate (tests/core/test_grid_factory_is_the_only_entry.py) enforces it, because the alternative had already happened: the new knob reached none of the eight hand-copied call sites.- Plugin API (M10 WP5):
supports_resample_memo+ the_resample/_finalizesplit. A method that can separate its costly alpha-free work from its cheap alpha-dependent finish may declare the capability and implement_resample() -> ResampleOutcome; the base bootstrap class then composesfrom_samplesfor it and the explore engine memoizes the first half. Opt-in exactly likesupports_vectorized(M7): theFalsedefault leaves a method fully functional through the ordinarycompare_pairpath — nothing special-cases a method name. The whole bootstrap family (6 classes) declares it; a roster gate keeps declaration and implementation in step.
- Three M9 surfaces read the raw config field where they meant “the local
day the window opens”, and would have broken the moment a start carried a
time:
IncrementalBackendcompared adateagainst it (aTypeErroron every cutoff undercompute.incremental_reads) and passed it as a day key into_ab_unit_state; the STATE stage seeded its day loop from it, carrying adatetimeinto aDatecolumn and into comparisons againstget_last_state_day(). All three now ask the grid. The STATE stage additionally clamps the opening day’s render window togrid.start_ts, so a sub-day start cannot sum pre-experiment facts into day state. - The CUPED pre-period stays whole-day under a sub-day start
(
[midnight(D − lookback), midnight(D))), instead of gaining a partial trailing day;statistics-changes.md§5 defines the lookback in whole days. Byte-identical at a midnight start, i.e. for every pre-m10 config. tz_midnight_utcnow rejects adatetimeinstead of silently dropping its time-of-day — the mechanism behind the truncations above.- Warning capture is thread-scoped (M10 WP4).
warnings.catch_warningssaves and restores process-global state, so overlapping scopes on two threads interleave: a guard raised by one gets recorded against the other, an “ignore” filter set by one silences the other, and — the worst shape — exits in the wrong order leave a finished thread’s recorder installed, after which every warning in the process disappears silently. Unserializing/recomputemade all three reachable inabk explore(a/recomputeconcurrent with Auto mode’s A/A scoring, which suppresses that very category), andabk runalready fanned experiments out over a thread pool, where a guard could be persisted against the wrong experiment’s rows. abkit’s three warning scopes now route throughabkit/utils/warn_scope.py: one process-global recorder installed by the outermost scope, per-thread frames, and one"always"filter per nest per category — no per-call writes to the global filter list. Warning routing only — no number moves. - A breaking-release schema refusal reached the operator as a stack trace,
not as an error line.
ensure_tables()ran outsideabk run’s failure handler, so theValueErrorthat names the drop-and-recreate remedy for a pre-0.5.0_ab_experimentsescaped uncaught: Click printed a traceback and the command’s own error line never appeared. On the one release where that message is the upgrade instruction, it was the message being buried.abk unlockhad the same hole (abk validateandabk cleanalready echoed it). horizon_seconds()is the true elapsed window length, measured between the two resolved instants, where it used to be(end − start).days + 1whole days. It now agrees with its own grid, which it used to contradict: pre-0.5.0 an October-to-November New York experiment reportedhorizon_days12.04 beside ahorizon_secondsof exactly 12 days. The change is exactly the UTC-offset difference between the window’s local edges — so ±1h across an ordinary DST transition, ±30 min in Australia/Lord_Howe, ±2h in Antarctica/Troll, ±24h across a date-line jump, and it also fires where there is no DST at all (a permanent zone shift, e.g. Moscow’s 2014 +4→+3). A window whose offset does not change is unaffected. Two consumers: the readout’s pre-horizon rationale line, and config-lint’s cadence gate — where a sub-day cadence sitting between the old and new lengths can flip accept↔reject, so a config that parsed on 0.4.0 may now be rejected (or vice versa) if a transition falls inside its window. No persisted column derives from it.
No ALGORITHM_VERSION bump and no statistics-changes.md entry anywhere in
M10: WP1/WP3 are config/planner/schema changes, WP4 is server concurrency and
warning routing, and WP5 is a structural refactor plus a cache. The numeric
gate for the window rename is that an unchanged window produces unchanged
numbers, and it is executable at two levels: every e2e byte-stability and
cross-mode parity gate in the suite still passes, with the only edits to those
tests being the renamed config keys and the ported horizon values, and
tests/e2e/test_sub_day_anchors_and_explore.py compares grids, cutoffs and
every derived number across 11 window shapes against a golden captured from
the pre-M10 code itself — byte-identical but for the horizon_seconds()
change above, which is pinned in both directions. WP4 touches no statistical
code path at all; WP5 moves bootstrap code inside abkit.stats and is pinned
by the untouched golden suite plus per-class bit-exactness of the split.
[0.4.0] - 2026-07-25
Section titled “[0.4.0] - 2026-07-25”- M9 WP6 — the milestone exit gate. The central promise is now
executable over the project
abk initships:abk runtwice withcompute.incremental_reads: trueplans nothing the second time and rewrites not one byte; a--full-refreshthrough the same path reproduces every number exactly;abk verify-incrementalis green over the whole series with zerounverifiedcutoffs (a green report that verified nothing is the failure it exists to prevent) while the non-additive scaffolded metric is skipped with a reason; day state materializes for the declared-additive metric only, one row per (unit, day);cuped-t-testserves Tier E on every knob butcovariate_lookback; and flipping the flag off reproduces every persisted_ab_resultsnumber — discrete columns exactly, continuous ones at the rel-1e-9 tolerance (partial-day sums associate differently than one full-window scan, so byte equality is the wrong assertion; the JSON payload columns are parsed before comparing because a CUPED θ legitimately differs in its last ULP). The red path is proven too: a late event backfilled into an already-materialized day (the documented WP4 limitation) makesabk verify-incrementalexit non-zero withDIVERGED, andabk run --full-refresh --from/--torestores agreement. Against a real ClickHouse the gate additionally migrates an existing pre-M9 install (_ab_resultscreated without the four covariate-moment columns) in place throughensure_columns’ realALTER TABLE … ADD COLUMNand reconciles the additive path against real SQL. Reconciliation coverage now spans all three metric kinds, daily and sub-day cadence, and multi-arm experiments.
- The state-series identity collapsed data, not just formatting (M9 WP6
round-1 review). The metric-SQL and assignment-SQL identity hashes
normalized with a quoting-unaware
" ".join(sql.split()), soWHERE campaign = 'Summer Sale'and… 'Summer Sale'— which select different rows — hashed identically: editing the literal did not orphan the materialized series, and the incremental read then summed two eras of one metric while recompute re-rendered the window under the new SQL.normalize_sql_for_identitynow collapses whitespace everywhere except inside quoted spans (string literals and quoted identifiers), which travel byte for byte; comments are scanned as spans so an apostrophe in-- don't sumcannot open a phantom literal. Reformatting still never orphans a series. (Unreleased-only: state rows written by earlier builds of this branch simply orphan and re-materialize;abk cleansweeps them.) - PostgreSQL installs silently skipped the additive schema migration when
the schema name was not lower-case.
table_exists/list_columnscompareinformation_schemastrings against the configured spelling, but schema and table names are interpolated into DDL unquoted, so PostgreSQL storesabkitinternalforinternal_schema: AbkitInternal. Every lookup missed,ensure_columnsnever ran, and the run failed later on a column mismatch. Catalog lookups now go through a_catalog_namehook — identity by default, lower-cased on PostgreSQL; MySQL keeps the configured case deliberately (it does not fold, and on Linux database names are case-sensitive). - A mid-run variant flip is no longer resolved silently. When the tail
render’s arm disagrees with the run’s cohort snapshot for the same unit, the
live arm still wins, but the incremental backend now warns once per
(metric, kind) with the count and a sample unit and points at
abk verify-incremental— the disagreement means the two read paths sampled the cohort at different instants, so their numbers may legitimately differ.
- M9 WP5 —
abk verify-incremental,abk run --cost-report, and the state GC. The reconciliation gate that makes turningcompute.incremental_readson a data-driven decision: for every already-computed cutoff of every state-eligible comparison,abk verify-incrementalloads the data through BOTH backends and diffs the results field by field at rel-1e-9 (--rel-tolto tighten), reporting per-cutoff and whole-series pass/fail and exiting non-zero on any divergence. Whole-series by design — a drift that only accumulates over many days cannot hide behind a green latest cutoff. Read-only and lock-free (it persists nothing, so it never races a run), and deliberately never part ofabk run. A fallback is not a pass: a cutoff where the incremental read fell back to recompute is reported asunverified, since both sides then ran the same code and agreeing proves nothing. Both this command and the driver build the reader through onebuild_incremental_backendfactory, so the command certifies exactly the backend the pipeline runs.abk run --cost-reportprints per-stage warehouse cost — wall-time, queries, rows returned, and rows scanned where the backend reports them (ClickHouse does; PostgreSQL/MySQL printn/arather than passing rows-returned off as a scan count). The flag is--cost-report, never--profile, which keeps its one meaning on every command.abk cleangains a state sweep:_ab_unit_stateseries that no live(experiment, metric)pair claims — a removed comparison, a renamed metric, a deleted experiment, or a comparison that stopped being state-eligible — are reported and (with--execute) dropped. It is deliberately not narrowed by--select, since state rows are keyed by(source_table, column_set_id), not by experiment. The milestone’s executable perf gate ships with it: with N units over D days the recompute path scansN·D(D+1)/2fact rows across the series while the incremental path scansN·Dand its COMPUTE stage touches the fact table not at all — asserted with exact arithmetic, not wall-clock. The concrete criteria for flipping the default are recorded incumulative-intervals.md§4.1. NoALGORITHM_VERSIONbump; no statistical number changed. - M9 WP4 —
IncrementalBackend: the opt-in additive read path. Withcompute.incremental_reads: true(project-level; experiments override via their ownincremental_reads; defaultfalseuntil the WP5verify-incrementalgate bakes), closed-form non-stratified comparisons load each cutoff from_ab_unit_state— one additiveSUM(...) GROUP BY unit_idover the closed-day moments (cached per day within a run) plus, for sub-day cutoffs, a fact scan of at most the current-day tail (cumulative-intervals §6.4) — instead of re-scanning the full cumulative window. The reshaped per-unit totals feed the SAMEMetricLoadResult→build_container→SufficientStatspath, so no new numerical code computes the arm statistic; the CUPED pre-period covariate keeps its one cached recompute-side load. Safety posture (m9 §0.2): any state gap (series absent, trailing, or truncated) falls back to full recompute for that cutoff with a per-(metric, reason) warning — never a silent undercount; a non-finite current-day tail falls back too; bootstrap/stratified/explicit-covariate comparisons always stay on recompute (the same eligibility predicate the STATE writer uses);--full-refresh/--resync-cohortwithout thestatestep disable incremental reads for that run (re-planned results must not read day state the refresh made stale). Arm split: tail units carry the tail render’s own arm; state-only units join this run’s cohort map — the free LOAD snapshot in direct mode, the persisted_ab_exposuresin copy mode — and a state unit missing from it (enrolled between LOAD and the STATE render) triggers ONE quiet re-read of the live assignment source, so a static cohort never pays a second validation query. The flag changes HOW a number is computed, never the number: flag-off behavior is untouched, and flag-on agrees with recompute at rel-1e-9 (float summation order differs between the two read paths by design — the M7 lesson; never byte-compared). Documented limitation (the m8 copy-mode precedent): an event backfilled into an already-materialized day LATER thandata_lagis frozen in day state —data_lagis the declared ingestion SLA;abk run --full-refresh --from/--tore-materializes and recomputes, and WP5’sverify-incrementalis the drift detector. NoALGORITHM_VERSIONbump; adversarially reviewed (2 rounds, 5+3 lenses with per-finding skeptic verification). - M9 WP3 — the STATE stage: per-(unit, day) moment materialization. A new
statepipeline step (betweenloadandcompute; theabk run --stepsdefault is nowvalidate,plan,load,state,compute) renders every STATE-eligible metric over each not-yet-materialized closed local day and replaces the per-unit additive moments into_ab_unit_statevia the long-tested replace-not-sum primitive — the write-only half of cumulative-intervals.md §4’s v1 strategy (the WP4IncrementalBackendreader flips the read path in a later WP; nothing reads the rows yet). Eligible: closed-form (unseeded) comparisons over non-stratified sample/fraction/ratio metrics with no explicitcolumns.covariaterole (a snapshot covariate is not additive across day renders — such metrics stay on full recompute), whose SQL does not referenceab_cov_*, and that declaresstate_additive: true— the author’s promise that every role column is a plainsum()/count()over the window, so per-day partials add up to the window total. The declaration is required because additivity cannot be read off SQL: a staging CTE, an outer re-aggregation, aUNIONbranch or an identitysum()over a renamed per-unitmax()all look additive textually. abkit still refuses projections that visibly contradict the promise (a baremax(), a constant,DISTINCT, a window function, multi-branch SQL) — a veto-only filter that can take eligibility away but never grant it — andabk verify-incrementalis the empirical oracle. The hazard is not hypothetical: the scaffoldedexample_signup_crprojectsmax(signed_up)and1 AS visits, and with the old always-on eligibility its per-day rows summed to eleven trials where the window has one. Bootstrap-only metrics never pay the write. The per-day render goes through the SAME M8build_cohort_backendfactory as every other cohort reader (never a hand-rolled_ab_exposuresjoin — both cohort modes are parity-tested). The state series identity issource_table = "{experiment}/{metric}"+column_set_id = hash(column roles + whitespace-normalized SQL body + the cohort-shaping config: assignment SQL, added_filters, unit_key, variants, timezone, start_date — plus end_date only when the assignment SQL referencesab_end_*, so a routine experiment extension never orphans an end-invariant series): editing any of them orphans the stale series (swept on the next run), mirroring howmethod_config_idorphans results; reformatting alone never does. The series is strictly contiguous — every day<= get_last_state_day()is materialized — and every failure path preserves that by TRUNCATING the tail:--full-refresh --from/--todeletes from the first day its window touches before re-rendering through the end of the series (a crash mid-refresh leaves a self-healing prefix, never silently stale days), so a backfill can’t leave stale state; in copy mode day-close is clamped to the copy’s coverage and--resync-cohortrebuilds day state together with the copy. Non-finite moments (NULL warehouse values) truncate the series from the failing day with a loud warning — earlier days are retained, the retry costs one render per run, and reads past the last valid day stay on full-window recompute; never a silent undercount. No statistical numbers changed: the stage only writes_ab_unit_state;_ab_resultsmath is untouched (noALGORITHM_VERSIONbump).
Changed
Section titled “Changed”- M9 WP2 — CUPED is Tier E in
abk explore. The three recompute gates intuning/recompute.pythat demoted the covariate family are relaxed:cuped-t-testnow reconstructs each arm’s full covariateSufficientStatsfrom the M9 WP1 persisted moments and rerunscompare_pairexactly over the whole grid — for every knob (test_type,calculate_mde,power, alpha, correction) exceptcovariate_lookbackitself, which correctly stays Tier R (a different lookback is a new pre-period render; the reconstruction is refused whenever the live lookback differs from the one the row was computed with). Pre-migration rows (NULL covariate-moment columns) and degenerate covariates (NULLcorr_coef) gracefully keep the old Tier S / α-inversion / baseline fallbacks — never an error. The golden round-trip gate pins the reconstruction against a from-scratch pipeline run — incl. θ — at rel-1e-9 (round-off-exact, not bit-identical: the documented Tier-E tolerance). NoALGORITHM_VERSIONbump: the numbers are computed by the samefrom_suffstatsmath from losslessly-persisted moments; only where the cockpit computes them changed. Riding along (the adversarial-review round-1 fixes): the CUPED achieved-power chip now reads the control-arm correlation off the reconstructed result first (cache-free, agreeing with the exact point beside it) and falls back to the session cache for pre-migration rows; the knob surface exposescache.covariate_moment_rowsand the explore client no longer demands a warehouse reload when switching back to the configured CUPED method whose rows reconstruct (rebuiltexplore.js).
- M9 WP1 — persisted CUPED covariate moments + the schema-migration
primitive.
_ab_resultsgains fourNullable(Float64)columns —cov_std_1/2,corr_coef_1/2— populated bycuped-t-testonly (NULL for every other method and for pre-migration rows). Together with the existingcov_value_1/2they complete each arm’s covariate sufficient statistics (cov_m2 = cov_std²·n,cross_c = corr_coef·√(m2·cov_m2)), the prerequisite for CUPED Tier-E reconstruction inabk explore(M9 WP2). A degenerate covariate (zero pooled variance) persistscorr_coefas NULL via the existing NaN→NULL cleaning — never an error. ensure_columns()— the project’s first post-release schema-migration primitive.ensure_tables()now additively syncs every existing_ab_*table to the current model: it diffs the live columns (system.columnson ClickHouse,information_schema.columnson PostgreSQL/MySQL) against the declared schema and emitsALTER TABLE … ADD COLUMNfor anything missing — additive-only (never drops/renames/retypes), idempotent, safe on every CLI invocation. MySQL has noADD COLUMN IF NOT EXISTS, so its path pre-checks via the diff and swallows the duplicate-column race (errno 1060). New columns must be nullable or carry a default; the primitive refuses otherwise, loudly. Upgrading an installed project is therefore automatic: the nextabk runmigrates_ab_resultsin place, old rows read the new columns as NULL.
No ALGORITHM_VERSION bump: this is a schema/plumbing change, not a
statistics change — nothing here deviates from the captured statistical
baseline that statistics-changes.md’s
change-control governs, and no persisted statistical number moves (the
schema-not-statistics framing itself lands in statistics-changes.md at the
M9 exit-gate docs sync, per the plan’s WP6). The new moments are pinned
against independent np.std/np.corrcoef computations at the golden
rel-1e-9 tolerance (tests/golden/test_golden_parametric.py).
[0.3.0] - 2026-07-21
Section titled “[0.3.0] - 2026-07-21”M8 — assignments: no-copy default + incremental copy (the implementation
record is
docs/specs/m8-implementation-plan.md).
No ALGORITHM_VERSION bump — zero statistical numbers changed anywhere in
the milestone: this is a data-provenance/performance release (where cohort
reads come from, never the math over them); the cross-mode parity gates pin
_ab_results/_ab_aa_runs/the baked explore payload identical across modes.
Documentation
Section titled “Documentation”- M8 WP7 — the three-way docs sync (docs only, no behavior change). All
three single-source bodies (
docs/,.claude/rules/, the packagedinit-claudeassets) now describe the M8 as-built cohort semantics — a code-grounded audit found 75 stale/missing spots across 36 files:docs/reference/internal-tables.mdmarks_ab_exposuresoptional, copy-mode only and documents the append-only incremental write pattern (watermark resume, grid-anchored closed-interval batches) in place of the old delete+reinsert description;docs/guides/experiments.mdgains the “Persisting the cohort:assignment.cohort_copy” section with the prominent KNOWN-LIMITATION callout (late-backfilled rows are silently missed by the watermark — stay on the no-copy default or recover withabk run --resync-cohort); the plan guide/reference carry the no-copy cost caveat (arrival-rate derivation re-executes the assignment SQL at invocation time);declarative-config.md§4/§5/§8 documentab_cohort_sourceas the one mode switch + the copy-mode{{ ab_added_filters }}lint; theabk initscaffold comments describe the live-join default and ship a commented-outcohort_copy:example; and the same stale “persisted once per run” claims are fixed where they lived in code — module docstrings andabk plan --helptext (cli/main.py,cli/commands/plan.py,database/tables.py,compute/recompute_backend.py,planning/). Status lines acrossREADME/CLAUDE.md/rules flipped to “0.2.0 published on PyPI”; the m8 plan became the implementation record (done table, per-WP as-built notes, exit-gate log).
Changed
Section titled “Changed”- M8 WP4 — the no-copy default: assignments are read DIRECTLY; the
build_cohort_backendfactory is the one source-mode switch. BEHAVIOR CHANGE. By default (assignment.cohort_copy.enabled: false)abk runno longer writes_ab_exposures: metric SQL joins the dedupingab_cohort_sourcesubquery over the live assignment SQL (WP3), the SRM gate/sub-day count stream/abk planarrival rate/report SRM chip all derive from the same validated in-memory snapshot, and read-only commands (abk plan,abk validate,abk explore, tuning RELOAD/Auto-validate,--report) see the LIVE source at invocation time instead of the last run’s frozen copy — the audit’s accepted cost/freshness tradeoff. Settingcohort_copy.enabled: truekeeps today’s persisted-copy behavior end to end (full-reload write at WP4; superseded by WP5’s incremental engine — see the WP5 entry below). Every cohort reader goes through the newexposure_source.build_cohort_backend(...)factory — the binding inter-milestone contract (m8 plan §0.5(e)): copy mode stays query-free for read-only callers, direct mode renders + validates the source once (cross-variant corruption now fails loudly at every surface). The sub-day SRM bisect bucketing and the arrival-rate arithmetic moved to the shared pureabkit/core/exposure_counting.py, used by BOTH the_ab_exposuresmixin and the direct-mode paths — one implementation, no drift. NoALGORITHM_VERSIONbump — zero statistical numbers changed: the cross-command parity gate (tests/e2e/test_cohort_mode_parity.py) pins_ab_results/_ab_aa_runs/the baked explore payload identical across modes (+ the tuning/reloadreply intests/tuning/test_server.py), and the driver-level gates pin result rows- the sub-day SRM verdict stream
(
tests/pipeline/test_pipeline.py::TestCohortModeParity). Adversarial-review hardening in the same change:abk explorefails the house way (cleanClickException, actionable message) when the live source empties or corrupts at startup;--reportonabk run/abk validatereuses the invocation’s own validated snapshot for the SRM chip (never executes the assignment source twice); and a direct-modebuild_report_payloadcall without a manager shows honest ZERO counts instead of silently reading a stale copy-era_ab_exposures.
- the sub-day SRM verdict stream
(
- M8 WP6 — the copy-enabled e2e legs (tests only, no behavior change).
tests/e2e/test_first_run_copy_enabled.py: the scaffoldedabk initexample withcohort_copy.enabledproves the CLI write path is the incremental engine end to end (first run persists throughinsert_exposures_incremental, a rerun is an append-only watermark resume — zero cutoffs planned, zero_ab_exposuresdeletes, byte-stable results), and a staggered growing-source scenario proves the true increment the single-instant scaffold seed cannot express: run 1 mid-flight persists only the closed buckets (already-visible open-bucket enrollment is withheld until it matures), run 2 appends exactly the delta (earlier buckets never re-read; the persisted rows asserted field-exact, not just as a unit set), and the two-run incremental history lands_ab_resultsidentical to a fresh direct-mode project computed in one shot (watermark_ts— the as-of-run provenance stamp — is the one legitimately differing column). TheDELETE FROM _ab_exposuresstatement pin intests/database/test_sql_managers.pyis re-scoped to name the resync/purge path it now serves. - M8 WP5 — the incremental cohort copy engine +
abk run --resync-cohort. Withassignment.cohort_copy.enabled,abk runno longer full-reloads_ab_exposures(delete + reinsert of the whole cohort every run): the newloaders/exposure_copy.copy_exposures_incremental(...)appends only the newly matured rows — GRID-ANCHORED closed-interval buckets (grid.start_ts + k·batch_interval; the still-open bucket and rows younger thanmaturity_delayare withheld until they mature, and the covered boundary is a deterministic function of the clock, never of the data), watermark resume from the FINAL-dedupedMAX(exposure_ts)snapped to its bucket floor (first run backfills from the experiment’s tz-snapped start; a customupdate_columnhas no persisted cursor and re-scans from the start every run — bounding another column by the exposure watermark would silently drop rows), andbatch_intervals_per_round_trip-sized round trips that re-render the assignment SQL with the batch’s bounds injected through the EXISTING{{ ab_added_filters }}hook (no new jinja surface; the hook is now REQUIRED in copy mode — config-lint and the engine both prove the reference is LIVE by rendering a sentinel filter through it, so a token parked in a comment cannot pass). The run-level whole-cohort validation (WP2) still runs every run, so cross-variant corruption fails loudly before any copy write; the persisted write path is append-only (insert_exposures_incremental, neverdelete_rows).abk run --resync-cohort(m8 plan §4 Q2 — a dedicated flag;--full-refreshkeeps its results-window semantics) recovers a poisoned copy by deleting it and rebuilding from the experiment start THROUGH the same engine — one write path, so the rebuild honors the identical closed/matured discipline (never persists unmatured rows, never advances the watermark past routine operation) and the from-scratch re-scan is what picks up backfilled rows; a no-op in the direct (no-copy) default. KNOWN LIMITATIONS, disclosed not masked (§4 Q3, doc-only): routine runs miss a row backfilled into an already-scanned closed bucket (recover via--resync-cohortor stay on the no-copy default); on malformed multi-row-per-unit input — already loudly warned about every run — a duplicate whose rows straddle two scan windows (round trips of one run, or a prior run’s window vs a resume re-scan) resolves to the later window’s minimum instead of the full reload’s global earliest (both test-pinned). In copy mode the SRM gate/report counts deliberately measure the LIVE validated source; the persisted copy metrics join trails it by the open bucket +maturity_delay, andabk runwarns when a computable cutoff exceeds the copy’s deterministic coverage (aligndata_lag >= maturity_delay + batch_interval). NoALGORITHM_VERSIONbump — zero statistical numbers changed: the cross-mode e2e parity gate and the pipeline parity tests now exercise the incremental engine on the copy leg and stay byte-identical. - M8 WP3 — the
ab_cohort_sourcebuiltin: one cohort fragment, two source modes. The packaged assignment macro’sexposed_units()now reads its cohort through the newab_cohort_sourcebuiltin, built in Python (query_template.build_builtins) as either the persisted_ab_exposurestable (+FINALon ClickHouse — today’s behavior, still the default, rendered byte-identically) or a dedupingGROUP BYsubquery wrapping the rendered assignment SQL directly (direct_source_sql— the M8 no-copy read path;MIN(exposure_ts)per(unit, variant), the same aggregation as the WP2 validation pushdown).RecomputeBackendacceptsdirect_source_sql/has_stratumand threads them into every render, including the CUPED pre-period covariate render;ab_exposures_tablestays available for external template consumers. Call sites still construct copy-mode backends — the mode switch is centralized in WP4’sbuild_cohort_backendfactory (docs/specs/m8-implementation-plan.mdWP3, §0.5(e)). NoALGORITHM_VERSIONbump — zero statistical numbers changed (direct-vs-copy load parity is test-pinned). - M8 WP1 — the
assignment.cohort_copyconfig block (parse-only for now).AssignmentConfiggains an opt-incohort_copyblock (enabled,update_column,batch_interval,batch_intervals_per_round_trip,maturity_delay) carrying the incremental-copy knobs for M8’s no-copy-default read-path flip (docs/specs/m8-implementation-plan.mdWP1). The knobs validate at config-parse time (Intervalgrammar; identifier-shapedupdate_columnwhen enabled) but change no behavior yet — the direct-join default and the incremental copy engine land across M8 WP2–WP5. Namedcohort_copy, notcopy: a pydantic field namedcopyshadowsBaseModel.copyand warns at import (m8 plan §4 Q1, settled at WP1). NoALGORITHM_VERSIONbump — zero statistical numbers changed.
[0.2.0] - 2026-07-20
Section titled “[0.2.0] - 2026-07-20”M7 — validate: vectorization + iteration policy (the first polish-track
release; implementation record: docs/specs/m7-implementation-plan.md).
No statistical numbers changed anywhere in the milestone (no
ALGORITHM_VERSION bump, goldens and both e2e matrix gates byte-identical,
abkit.stats purity held): the A/A validate engine went from minutes of
nested Python loops to seconds of block-streamed numpy — ~10× per validate
cell, ~18× for the composed family sweep, up to ~149× on the closed-form
significance kernel — behind exhaustive scalar↔vectorized parity gates, and
two run-policy defaults changed (the opt-in --family-sweep and the
per-cell auto-N tied to alpha; see the WP6 entries under “Changed”).
Changed
Section titled “Changed”-
M7 WP6 — the composed family sweep (D9) is opt-in:
--family-sweep. BEHAVIOR CHANGE.abk validateno longer auto-runs the multi-metric FWER/FDR sweep whenever--metricwas omitted (it silently roughly doubled every multi-metric run’s cost — REPORT item 7); pass--family-sweep(ValidateSettings.family_sweep=True) to include it. A bare multi-metric run prints a one-release migration notice naming the flag;--family-sweepcombined with--metricis logged-and-skipped (one metric has no family to compose). Scripts or dashboards that relied on the__family__sentinel row appearing in_ab_aa_runswithout any flag must now pass--family-sweep. Explore’s Auto mode (POST /validate) does not opt in — the D3 calibration chip keys on per-cell rows only, so Auto runs get proportionally faster. -
M7 WP6 — default placebo iterations are tied to each cell’s effective alpha:
max(2000, ⌈200/α⌉). BEHAVIOR CHANGE. The flatDEFAULT_ITERATIONS = 2000starved tight secondary-tier alphas (at α = 0.5% a 2000-split FPR estimate carries ~±0.16pp SE against a 0.5% target — REPORT item 8); the default now resolves per cell at the cell’s effective post-correction alpha (≈4000 at the 5% main tier, ≈40000 at a 0.5% secondary tier), so a default run costs more iterations than before — cheap after the WP1–WP5/WP7 vectorization (~10× per whole cell by the WP5 perf gate, ~18× for the family sweep; individual kernels up to ~90×).-n/--iterationsstays a hard override for every cell; the family sweep sizes its shared draw count at the tightest member alpha; the persisted row’siterationscolumn records the resolved N that actually ran. Per the m7 §4.1 maintainer call the auto-N is never hard-capped — above 100 000 the runner logs a warn-and-continue decision entry, echoed by the CLI as a yellow terminal warning, instead of silently truncating a configured alpha tier. Neither WP6 change moves a statistical number — Monte-Carlo sample size and which passes run are not method math (noALGORITHM_VERSIONbump, nostatistics-changes.mdentry; the exact-null FPR/power columns stay seed-deterministic at any given N, and the exit-gate e2e pins the same numbers under its explicititerations=). This is deliberately distinct from the byte-identical WP1 hot-path fix below — do not conflate the two categories. -
M7 WP1 — scalar hot-path quick wins (hardening bucket A, A1–A8). No statistical numbers changed: the old-vs-new swap was verified bit-exact on the capture environment against a fixture frozen from the pre-change code, and the committed golden gate
tests/stats/test_normal_path_golden.pyre-checks the battery (extreme-z, degenerates, all six closed-form methods end-to-end) on every run — float fields at the repo’s golden relative 1e-9 (BLAS/libm builds differ across machines in the last ULP; a formula change fails by orders of magnitude), every reject/size/warning/flag field exactly. The whole stats+golden suite passes unmodified (634 passed, 1 opt-in benchmark skipped). The wins:- A1 —
scipy.special.ndtri/ndtrreplace the frozensps.normobjects on the closed-form significance path (effects.normal_test, the z-test,sequential.se_from_ci_length), with the sf tail computed asndtr(-z)(never1 − ndtr(z), which drifts for extreme z). Alpha-only quantiles are now computed once per alpha (lru_cache), not per comparison. Measured:normal_test283.8 → 1.9 µs/call (~149× on theabk validate/explore closed-form hot path). - A2 — statsmodels imports moved inside the power/MDE solves —
import abkit.statsno longer eagerly loads statsmodels+pandas+patsy (~0.5 s cold in this env); a subprocess test pins the deferral. - A3 —
TestResult.effect_distributionis now aLazyNormalproxy on the closed-form path — freezing the never-serialised scipy distribution is deferred to the first attribute read (delegated reads are byte-identical); theis not Nonetruthiness contract andto_dict()behavior are pinned by a new test. (The bootstrap methods’effect_distributionstays eager — negligible next to the resampling itself.) - A4 — bootstrap result-assembly dedup — per-arm
stat_pointvalues are computed once and passed into_finalize;pvalue_signcounts each side once and divides once (provably byte-identical, goldens intact). - A7 — shared
BaseMethod._result_from_normal_test— the six closed-form methods’ copy-pasted ~20-kwargTestResulttails now assemble in one place (field-drift risk removed), pinned field-by-field by the golden gate. - A8 —
samples.pymicro-dedups —SufficientStats.from_samplereuses theSample’s already-computed covariate mean;from_ratio_samplecomputes each mean once;RatioSufficientStatsgains the samem2 ≥ 0validationSufficientStatsalready had. - A5/A6 — registry-driven contract tests + a completeness gate — the
universal method contracts (dual-entry, seed-exclusion,
to_dict, quarantine) are parametrized off the plugin registry so a new method is auto-swept in, and a new completeness test fails if aBaseMethodsubclass is importable but silently unregistered.
- A1 —
-
M7 WP2 — the array-wise significance kernel (
supports_vectorized+from_suffstats_array). Purely additive; no statistical numbers changed — every scalar path is untouched byte-for-byte. A new opt-in plugin capability (mirroringsupports_sequential) lets a method expose a batch significance entry: column arrays of per-arm sufficient statistics in, a slimBatchEffectResult(effect/left_bound/right_bound/ci_length/pvalue, one row per comparison) out, computed via numpy broadcasting with the alpha-only quantiles evaluated once. Exactly five methods opt in —t-test,z-test,cuped-t-test,paired-t-test,ratio-delta(pinned by a capability-roster test); bootstrap stays scalar-only, exercising the fallback the M7 WP4 engine will rely on. The sequential module gains the same siblings (se_from_ci_length_array,sequentialize_array). Row-level parity with the scalarfrom_suffstatsis pinned bytests/stats/test_vectorized_parity.py+tests/stats/sequential/test_sequential_arrays.pyacross every guard branch (H5 denominators, degenerate variances, pooled proportions 0/1, extreme-z tails, heterogeneous 1e-4…1e4 magnitude mixes) — bit-exact for all five methods and both test types, by construction: power terms route through the same C-librarypowthe scalar**uses (_libm_pow), because numpy’s own integer-exponent power is 1 ULP off libm and the cancelling delta-method variance sum amplifies that far past rel-1e-9 (found by adversarial review round 1, pinned by a cancellation regression test); only the sequential siblings’log/expkeep the golden rel-1e-9 bound across libm/numpy builds (same-sign sums, no cancellation to amplify — measured byte-identical on the capture environment). Degenerate batch rows yield NaN (“gaps, never zeros”) instead of per-row warnings/exceptions — the one documented contract divergence (ddof-1n < 2rows NaN-poison where the scalar raises) has its own regression tests; mismatched per-arm row counts, 0-d/scalar columns and 2-D columns all fail loudly (SampleValidationError), never broadcast or malform; and the kernels mirror the scalar constructors’int(n)truncation (np.trunc) so a fractional-nrow cannot silently diverge (both from adversarial review round 2). Measured on the M7 reference shape (200k rows ≈ 2000 iterations × 100 cutoffs): ~120 ms batched vs ~1.4 s scalar-looped (~12×) for the relative t-test kernel, ~16 ms (~90×) for pow-free branches — the libm-pow routing deliberately trades a slice of the speedup for bit parity. -
M7 WP3 — the block-streamed vectorized placebo-resampling engine (
abkit/validate/vector_resample.py). Purely additive; no statistical numbers changed — nothing consumes it yet (the M7 WP4score_cellrewrite will) and the scalarresample.pypath is untouched.placebo_mask_blockproduces a block of placebo masks where rowiISplacebo_mask(..., derive_seed(*seed_parts, block_start + i))— the permutation layer stays bit-identical to the scalar loop by construction.build_arm_batchthen collapses a whole block’s per-arm sufficient statistics at one cutoff into one GEMM per arm (pooled-shifted one-pass co-moment columns;sample/CUPED/fraction/ratiokinds; columns keyed to feed WP2’sfrom_suffstats_arraydirectly), with per-(iteration, cutoff)degenerate gap masks (MIN_ARM_UNITS, zero-trial fraction arms) whose rows are NaN-poisoned — gaps, never zeros. Blocking mirrors the bootstrap engine’sBLOCK_QUANTUM/256 MiB-cap arithmetic (block_rows/iter_blocks) with one documented divergence: mask rows are seed-independent, so a block may shrink below one quantum (down to one row), keeping the cap honored for the block-scaled working set at any population size (the per-cutoffk ≤ 5value columns are a cap-independent8·k·n_unitsfixed overhead, asserted separately by the memory tests). The block-size contract is stated honestly from measurement: masks/counts/degenerate flags are byte-identical under ANY partition; float columns are byte-reproducible under a fixed partition and ULP-class (gated rtol 1e-12) across different partitions — it was measured that no float reduction (BLAS or numpy’s ownsum(axis=1)) keeps the same row bit-stable across buffers with different row counts, so the bootstrap engine’s “any cap, same bytes” promise is provably out of reach here and rel-1e-9 scalar parity (matmul-vs-.sum()reduction order) is the numeric gate, pinned per row against the scalarbuild_armacross all four input kinds, growing unit sets, extreme shares, offset (1e8) data and mixed degenerate blocks intests/validate/test_vector_resample.py. Resolves m7 open question §4.4: cross-cutoff prefix sums are permanently inapplicable (the full-window re-render makes per-unit values non-appendable — refunds shrinksum(...)metrics,max(...)-shaped metrics are not additive at all), recorded in the module docstring.inject.pygains the batch mirror of the injected pass (inject_multiplicative_columns/injection_clamped_columns, bit-exact vs the scalar injection algebra per row) so the WP4 scorer’s power/coverage pass has its seam ready. Adversarial review round 1 (2 reviewers, 2 major- 6 minor, all fixed): the rel-1e-9 parity band is scoped and pinned at its
real float64-conditioning boundary (
|value|/σ ≲ 1e10; the scalar path’s rounded-arm-meanm2inflation is what diverges past it, measured ~5e-9 at 1e12), the CUPED/ratio memory profile is asserted with the capped and fixed parts split, overflow-scale data cannot leakRuntimeWarnings or a non-degenerate NaN row unnoticed, and malformedcount > nobsfraction data is pinned to flow to a NaN gap (the scalar path crashes at construction — the one documented build-level divergence). Round 2 (fresh reviewer, 2 major + 3 minor, all fixed): the hoist API rejects a mismatched(prepared, cut)pair (an equal-sized-cutoffs mixup would otherwise score silently-wrong numbers), the batch injection’s deliberate NaN-m2 divergence from the scalarmax(0.0, nan) == 0.0quirk is documented + regression-pinned (the batch keeps the gap poison), and panel arrays are float64-normalized like the scalar constructors (a float32 panel would otherwise break rel-1e-9 parity at ordinary offsets). Measured at the reference shape (2000 iterations × 100 cutoffs, CUPED, n=2000): ~1.4 s for the full suffstats aggregation vs ~20 s for the equivalent scalarbuild_armloop (~15×), before the WP4 significance-side vectorization lands on top.
- 6 minor, all fixed): the rel-1e-9 parity band is scoped and pinned at its
real float64-conditioning boundary (
-
M7 WP4 —
score_cellnow runs the vectorized engine forsupports_vectorizedmethods, with the original scalar loop preserved verbatim as the fallback. No statistical numbers changed — the e2e validate/sequential matrix gates pass unmodified against the new default path, and per-kind smoke-parity tests pin the two engines against each other (integer tallies + count-ratio columns exactly equal; continuous means at rel-1e-9, the WP3 reduction-order budget). The vectorized engine consumes the WP2+WP3 primitives end to end: per block of iterations (iter_blocksoverblock_rows(n_units)— blocking is a pure function of(iterations, n_units)+ module constants, so persisted A/A numbers stay byte-reproducible run-to-run under a fixed BLAS configuration, D13; a different BLAS build/thread count re-rounds the GEMM’s continuous columns at ~1e-15 rel, counts unaffected — the same scope the Poisson bootstrap engine ships with), each cutoff is onebuild_arm_batchGEMM + onefrom_suffstats_arraycall; the peeking first-crossing state streams per row in O(block) memory (neverblock × cutoffs), explicitly guarding the argmax-on-all-False footgun (regression-tested: a grid where no null split ever crosses reportspeeking_fpr == 0.0, not 1.0); the always-valid D8 twin rides the same per-look(effect, SE)arrays throughsequentialize_arrayunder the unchanged scalar τ² anchor; the injected power/coverage pass reuses the held horizon batch throughinject_multiplicative_columns(same one-shot saturation warning); and the reporting-only achieved-MDE loop stays scalar but strictlyiterations-shaped (neveriterations × cutoffs— the §WP4 risk-list regression). Methods without a batch kernel (supports_vectorized=False: the bootstrap family, any custom plugin) dispatch to_score_cell_scalar— a pure code move of the previous loop, pinned identical via a stub-method test; a plugin that declaressupports_vectorized=Truewithout a working batch kernel fails its own cell loudly (ValidateError), never aborting the whole matrix. The engine’s live allocations share ONE 256 MiB ceiling: hoisting the per-cutoff GEMM operands (prepare_cutoff) gets only what the block working set leaves of the cap (past the leftover, blocks re-prepare per cutoff — bounded memory, identical bits either way, equality-pinned by forced-non-hoist tests over single- AND multi-block partitions; the BLAS-scope boundary itself stays documented-not-CI-enforced, same as the donor bootstrap engine’s gate). Also fixed while under review (pre-existing, shared by both engines, no scorable number moved): an exactly-zero pooled ratio denominator crashed the whole matrix with an uncaughtZeroDivisionErrorout of_point_estimateinstead of falling back to the per-iterationvalue_1truth anchor as documented — now guarded likeratio_delta._arm_linearisation, regression-tested on both engines. Measured at the reference shape (2000 iterations × 100 cutoffs, CUPED, n=2000, with injection): ~2.5 s vectorized vs ~25 s scalar (~10×); the dedicated parity + perf gates land in WP5. -
M7 WP5 — the exhaustive parity gate + the executable perf gate closing the milestone’s engine chain. Zero statistical numbers changed — every existing golden reference (the validate-matrix and sequential-matrix e2e, the sequential/family parity suites) passes unmodified.
tests/validate/test_vector_parity.pyruns the preserved scalar engine against the vectorized default across ≥50 seeds × 8 shapes (sample / CUPED / absolute test_type / fraction / ratio, plus three adversarial stress shapes: a gap-heavy sparse shape where some splits degenerate and some don’t, CUPED at theMIN_ARM_UNITSfloor, and a saturating-clamp fraction injection), ± injection, asserting exact equality on every count/decision/curve/warning field — includingachieved_mde, see below — and rel-1e-9 on continuous means, with a trip-wire pinning everyCellScorefield to a parity class so a future field cannot dodge the gate, a multi-block streaming test (quantum 1/7/128 — cross-block accumulators and the ragged final block), and scanned-and-pinned deterministic seeds for two rare-but-reachable states the battery alone would undersample: the τ²-unanchorable cell (“always-valid column skipped”) and the no-valid-horizon cell (the milestone exit run passed atABKIT_PARITY_SEEDS=200— 1 600 engine-pair runs). The §0.3(3) mandatory near-boundary stress manufactures the dangerous input outright: brentq solves the injected δ that puts a split’s CI bound exactly on the significance boundary — at δ·(1±1e-9) parity stays exact (the bound sits five orders of magnitude above the engines’ ~1e-16 ULP divergence), and AT the solved root (|bound| ≲ 1e-15, inside ULP ambiguity) the measured, now-pinned honest limit is a single flipped decision confined to the stressed iteration’s power column — both roundings correct, real cells at generic positions unaffected (the e2e matrices are byte-identical); two scanned seeds whose null split is already significant keep the negative-root bracket branch live rather than dead defensive code. Fixed under adversarial review round 1 (the one engine change in this WP): the vectorized MDE seam now rebuilds each valid row’s control arm through the scalarbuild_armon the row’s own mask — bit-identical_analytic_mdeinputs by construction — instead of reading the GEMM columns, which diverged at a knife-edge (a 2-unit CUPED arm has metric↔covariate corr ≡ ±1; whichever engine’s reduction rounds exactly onto ±1 reportsachieved_mde=Nonewhile the other reports0.0, and the persisted column feeds the Recommended-row tie-break).achieved_mdeis therefore asserted exact, not rel-1e-9, and the GEMM-column_control_stats_from_rowhelper (with its documented fractional-count clamp caveat) is gone.tests/validate/test_vector_perf.pyasserts the REPORT reference cell (2 methods × 2000 iterations × 100 grid cutoffs × 1000 units, null + injected + sequential columns) under a generous CI-safe 10 s bound sized against the coverage-instrumented run — the CI Test job traces--cov=abkit, which roughly doubles the cell: dev-measured ~1.3–1.7 s bare / ~2.2–2.5 s under coverage (vs ~25 s scalar, same methodology), with the scalar engine monkeypatched to fail loudly if dispatch ever regresses. Adversarial review round 2 (fresh reviewer) additionally: caught and fixed the MDE-seam rebuild crashing a whole fraction cell on corrupt over-counted input (per-unit successes > trials) where the batch main pass scores it — the row’s MDE is now skipped, reporting-only stays reporting-only, with the residual scalar-fails/batch-scores divergence on such corrupt input documented in the spec §9 and pinned by a dedicated regression test; re-examined the WP2 kernel-tolerance question (§4.3) — already closed at exact (assert_array_equal, nothing to tighten); and measured the battery’s continuous-field deviations at ≤ ~2e-14 rel, keeping the rel-1e-9 assertion as the principled conditioning-band bound, not defensive slop. The spec gains the matching contract section (aa-false-positive-matrix.md§9 “Implementation note”) so the invariant lives in the spec, not only in code comments. -
M7 WP7 (stretch) — the composed family sweep (D9) runs its own block-streamed vectorized engine. Zero statistical numbers changed — the scalar loop is preserved verbatim as the fallback, and every existing family/e2e reference passes unmodified.
family.pyhas its OWN hot loop (the §0.3(1) plan-review correction — the WP4score_cellrewrite never touched it);sweep_familynow dispatches exactly likescore_cell: when EVERY member’s method opts in viasupports_vectorized, blocks of shared union masks come fromplacebo_mask_block(row i IS the scalar union mask — bit-identical by construction), each member’s per-look arms build through onebuild_arm_batchGEMM + onefrom_suffstats_arraycall (the_Peekaccumulator gets a block-wise mirror,_PeekBlock, with the same first-crossing/latest/min-p semantics), and the per-iteration COMPOSITION (composed_significance) stays the unchanged scalar helper applied in iteration order — so everyFamilyScorecolumn (count ratios, exact-fraction FDP sums, warnings incl. the one-shot clamp warning’s lexicographic (iteration, member) pick) is expected EXACT, not rel-1e-9. A family with any non-opted-in member (bootstrap, custom plugins) runs_sweep_family_scalar— a pure code move of the previous loop; a lyingsupports_vectorized=Truemember fails the sweep loudly as aValidateError. The new gatetests/validate/test_family_vector_parity.pyasserts exact equality on everyFamilyScorefield across ≥50 seeds × 5 family shapes (overlapping/disjoint cohorts, ratio+CUPED members, a persistent-gap 3-unit member with its ‘scored in 0 iterations’ disclosure, a saturating-clamp planted fraction member, bonferroni AND benjamini_hochberg, ± injection, ± sequential; exit run atABKIT_PARITY_SEEDS=200— 1 000 engine-pair runs), plus multi-block (quantum 1/7, every shape) and dispatch/fallback/lying-flag contracts. Measured on a reference family (3 members × 2000 iterations, sequential + injection): ~0.11 s vectorized vs ~1.96 s scalar (~18×), with byte-identical output. Two adversarial review rounds; fixed under round 1: the batch engine gained the scalar_member_marginal’sexcept Exceptionnet around the batch kernels (a structural kernel raise — e.g. a programmatically-built CUPED member on a covariate-less panel — now gaps that member exactly like the scalar engine instead of crashing the sweep;NotImplementedErrorre-raises so the lying-flag contract stays loud), and the corrupt-input divergence class (fractioncount > nobs: the scalar engine crashes the sweep, the batch engine scores it) is now spec-documented for the family surface and pinned by a dedicated regression test — the batch-flag hardening remains the same named follow-up asscore_cell’s. Round 2 scoped the net honestly: undersequential=True(the runner’s only mode) a member whose τ² ANCHOR itself raises structurally crashes BOTH engines identically inside the shared, unguarded_cell_tau2— pre-existing, symmetric, runner-isolated; the engine net applies where the anchor didn’t already fail (a degenerate-anchor member — pinned by a dedicated walk-raise parity test — orsequential=False), and guarding_cell_tau2itself is a named follow-up since it would change both engines’ behavior at once. -
The polish track M7–M17 (
0.2.0…0.12.0) planned into the repo — docs only, no behavior change, no statistical numbers touched: the approved (2026-07-18) track section inROADMAP.md(milestone map + versioning + the coverage map over the data-flow audit’s 15 items and the entire post-baseline hardening backlog + the cross-cutting discipline, incl. the M7–M12 “numbers do not move” parity gates and the M8→M9build_cohort_backendblocker contract), six as-designed contractsdocs/specs/m7…m12-implementation-plan.md(from the code-verified WP breakdowns), and the verified pain audit committed asdocs/research/2026-07-data-flow-audit/REPORT.md(four verification corrections recorded in its banner). M13–M17 stay contours — each opens with its own design session.
- M7 WP0 — multi-arm Review mode dropped every verdict after the first
(UI-only; no statistical number touched).
abk explore’s Review mode rendered a metric’s verdict via.find(...)overpayload.verdicts, which holds one block per (metric × control-vs-treatment pair) — so in a 3+-arm experiment only the first pair’s verdict showed and the rest were silently dropped (the underlying per-pair verdicts were always computed and persisted correctly). Review mode now renders one labeled verdict line per declared pair (sameabk-review-verdict/abk-verdict-<word>marker classes; 2-arm rendering unchanged), with jsdom regression tests for both the 2-arm and 3-arm cases and the rebuilt committedexplore.js. A new honest “Known multi-arm limitations” section indocs/guides/experiments.mdnames what is not k-arm-aware today: no experiment-level winner rollup (M14),abk plansizes off the first declared pair only, andabk validate’s placebo split is two-arm (control share vs the rest pooled).
[0.1.2] - 2026-07-09
Section titled “[0.1.2] - 2026-07-09”Explore-cockpit / CLI DX + reporting polish. No statistical numbers changed
(no ALGORITHM_VERSION bump, goldens intact, abkit.stats purity held) — every
change below is transport, logging, or presentation.
- Brand logo in every generated surface — the “Diverge” mark +
abkitwordmark now render in theabk run --reportandabk exploreheaders (inline SVG, sharedweb/src/shared/logo.ts), not just the browser-tab favicon. - Progress heartbeats on long-running compute so a multi-minute run is no longer a
silent freeze:
abk runprints a throttledLOOK i/Nper computed look;abk validatestreamsscoring cell i/Nper cell; Auto mode echoes the same to the explore terminal.
Changed
Section titled “Changed”- Explore Auto button is honest when unavailable — on a
--no-serve/ saved-report page it now carries an actionable tooltip (“Auto needs a live server — open the printed localhost (127.0.0.1) URL, not a saved report”) +aria-disabled, and it shows abusystate while a validation is in flight. - Removed the redundant explore “CUPED on/off” checkbox — it was a pure UI alias of
the method picker (it only strip/prepended
cuped-and switched the method). CUPED is now chosen directly in the method picker as thecuped-t-testvariant; no functional loss, one fewer duplicate control.
- No more
BrokenPipeErrortracebacks fromabk explore— the stale-drop discipline (a knob turn aborting a superseded request) left the server writing to a closed socket; the transport helpers now suppressBrokenPipeError/ConnectionResetError(the latest request still computes and replies). - No more per-split
AbkitStatsWarningflood duringabk validate/ Auto mode — the A/A sweep re-invokes the same method over hundreds of placebo splits × looks, so the CUPED low-correlation / ratio-zero legacy guards spammed stderr thousands of times. They are now suppressed inside the scoring loop only (the single realabk runstill surfaces them; also carried inTestResult.warnings). Non-numeric.
[0.1.1] - 2026-07-08
Section titled “[0.1.1] - 2026-07-08”Documentation + AI-assistant-context accuracy patch (no code, no statistical
numbers — a post-0.1.0 fact-check of every published doc page and every packaged
abk init-claude asset against the shipped CLI/config/method surface). 15 verified
findings fixed; each was independently re-verified against the code.
- Packaged
abk init-claudeassets now match the shipped API (these ship in the wheel, so the fix ships in0.1.1):- Metric-SQL docs no longer reference a non-existent
{{ data_schema }}template built-in (rules/metrics.md,skills/abk-new-metric) —{{ data_database }}is the single data-location built-in on every dialect (on Postgres it resolves to the profile’sdata_schemavalue); underStrictUndefinedthe old note would have made Postgres metric SQL fail to render. skills/abk-exploreno longer lists the multiple-comparison correction among the identity params that orphan an_ab_resultsseries — the correction (likealpha) is experiment-level and never entersmethod_config_id; changing it re-arms the calibration chip but does not orphan results.skills/abk-validatequotes the actual Recommended-row rationale (“highest power among methods with FPR within budget”, tiebreak: tightest achieved MDE), not a CI-width criterion the selector never uses.rules/explore.mdmarks Segment mode as a deferred placeholder (not an available 0.1.0 cockpit mode).rules/project.mddescribes env-var interpolation correctly (an unresolved placeholder is kept verbatim, not raised as an error; only channel secrets are actively rejected);skills/abk-setup-projectscopes the “nodatabase:key” note to ClickHouse (it is optional for MySQL, required for Postgres).
- Metric-SQL docs no longer reference a non-existent
abk test-report+notification_channelsare now covered for the AI assistant and in the docs (they shipped in0.1.0but were undocumented): atest-reportcommand entry +## abk test-reportsection (rules/cli.md,docs/reference/cli.md), anotification_channelsblock inrules/project.md, a routing entry inCLAUDE.section.md, and the command added to the enumerated command surface indocs/README.md/docs/getting-started/installation.md.abk planruntime/ASN documented as shipped:docs/reference/cli.mdno longer says runtime/ASN “is not part of this command”, and the--arrival-rateflag is added to theabk planoption/flag tables indocs/reference/cli.md,rules/plan.md, andskills/abk-plan.- The CI
install-smokeversion gate now comparesabk --versionagainstabkit.__version__dynamically (no longer hard-pinned to a literal), and theabk --versionsample ininstallation.mdtracks the release.
No abkit.stats change; no ALGORITHM_VERSION moved; goldens untouched at rel-1e-9.
[0.1.0] - 2026-07-08
Section titled “[0.1.0] - 2026-07-08”The first tagged public release — milestones M1–M6. The pure numpy statistical
core, the declarative YAML+SQL config / DB layer / recompute pipeline, the explore
cockpit + self-contained reports, abk validate (the A/A false-positive matrix),
opt-in sequential analysis + abk plan, and the M6 DX layer (abk init-claude,
abk test-report, the docs site, Prefect scaffolding). No statistical numbers
changed across M2–M6 (goldens intact at rel-1e-9; no ALGORITHM_VERSION moved).
- M6 WP10 — the M6 exit gate: release-readiness e2e, ≥2 adversarial review rounds,
and the coordinated milestone-header sync. New
tests/e2e/test_release_readiness.pyproves the whole first-release journey offline and byte-reproducibly —abk --versionreports the real (non-placeholder) release,abk init→abk run --selectlands a real verdict-bearing_ab_resultsrow,abk run --reportbakes a self-contained zero-network readout,abk init-claudematerializes the managedCLAUDE.mdblock + the 9 rules + 7 skills (idempotently), and the committed renderer bundles are self-contained (offline, no external host). The wheel-packaging DoD — a built wheel shipping both bundles + everyabkit/cli/assets/claude/**asset and resolving in a clean venv — is owned authoritatively by the CIlintwheel-namelist gate + theinstall-smokejob (across the Python matrix), which this fully-offline e2e complements deterministically. The as-built docs are flipped to one story now that M6 is shipped: the status headers inCLAUDE.md,.claude/rules/architecture.md(including__version__0.0.1.dev0→0.1.0in the banner),.claude/rules/contributing.md(the release checklist names the three single-source bodies + the packaging DoD), andROADMAP.md(M6 ✅ SHIPPED; the solealpha_spending/group-sequential deferral pointed at the future with no version promise). The exit-gate review (≥2 full rounds, refute-by-default, a second independent verifier per finding) is recorded indocs/specs/m6-implementation-plan.md §5. Noabkit.statschange; noALGORITHM_VERSIONmoved; goldens untouched at rel-1e-9;abkit.statspurity intact. - M6 WP9 — release engineering (prep only; the tagged publish is a separate,
maintainer-gated step). Bumped
__version__0.0.1.dev0→0.1.0(the first real version must exceed the reserved placeholder or PyPI rejects the upload) and the packaging classifier toDevelopment Status :: 3 - Alpha. Cut this[Unreleased]history into the dated[0.1.0]section. Hardened the release DoD with three new gates: (1) the CI wheel-namelist gate now also asserts the wheel ships everyabkit/cli/assets/claude/**file (the 17abk init-claudeassets — the highest-risk packaging miss, since a bad wheel can’t be re-uploaded under the same version), alongside the existingreport.js/explore.jsbundle check; (2) a newpip installDoD smoke job installs the built wheel (not-e .) into a clean venv on Python 3.10/3.11/3.12 and provesabk --versionreports0.1.0andabk init-claude -d <tmp>materializes the managedCLAUDE.mdblock + the 9 rules- the 7 skills from
importlib.resourcesat install time; (3) the WP8-promised cross-body docs single-source drift gate (tests/docs/test_docs_single_source.py) asserts every packaged operator rule inabkit/cli/assets/claude/rules/has a corresponding publisheddocs/page — so a new rule cannot ship without a user-doc home. Themypy abkitstrict gate stayscontinue-on-error(aspirational) for 0.1.0: the ~124 tracked strict-mode errors live in numeric hot paths (recompute.py/readout.py) and clearing them is a post-0.1.0 quality pass, not a release blocker (§7 Q9 decision). Noabkit.statschange; noALGORITHM_VERSIONmoved; goldens untouched.
- the 7 skills from
Changed
Section titled “Changed”- M6 WP8 — named-deferrals hygiene: the shipped code, packaged assistant assets, docs, and
specs now tell one true story about what is and isn’t implemented (no behavior change).
Every “planned for M6 / deferred to M6 / M6 follow-up” string that pointed at a feature which
actually shipped in M6 is flipped to shipped —
abk planruntime/ASN (WP-A) and the A/A sequential × composed sweep (WP-B) acrossabkit/planning/__init__.py, the packagedabk init-claudeassets (rules/plan.md,rules/validate.md,skills/abk-plan),docs/guides/plan.md,docs/specs/cli-and-dx.md,docs/specs/aa-false-positive-matrix.md,ROADMAP.md,.claude/rules/architecture.md, and this repo’sCLAUDE.md. The one genuinely unshipped item —alpha_spending/ group-sequential — is re-pointed everywhere from “M6” to a future item with no version promise (the user-facing config error already refuses it cleanly). Three spec-reconciliations bring the as-built into line with the prose: the single-source docs model is documented as three separately-authored bodies kept consistent by human review (not machine cross-generation; a CI drift gate lands in WP9), the BI deliverable as tool-agnostic reference SQL + one Grafana dashboard (not a per-tool importable dashboard for each of the four), and project-level error notification as a post-M6 item (withabk test-reportthe shipped connectivity smoke). Noabkit.statschange; noALGORITHM_VERSIONmoved; goldens untouched. - M6 WP7b — the self-contained
abk run --report+abk exploresurfaces now render in the finalized Iris brand. The one brand-token layer (web/src/shared/chart.tsTOKEN_FALLBACKS) was frozen from placeholder values to the real Iris palette (docs/design/brand-tokens.md): a warm-paper light page (#f5f1e8/#1b1916), a dark chart panel, the iris-family series slots (#c9a6f0/#8e76e0), and the five verdict/status tokens (WIN#1e9e6a… SRM#b23a6b). Both page shells (abkit/reporting/html_report.py,abkit/tuning/html.py) now carry the abkit “Diverge” brand mark as their favicon (iris tile + paper strokes) and open on warm paper — still fully self-contained (no network, no webfonts; system-font fallback).report.js+explore.jsrebuilt. The CI token-sync gate is promoted to a hard value check for the theme-independent tokens now that the palette is frozen (per-theme surface tokens stay value-skipped). WCAG-AA contrast recorded for the reskinned text surfaces (body/muted/accent on paper and the dark chart panel all pass AA). Noabkit.statschange; noALGORITHM_VERSIONmoved.
- M6 WP7b — an interactive stabilization demo on the landing page. The marketing hero now
mounts a live
#abk-demowidget (dial true effect / noise / traffic, watch the cumulative effect + CI converge past the decision horizon and the WIN/LOSE/FLAT/INCONCLUSIVE verdict get called). Its compute core (website/src/scripts/demo/stats.ts) is a TypeScript re-derivation ofabkit.stats, golden-parity-gated in CI (check-demo-parity.mjs, rel-1e-6), painted through the shared framework-free renderer core (web/src/shared/chart.ts). Everything is client-side and dependency-free. TheNotification channelsguide (docs/guides/notification-channels.md, WP5) is now wired into the docs site (sidebar +sync-docsPAGES).
abk initprod-profile env placeholders were double-wrapped (latent scaffold bug). Theprod:profiles in the threeabk inittemplates used quadruple-brace{{{{ env_var('ABKIT_*') }}}}placeholders, butprofiles.ymlis written raw (never.format()-ed, unlikeabkit_project.yml), so a set env var resolved to{{value}}(wrapped in stray braces) instead ofvalue. Corrected to standard double-brace{{ env_var('ABKIT_*') }}. Latent becauseabk init’s scaffold self-check runs with the vars unset (a preserved placeholder validates fine either way). Surfaced while adding the WP5notification_channels:seed block.- M6 WP1 — tooling debt root-caused + partly cleared (no behavior change). The
long-standing “
mypyfails on clean HEAD” was not a numpy issue: a stray comment# type: (required, optional)inabkit/config/metric_config.pywas parsed by mypy as a PEP-484 type comment (Invalid syntax), making it bail before type-checking anything. Reworded the comment; raised[tool.mypy] python_versionto3.12(clears the secondary numpy 2.5 PEP-695 stub error); addedyaml.*toignore_missing_imports.mypy abkitnow runs to completion (it reports ~124 real strict-mode errors, stillcontinue-on-error— tracked debt, they live in numeric hot paths). Pinned[dev]black==24.4.2andmypy==1.10.0to the pre-commit revs so CI and local pre-commit cannot diverge (zero reformat churn). No runtime code changed; goldens untouched; noALGORITHM_VERSIONmoved.
- M6 WP5 —
abk test-report+ a minimal notification-channel layer (abkit/notify/). A new command sends a synthetic mock readout through every channel in a newprofiles.ymlnotification_channels:block and prints a per-channel ✓/✗ — a connectivity + formatting smoke test (no lock, no warehouse read, no statistics). Five channels ported and reshaped from detectkit’s alerting channels — Slack, Mattermost, a generic webhook, Telegram, email — keeping the transport/envelope but dropping every alerting semantic (no severity / recovery / no-data / detector / quorum / consecutive machinery; abkit has no alerting). The message is experiment-primary: a verdict (WIN/LOSE/FLAT/INCONCLUSIVE, SRM-gate overriding), effect + CI, p-value, the effective post-correction alpha, and the weekly-cycle representativeness, colored by the five brand verdict tokens. Secrets come only from env interpolation (${VAR}/{{ env_var(…) }}) and an unresolved placeholder is refused with a clear error.notification_channels:is a new typed field onProfilesConfig(NotificationChannelConfig, additive — existingprofiles.ymlfiles are unaffected); a commented example ships in theabk initseed. The command exits non-zero on any send failure / misconfiguration (the CLI-is-the- automation-unit convention). Pure Python, no new dependency (requestswas already a dependency);abkit.statsuntouched, noALGORITHM_VERSIONmoved. Covered bytests/notify/test_channels.py+tests/cli/test_test_report_command.py. - M6 WP-A —
abk plangains runtime + ASN (read-only, no stats-core change). Given a unit-arrival rate — derived read-only from_ab_exposures(newget_arrival_rate: distinct units per observed day, whole-cohort window, split to the control arm) or supplied via the new--arrival-rate <units/day>flag — each sizable comparison now also reports runtime (days-to-required-N = required_n / rate+ the planned horizon) and, for asequential.enabledsequential-eligible design, the always-valid ASN (average sample number): the expected control-arm N at which the confidence sequence first excludes zero under the true effect (H1) and the null (H0). ASN is a deterministic fixed-seed Monte-Carlo estimate over the canonical information-time process, crossing the exact shipped CS boundary (abkit.stats.sequential) — it adds no estimator and moves noALGORITHM_VERSION;abkit.statsstays pure and byte-identical. No arrival data ⇒ runtime is SKIPPED with a reason (never guessed); a fixed-horizon/resampling design ⇒ASN n/a. Honest framing: the always-valid design’s sample requirement (N to reach a given power) is larger than the fixed required-N (the Robbins mixture CI is wider by design — the price of unlimited peeking), so the CS never lets you design for fewer units at the same power. The reported ASN is a different quantity — the expected stopping N, horizon-capped — guaranteed only against the horizon (ASN_H1 ≪ horizon-N; ASN_H0 ≈ horizon-N; monotone in effect); vs required-N it is regime-dependent (can dip below in the underpowered/horizon-capped case, which the CLI line flags). The Monte-Carlo estimate is cross-validated against an independent scalar first-passage simulation in the tests. - M6 WP-B — the A/A composed sweep gains its always-valid (peeking) twin (no
behavior change to the shipped single-look family).
abk validate’s composed multi-metric family sweep now mirrors the per-cell D8 trio at the family level: alongside the unchanged single-lookfwer/fdr, it composes a matched peeking pair over the same shared placebo assignments —fwer_peeking/fdr_peeking(each member’s fixed CI peeked across every look: the composed optional-stopping hazard, inflated) andfwer_sequential/fdr_sequential(the always-valid twin via the identical D8 estimator: controlled, ≈ the single-look rate). Gated on a sequential-eligible family (≥1 member has a frozen τ²); an ineligible member (bootstrap — unscorable from suffstats) is a full gap in every family, disclosed by the existing “scored in 0 iterations” warning. The numbers persist additively in the_ab_aa_runssentinel row’sdetails.family(no new schema column); the report’s composed band renders a “peeking → always-valid” recovery stat. This is a validate-layer MODE transform reusing the M5 D8 estimator verbatim — noALGORITHM_VERSIONbump, no stats-core number changed, the single-look family byte-stable (sequentialdefaults off). Closes the last non-alpha_spendingA/A deferral (aa-false-positive-matrix.md §8.1). Pinned by the D8×D9 headline tests intests/validate/test_family_sweep.py+ the sequential-matrix e2e. - M6 WP7a — the abkit docs + marketing website (
website/, Astro + Starlight). A single-source site built from thedocs/body viasync-docs.mjs, on the real Iris brand (brand.css, light+dark, name-locked to the bundles’--abk-*token layer), with the “Diverge” logo/favicon, a landing page, and an interactive stabilization-chart demo whose JS compute path is golden-pinned toabkit.stats(hard demo-parity CI gate).web/andwebsite/are now an npm workspace (single root lockfile); a Docker-freewebsiteCI job runs sync +astro check+ build + demo-parity. The live deploy (Dockerfile → GHCR →abkit.pipelab.dev) is a separate gated step. Renderer bundles unchanged. - M6 — user-facing docs body + brand source-of-truth. The
docs/guide/reference tree (WP3) and the finalized Claude Design brand deliverables underdocs/design/(brand-tokens.md, logo SVGs, mockups) that the site and surfaces build on. - M6 WP2 —
abk init-claude+ packaged Claude Code context. New command that installs AI-assistant context into a user’s abkit project (idempotent, version-stamped, re-runnable after upgrade): a marker-delimited managed block inCLAUDE.md(existing content preserved; a stale versioned marker is refreshed in place), the 9 reference rules under.claude/rules/ab-analysis-kit/(overview, cli, project, experiments, metrics, methods, explore, validate, plan), and the 7abk-*skills under.claude/skills/(setup-project, new-experiment, new-metric, explore, validate, plan, feedback). The source tree ships in the wheel (abkit/cli/assets/claude/**) and is read viaimportlib.resources. Ported from the detectkit donor (cli-and-dx.md §5); mechanism domain-agnostic, content authored for A/B analysis and fact-checked against the M5 as-built engine. - M6 WP4 — BI reference queries + dashboards (
docs/examples/bi/). Connect Grafana / Lightdash / Metabase / Superset to the_ab_resultscontract table:queries.sql(8 tool-agnostic recipes — headline scoreboard, the effect+CI stabilization chart, raw/CUPED arm values, significance-vs-effective-alpha, MDE/power, cross-experiment board, freshness, config-drift detector),srm_panel.sql(the SRM validity guard), one importablegrafana_dashboard.json(ClickHouse), and a README documenting the five hard invariants (readFINAL; group bymethod_config_id; compare to the row’s two-tieralphanot 0.05; respect the pre-horizon peeking guard viais_horizon/ci_kind; handle NULLs). Guarded bytests/reporting/test_bi_examples.py, which fails if a recipe drifts from the real_ab_resultsschema. Docs/SQL only — no runtime code. - M6 WP6 — Prefect deployment scaffold.
abk initnow also scaffoldsrunners/prefect.yaml(a Prefect 3 project-deploy config —prefect deploy --allschedules the dailyabk run) beside the existingrunners/prefect_flow.py. Documents thetag:actualconvention the daily job relies on (tag live experimentsactual; the demo is taggedexampleso the schedule skips it) and pins the targeted Prefect major. The[orchestration]/[all]extras now requireprefect>=3.0to match the scaffolded syntax (abkit still never imports prefect). Scaffold test asserts the deployment is valid YAML and the flow parses. - M5 — sequential analysis, the always-valid CI,
abk plan, composed corrections. Opt-in (sequential: {enabled: true}, default off — the fixed-horizon series is byte-identical, noALGORITHM_VERSIONbump, goldens untouched). Landed so far (implementation record:m5-implementation-plan.md; math:statistics-changes.md §4.1):- The always-valid confidence sequence (
abkit/stats/sequential/) — an asymptotic Gaussian confidence sequence (Waudby-Smith & Ramdas normal mixture) computed as a pure experiment-level MODE transform over the fixed(effect, SE), never a method plugin. SE recovered by CI-inversion (preserving the delta-method covariance); the mixing varianceτ²is anchored to the first usable look (stable across runs, computable live). Rows carryci_kind='always_valid'. - The A/A matrix’s sequential side-by-side column (D8) —
abk validatenow measures the always-valid peeking FPR, power, and CI-width beside the fixed ones: where the fixed peeking FPR breaks budget, the always-valid twin returns to ≈α (the honest completion of the peeking story). Surfaced in the matrix report (a “peeking (AV)” column + a second curve) and the live explore calibration chip. - Pipeline activation — a plain
abk runon a sequential-enabled experiment emits always-valid rows.scheme: alpha_spending(group-sequential) is a clear “planned M6” config error. - The toggle self-invalidates (B4) — flipping
sequential.enabledon an existing experiment now re-plans the affected series in place on a bareabk run(no--full-refreshneeded):sequential.enabledis deliberately not inmethod_config_id, so the planner compares the persisted per-pairci_kindagainst the mode this run stamps and forces a full recompute on a mismatch — idempotent (a steady sequential experiment still plans zero) and robust to the first-usable-look τ² anchor legitimately leaving a later-usable pair fixed. - Explore threading (B5) — the live explore recompute now mirrors the baked
per-pair CI vocabulary so the cockpit never mixes fixed & always-valid on one chart.
A pair is widened live iff its persisted rows are already always-valid (a
read-view of what
abk runstored — so the multi-pair case where the anchor left a late-usable pair fixed, and a not-yet-applied config toggle, both stay consistent); each widened point uses the same first-usable-look τ² (the configured knob state reproduces the baked always-valid bounds — exactly for the closed-form families). α-inversion cannot honestly widen an already-widened persisted CI, so under the mode those cutoffs are dropped with a Reload hint rather than shown as a silent fixed CI; a switch to a sequential-ineligible method (bootstrap) turns the mode off. Server-only — no bundle change (the client draws whatever bounds the reply carries). - The readout reads always-valid rows early (WP4) — the pre-horizon withholding
that refuses WIN/LOSE/FLAT before the planned horizon now lifts for a row whose
persisted
ci_kindisalways_valid(a fixed row is still withheld). An early decisive verdict names its own justification (“called before the planned horizon under an always-valid confidence sequence — peeking-safe by construction”). The “covers X% of a weekly cycle” representativeness caveat on a sub-week verdict is promoted from a caveat bullet to a structuredweekly_cycle_pctrendered as a chip on the HTML report’s verdict card. The daily-SRM posture under sequential is settled (plan D9): daily & coarser keep the χ² gate (bounded looks on a ~3.3σ hard gate ⇒ negligible peeking inflation); only sub-day (a follow-up) swaps to the anytime-valid multinomial test. - Sub-day anytime-valid SRM (WP5) — below 1d cadence the SRM gate swaps from χ²
to an anytime-valid Dirichlet-multinomial e-process (Lindon & Malek 2022;
statistics-changes.md §4.2): a dense sub-day cadence would peek the χ² hard gate dozens of times a day → false alarms, whereas the e-process is valid at every look by construction. Dispatched onexperiment.is_sub_day()(daily & coarser are unchanged). One verdict per look, stamped from the cumulative as-of exposure counts (get_exposure_count_stream) — the truthful as-of series the M2 whole-cohort broadcast deferred — and it runs even on demoted rows. Default prior is the paper’s uniformDir(1,…,1); the anytime false-alarm rate holds ≤ α for any fixed prior. It is an additive gate, not a registered method: noALGORITHM_VERSIONbump, goldens untouched, no schema change (reusessrm_flag/srm_pvalue). abk plan— the read-only pre-launch power/sizing planner (WP6) —abk plan --select <exp> [--metric <m>] [--mde <pct>] [--power] [--alpha] [--baseline]reports, per comparison, the required sample size to detect a target MDE, the achievable MDE at the current size, and the achieved power — at the effective two-tier alpha — plus the projected look count and cost shape from the samegenerate_gridenumerationrun/config-lint use. Baseline moments come from the latest persisted_ab_resultsper-arm stats (a--baseline metric:mean=..,std=..,n=..override sizes a greenfield experiment); the target MDE defaults to the comparison’smin_effect. Strictly read-only — no lock, no_ab_*writes. Refuses what it cannot size honestly: ratio and bootstrap/resampling methods have no versioned power formula (SKIPPED, never invented math), and CUPED is sized on the raw persisted variance (ρ is not persisted per row) as a flagged conservative upper bound. Runtime / ASN (days-to-N from an arrival rate + the sequential design’s average sample number) are a named M6 deferral.- The composed multi-metric FWER/FDR family sweep (D9, WP7+WP8) — M4 validated only
the per-cell peeking FPR at the correct two-tier alphas; D9 closes the family-level
loop. The read-time composed rule (two-tier Bonferroni ∘ Benjamini-Hochberg) is
extracted from the readout’s inline
_build_sig_mapinto one shared pure helper (stats.correction.composed_significance, WP7) that the readout and the sweep both apply — a behavior-preserving refactor (goldens untouched, verdict-snapshot pinned).abk validatethen runs the sweep: each iteration draws one unit→arm assignment over the union of the metrics’ cohorts (the real single-assignment semantics; no imputation — a unit absent from a metric doesn’t contribute), scores every metric at its horizon, and tallies the empirical family-wise error rate (any false rejection) and false-discovery rate (mean false fraction among rejections). On the placebo (complete) null FWER and FDR coincide by construction, at the composed rule’s nominal rate (≈α per tier, so ≈2α whole-family under the default two-tier Bonferroni); the budget is anchored to that nominal rate so “over budget” flags a miscalibrated method (clustering), not a loose correction. A planted true effect in one metric leaves the null metrics’ family error controlled. Persisted as one sentinel_ab_aa_runsrow (metric='__family__', numbers indetails) — no schema change, never lights the per-cell calibration chip — and surfaced as a composed-family band above the report’s A/A matrix (report.jsrebuilt). Fixed-horizon only; sequential × composed is a named M6 follow-up.
- The always-valid confidence sequence (
- M4 —
abk validate, the A/A false-positive matrix. The trust artifact that answers “is this method actually calibrated on this data, or does it lie about its α?” (docs/specs/aa-false-positive-matrix.md; the implementation record ism4-implementation-plan.md):abk validate --select <exp> [--method <m>] [--metric <m>] [--iterations N] [--inject-effect <pct>] [--scoring fpr|power|mde] [--report] [--force]— draws N deterministic placebo A/A splits over the experiment’s own pooled cohort (label-permutation, an exact null by construction), scores each declared method’s empirical single-look FPR, cumulative-peeking FPR, power @ MDE, achieved MDE, CI coverage, and effect-exaggeration-at-stop, and persists one_ab_aa_runsaudit row per cell at the effective per-comparison alpha. Its own out-of-band lock (process_type='validate',abk unlock-clearable); non-zero exit on failure; stagesLOAD → RESAMPLE → SCORE → PERSIST(distinct copy fromabk run’s config-lintVALIDATE).- Honest peeking FPR — the naive optional-stopping hazard (CI-excludes-zero at
any look, pre-horizon refusal off), reported beside the single-look FPR so the
jump is visible, with the per-look cumulative curve. Deliberately not the readout’s
stabilized verdict (that is the defense);
pipeline/readout.pyis untouched. - The matrix UX — budget-band-colored FPR cells, an explicit Recommended row
(FPR-closest-to-nominal, max-power) with a truthful one-line rationale, plain-language
per-method verdicts, and the “nominal α 5%, real peeking FPR X%” headline. Rendered by
abk validate --reportreusing the committed report bundle (no third JS bundle) and surfaced live by the explore calibration chip. - Auto mode — a real server-side
POST /validate(was a 501 stub) runs a reduced validate, refreshessession.aa_rowsin place so the D3 chip greens without an explore restart, and re-seeds the knobs to the recommended config. The Apply gate is unchanged (an uncalibrated Apply still confirms). metric.aa_fpr_budget(a fraction in(0,1]) completes the budget resolver (metric → project → α×1.5); added to the §8 validation matrix.- No statistical numbers changed — validate reads the existing
from_suffstatsmethods; the goldens are untouched and noALGORITHM_VERSIONwas bumped.
-
M3 milestone review closure (the WP10 exit gate: 7 lenses / 17 raw findings, verified + inline-triaged — 13 real, all fixed; the full record is
m3-implementation-plan.md §5):- Apply writes are atomic: the final YAML overwrite goes through
temp +
os.replace(+fsync) — an ENOSPC/kill mid-write can no longer leave the live config torn while the reply claims nothing was written. - Guardrail regression is correction-independent: judged from the STORED CI bounds per D5(c) — BH adjustment can no longer un-flag a stored-significant harm and un-block a WIN (known-answer test added).
- SRM stays loud over an empty main series: the summary scans ALL comparisons’ series, so the state an explore Apply produces (main series empty under its new id, flagged rows elsewhere) no longer renders a green “SRM ok” chip.
- The D3 Apply gate keys role flips at the PROSPECTIVE alphas: posted
is_main/is_guardrail flips overlay the prospective experiment before
effective_alphas, closing the under-gating latent behind the empty_ab_aa_runs(server + regression test). - Ctrl-C cannot swallow a successful Apply:
serve_explorereturns the applied config even when SIGINT races the post-Apply self-shutdown window — the orphan/re-run epilogue always prints. - Stale mid-series horizons render honestly: both charts corroborate
a stored
hz=1row against the CURRENT config horizon, so anend_dateextension no longer paints later cutoffs as decision-grade solid CIs (§4). - Cockpit dirty-state fidelity:
editedkeeps FULL params (an edit back to a spec default no longer silently reverts to the configured value on a rail rebuild; wire bodies are minimalized at send time), and the confirm box’s “Apply anyway” runs the same preflight as the Apply button (a pending Tier-R edit can no longer ride into the YAML). - Orphan warnings survive unbindable legacy method blocks; the client
remembers a completed covariate
/reload(no redundant re-renders); the explore bake test assertshttps://too;build.mjsfails on</script/<!--tokenizer hazards inside a bundle; header period timestamps are labeled UTC next to the experiment-tz name.
- Apply writes are atomic: the final YAML overwrite goes through
temp +
-
M3 WP5/WP6/WP8 review-closure (adversarial review, 4 lenses / 25 raw findings; the verify fleet was limit-truncated, findings triaged inline — 7 real after dedup):
- The D3 calibration gate lost its side doors: correction-only and
role-flip-only Applies now gate too (a correction edit re-keys every
comparison; a role flip moves comparisons across the two Bonferroni
tiers), and the gate keys by the prospective EFFECTIVE per-comparison
alpha (
effective_alphasover the applied alpha/correction), not the raw body alpha — restoring the mechanically testable “every Apply takes the confirm path” DoD. Params carrying a riding"name"key are keyed exactly as the writer strips them; unbindable params gate conservatively instead of silently skipping the check. - Handler-thread hardening: a malformed
Content-Lengthheader and a non-numericalphain the/applybody are clean 400s (previously a dead thread with no HTTP reply);/applyis serialized under the request lock (two tabs cannot race the archive/rewrite seam or the shared CLI-thread DB manager) and a second Apply after a successful one is a 409; the self-shutdown thread now spawns in afinally, so a client that vanishes mid-reply can no longer leave the server alive with the YAML already rewritten (Ctrl-C would then have lied “experiment unchanged”). /reloadrefuses on a budget-degraded (suffstats-only) session instead of silently growing a shadow cache the replies keep contradicting, and keepssession.cache_valuesaccounting exact when replacing entries.- The HTTP
comparisonsparser preserves an ABSENTparamskey asNone(the writer’s “a method switch must carry the full param set” guard was bypassable with a fake{}); the provenance header sanitizes newlines (no comment-escape injection into the emitted YAML); the WP5 role-flip test now proves the promised per-comparison alpha shift on a three-comparison fixture (0.05 → 0.025), not a structural equality.
- The D3 calibration gate lost its side doors: correction-only and
role-flip-only Applies now gate too (a correction edit re-keys every
comparison; a role flip moves comparisons across the two Bonferroni
tiers), and the gate keys by the prospective EFFECTIVE per-comparison
alpha (
-
M4 WP5 — the A/A calibration matrix report + payload block + metric budget (per
docs/specs/m4-implementation-plan.mdWP5/D10/D12):abk validate --reportnow bakes a self-contained matrix page by reusing the committed report bundle (no third JS bundle) — the report/ explore payload’s reservedcalibrationblock is filled from the latest_ab_aa_runsinvocation (abkit/reporting/calibration.py), so the offline readout and the live explore chip both surface the “nominal α X%, real peeking FPR Y%” headline, the per-method matrix (FPR coloured against theaa_fpr_budgetband, the Recommended row + rationale, plain-language verdicts), and the recommended cell’s cumulative peeking-FPR-vs-looks curve. The scorer now emits that monotonepeeking_curve(one point per grid look, ending at the reported peeking FPR — the “peeking is the product” visual). AddsMetricConfig.aa_fpr_budget(a fraction in(0, 1]) completing theresolve_fpr_budgetchain (metric → project →α × 1.5). No payload version bump; no statistical-number change (goldens untouched). The standalone WP4 matrix template is retired in favour of the shared bundle. -
M3 WP7 — the explore cockpit client (per
docs/specs/m3-implementation-plan.mdWP7; data-contract §5.1 as amended by D9/D12): the browser half ofabk explore, ported from the detectkittune.tsskeleton toweb/src/explore/and committed as the wheel-shippedabkit/tuning/assets/explore.js(replacing the WP6 placeholder). The windshield: the stabilization chart with D1-tier-styled live segments (solid exact, hatched “approx (α-only)”, the persisted baseline always visible), §4 dashed pre-horizon CIs, greyed insufficient spans, run breaks at server-refused cutoffs, an off-scale indicator, and pinned chips (lift, ±CI, p, power, the D3 calibration chip incl. the alpha-mismatch downgrade, the red SRM gate, the sub-day look counter) re-keyed from every/recomputereply. The side rail is auto-derived fromparam_specs(Basic = method/CUPED/test_type/alpha; an Advanced disclosure for the rest + correction; identity ⚠ and Tier-R ↻ badges; the donor’s slider identity hazard ported). Tier-R edits route through a per-metric confirm →POST /reload; Apply follows the dirty-slot discipline (role-only entries carry no method key; minimal params) behind the uncalibrated-cost confirm mirroring the server gate, with the archive/orphan/abk cleanepilogue. The donor’s stale-drop discipline is re-expressed over HTTP: a monotonicrequest_idseeded fromDate.now()(re-seeded after a two-tab 409),AbortControllerkill-not-queue, stale replies never clear the spinner, the 130 ms debounce with the flush-before-switch trap. The client resolves raw alpha + correction to the effective per-comparison alpha by mirroringanalyze.effective_alphasover the newpayload["explore"]["experiment"]block (raw alpha, correction + choices,groups_count,non_main_count). Toolchain: a secondbuild.mjsbundle entry (marker-gated),--abk-explore-accentjoins the brand-token layer, the CI hex loop coverstuning/html.py, the wheel gate assertsexplore.js, a jsdom smoke suite drives the live half through a fakefetch, andtests/tuning/test_explore_bundle.pypins the bundle packaging + the alpha-mirror substrate. Reviewed: 11 findings fixed pre-merge (stale cached-reply adoption on metric switch, surfaced-subsetnon_main_count, two-tab 409 lockout, reload-pending Apply bypass, chart listener leak, and six more). -
M3 WP10 — the e2e exit gate (per the plan WP10):
tests/e2e/test_first_report.py(scaffold →abk run --report→ a verdict-bearing, self-contained readout with the baked payload asserted structurally; re-run byte-stable modulogenerated_at; a builder crash yellow-skips) andtests/e2e/test_explore_session.py(the real explore server over live HTTP: persisted numbers reproduced at rel-1e-9, Tier-E alpha recompute + α-inversion on a suffstats-only CUPED series, the stale 409, the Apply gate →.historyarchive → orphan block → self-shutdown). -
M3 WP8 —
abk explore(perdocs/specs/m3-implementation-plan.mdWP8; cli-and-dx §1): the cockpit shell —abk explore --select <exp> [--metric <m>] [--no-serve] [--no-open] [--profile]. Registered per the house pattern (eager stanza, lazy command body —abk --versionstays instant). Resolves exactly ONE experiment (selection errors name the namespace), guards a never-run project with the friendly “runabk runfirst” noop (D2), prints the startup orphan warning (the samelist_method_config_idsscan the driver andabk cleanuse), streams the session load through the houseStageLogRenderer, then serves the WP6 cockpit — or, with--no-serve, atomically writes the staticreports/<experiment>__explore.htmlsnapshot (null endpoints — the preview badge, Apply disabled).--metricnarrows the opened comparison (default: the main metric). The Apply epilogue echoes the archive path, updated/preserved comparisons, the orphan warning +abk cleanhint, and the “re-runabk run --select <exp>” reminder; Ctrl-C cancels with the experiment unchanged. All failures exit non-zero (the house rule). -
M3 WP6 — the explore localhost server + page + payload (per
docs/specs/m3-implementation-plan.mdWP6/D1/D3):abkit.tuning.server:build_explore_server/serve_explore— the donor’s exact interaction contract on127.0.0.1:0with a one-shot token: GET serves ONE pre-rendered page on any path (the token gates only POSTs);POST /recomputeanswers knob states from the in-memory session — repeatable, advisory, lock-serialized, stale-dropping (outdatedrequest_ids get409 {stale}before AND after the compute lock — debounced knob drags never queue behind an in-flight bootstrap) and silent;POST /reloadexecutes the confirmed Tier-R actions with its OWN manager inside the serialized handler (re-rendering cached cutoffs under the requested lookback — the session tracks per-entry render lookbacks so the refreshed cache serves subsequent/recomputes) and streams a run-log throughserver.echo;POST /validateis the reserved M4 slot (501);POST /applyis the only terminal action — the server-side calibration gate (D3:confirm_uncalibratedrequired while the applied(metric, method_config_id, alpha)keys are not green — with_ab_aa_runsempty until M4 every Apply takes the confirm path), the WP5 seam, theorphanedblock + warning echoed in the reply, then self-shutdown from a daemon thread. Invalid configs return 400 and KEEP serving; error detail travels in the UTF-8 body (never the latin-1 status line); oversized bodies drain-then-413; no pipeline lock is ever taken.abkit.tuning.html:render_explore_html— the WP3-hardened template mechanics verbatim (one-pass regex substitution, every<in the baked JSON escaped, no webfonts,abk-exploremount,__ABK_EXPLORE__global). Ships with a committed placeholderassets/explore.js(honest pending note) until the WP7 cockpit bundle replaces it — the wheel packaging contract was pre-wired in WP3.abkit.tuning.payload:build_explore_payload— the WP2 report payload riding verbatim + theexploreblock (knob surfaces fromparam_specs, per-metric initial calibration chip state keyed by the configured(method_config_id, alpha), session-cache facts, ms-epoch cutoffs) and the four endpoint slots (None= the static--no-servepreview badge).
-
M3 WP5 — Apply,
.history, orphan detection (perdocs/specs/m3-implementation-plan.mdWP5/D4/D9):abkit.tuning.config_writer:apply_tuned_config— the ONLY mutation seam ofabk explore, donor-disciplined validate → archive → re-emit: per-comparisonmethodblocks (matched by metric; a merely-viewed comparison is never written — the dirty-slot discipline), Review-modeis_main_metric/is_guardrailflips (marking only, D9), and experiment-levelalpha/correction, merged into the parsed document and validated as a whole (create_methodper touched method +ExperimentConfig.model_validate) before ANY filesystem write. Tunability is registry-derived (paired designs and cross-kind methods refused — never a hardcoded name set); identity-excluded params (seed,max_block_bytes) carry over from the slot being retuned via the specs.- The previous YAML is archived byte-verbatim (comments included) to
<dir>/.history/<experiment>/<experiment>-<stamp>.ymlbefore overwrite — repeated Applies each archive, same-second Applies de-collide, and discovery never picks archives up as live configs. Comments die on re-emit (owner-ratified D4); re-emission is isolated behind the ONE_reemit_yamlstrategy function so a comment-preserving ruamel backend can swap in later without contract changes. - Orphan detection (NEW vs the donor): old-vs-new
method_config_idper touched comparison through the single hashing path; an identity edit over a series with persisted rows yields theorphanedblock + the driver-identical warning (abk clean+abk run --selecthints) in the result, and the provenance header. Apply never auto-cleans or auto-runs; alpha-only edits and role flips are orphan-free by construction.
-
M3 WP4 — the explore recompute engine (per
docs/specs/m3-implementation-plan.mdWP4/D1/D3/D11/D12):abkit.tuning.session:load_session— the one warehouse load pass at explore start (D2): the persisted per-comparison series plus the bounded Tier-S per-unit cache (latest cutoffs first, older newest-first under a ~2×10⁷-value budget; over-budget degrades honestly to a suffstats-only session with a reason string, never a silent partial cache).abkit.tuning.recompute:RecomputeEngine— one knob state answered entirely in memory (D1, “no warehouse round-trip per knob change”): Tier E exact suffstats reconstruction across the whole grid for the closed-form families (t-testm2 = std²·n; z-testnobsinverted from the persisted SE — never from the one-row-per-unitsize_i; ratio-delta via the exact denominator≡1 surrogate; CUPED→t-test “CUPED off” rides the persisted ORIGINAL per-arm mean/std), Tier α alpha-inversion for closed-form rows (symmetric normal CIs only — resampling families are declaratively excluded), Tier Sfrom_samplesover the session cache (bootstrap knobs, the stratify toggle, CUPED param edits) with the per-row seed re-derived by the persisted convention so unchanged knobs reproduce stored rows byte-exactly, and Tier R classification for CUPED off→on /covariate_lookbackedits (the serialized/reloadexecutes them, WP6). Per-pair points carry an exact/approx/baseline tier; windshield chips (lift, CI half-width, p-value, achieved power atmin_effectwith honest capability notes); the livemethod_config_idhashed only through the bound-probe path; knob metadata auto-derived fromparam_specs(nothing special-cases a method name; a suppliedseedis ignored with a warning);QuarantinedMethodErrorsurfaces verbatim.find_calibration+resolve_fpr_budget(D3): the calibration chip lookup keyed by(metric, method_config_id, **alpha**)against the as-built_ab_aa_runs(status='failed'/FPR-less rows never count; alpha edits downgrade toalpha_mismatch; identity edits flip to uncalibrated — that IS the staleness semantics); budget resolves metric-seam → projectaa_fpr_budget→α × 1.5.pipeline.analyze.build_containeris now public (shared by the engine’s Tier-S path — byte-identical containers to the pipeline);InternalTablesManager.aa_runs_table_exists()guards chip reads on a never-validated project. Sidedness + winsorization stay OFF the knob surface (D12) — deferred to M4 under change control (ROADMAP note).
- M3 WP4 review-closure (adversarial review, 4 lenses / 15 findings, the
blocker empirically reproduced by an independent verifier):
RecomputeEngine.recomputegained theanalyze_cutoff-parity gate: a paired or cross-kind knob state (e.g.t-teston a fraction series, whose persistedstd_iis the SE, not a sample std) now raisesMethodParamErrorinstead of returning a silently ~nobs-fold-collapsed CI labeledtier="exact"(the confirmed major).- Tier E now refuses rows whose per-arm columns don’t carry mean/std
semantics: a resampling series with a non-mean
stat(e.g. median bootstrap) persists the bootstrapped statistic invalue_i— such rows recompute only through the Tier-S cache (correct) or stay gaps, never “exact” numbers off the median. Unknown/quarantined legacy row methods are likewise never reconstructed. - New declarative
BaseMethod.requires_covariatecapability flag (CUPED + post-normed families): the Tier-S cache gate reads it instead of guessing from param names, sopost-normed-bootstrap— which needscov_arraybut has nocovariate_lookbackparam — yields an honest gap on a covariate-less cache instead of an unhandledSampleValidationError. - Demoted (
insufficient_data) and NULLed (H5) rows now pass through the reply untouched as flaggedbaselinepoints (NULL test columns, real sizes) instead of vanishing; the windshield chips read the latest point with inference, so a demoted latest cutoff no longer blanks or shifts them silently. - Point
size_ikeeps the persisted unit-count semantics across every tier (a fraction result’sround(nobs)no longer makes sizes jump between tiers of one series; the method sizes stay on the rawresult); the fraction power chip solves on trial counts (nobs) from the reconstruction, falling back to SE-inversion. - The session load clamps the cache during the latest-cutoffs pass, bounding
the transient peak near the budget in the exact scenario the clamp exists
for;
knob_surfaceadditionally exposesneeds_covariateper method, thecorrection_tier(correction resolves to the effective alpha upstream — the WP4 DoD’s experiment-level-knob classification), and the cache’scovariate_cutoffs(the WP7 ↻-badge substrate).
Changed
Section titled “Changed”- D11 — canonical unit order in
load_metric(M3 WP4; recorded instatistics-changes.md §8; a pipeline-level input-assembly fix, NOALGORITHM_VERSIONbump): every variant’s per-unit arrays are sorted by unit key after fetch, making order-dependent bootstrap replicates reproducible across physical warehouse read orders (ClickHouse guarantees none). Bootstrap rows persisted before the sort may differ from re-computed ones on backends that happened to return a different order; closed-form results are order-invariant.
- M3 WP3 — the self-contained HTML readout +
abk run --report(perdocs/specs/m3-implementation-plan.mdWP3/D7/D8):abkit.reporting.html_report:render_report_html(payload)— one offline HTML per experiment (baked payload + the inlined committedassets/report.jsbundle; framework-free, zero network requests, no webfonts — the donor’s Google-Fonts links are deliberately dropped). Template mechanics per the donor (escaped title; data-URI favicon; never.format), hardened past it after the WP3 adversarial review: the baked JSON escapes every<as\u003c(escaping only</leaves the HTML tokenizer’s<!--+<scriptdouble-escaped state able to swallow the real terminator), placeholders substitute in one regex pass (a payload string containing__REPORT_JS__can no longer be clobbered), and the CLI writes the file atomically (temp +os.replace) so a mid-write failure never truncates a previous good report.web/— the dev-only bundle toolchain (D7):web/src/shared/payload.ts(the §5.3 contract in documented lockstep withbuilder.py),web/src/shared/chart.ts(canvas primitives + the one placeholder brand-token layer per branding-and-site.md §3),web/src/report/report.ts(the experiment-primary renderer: verdict banners with rationale/caveats/ guardrails, the stabilization chart — effect + CI vselapsed_days, zero line, horizon marker, wheel-zoom/drag-pan/hover — four one-axis small multiples (variant means incl. CUPED covariate, pair MDE vsmin_effect, p-value vs α, client-derived avg group size), a results/audit table, the red SRM gate chip, the calibration empty state “uncalibrated — runabk validate(M4)”, and the sub-day look counter). Built byweb/build.mjs(esbuild, IIFE, es2019) into the committed, wheel-packagedabkit/reporting/assets/report.js.- Peeking honesty rendered per data-contract §4 with stable
machine-checkable markers: pre-horizon fixed CIs dashed/de-emphasized
(
abk-prehorizon),insufficient_datacutoffs greyed with counts+SRM only (abk-insufficient), the SRM chip (abk-srm-fail); asserted by the build script, the Python suite, the jsdom smoke suite, and a new CIbundlejob that rebuildsweb/and diffs the committed assets (freshness gate). abk run --report(D8, the donor’s tri-state flag): bare →reports/<experiment>.html, a directory →<dir>/<experiment>.html, a.htmlvalue → that exact file. Emitted per experiment after its pipeline best-effort — a report failure yellow-skips and never fails the run (the one recorded exception to the CLI exit-non-zero contract) — and even with zero pending cutoffs (the re-run-to-report path).--reportwith--steps validateis rejected; one.htmlfile with multiple selected experiments is rejected. cli-and-dx §1’s never-wiredreadout--stepstoken is amended away (D8).- Payload series points gain per-arm keys
v1/v2/sd1/sd2/cv1/cv2(stored value/std/CUPED covariate means) — additive, no schema v-bump — feeding the §5.2 variant-means/lift view; §5.3 amended,payload.tslockstep.
- MDE solve crash + report cost (M3 WP2 review-closure, adversarial
re-verification):
abkit.stats.power— statsmodels’solve_powerreturns a shape-(1,)ndarray from itsfsolvefallback for a data-dependent few-percent of ordinary(nobs, ratio)inputs (e.g. n=139, ratio=1.0); under numpy ≥ 2.0float(ndarray)raised, crashing the readout verdict and report MDE paths._as_scalarnow extracts the value (value-preserving — golden tests unchanged, zero statistical numbers changed). And the report payload’s per-pointmdereads the storedmde_1/2columns only (null when the row did not compute MDE) instead of a read-time statsmodels solve per point — the read-time D5(b) fallback stays verdict-level (one solve per pair on the latest cutoff). A worst-case sub-day payload dropped from ~40–100 s (and a hard crash) to milliseconds; data-contract §5.3 amended. - Payload consistency (M3 WP2 sweep-closure, second review pass):
- per-point
mdenow honours the D5(b) both-present guard — a half-present stored pair (one arm’s MDE solved to inf and was NULLed by enrich) shows null, never the finite arm alone (which would fake adequate power and contradict the verdict on the same cutoff; review finding). srm.observedis the whole-cohort count even under a pinned-endreplay, so it stays coherent with the whole-runsrm.flag/pvaluethe driver computes once and broadcasts (theuntil=pin is dropped; per-cutoff SRM lands with M5 sequential). §5.3 amended.
- per-point
- SRM chip loudness under replay (M3 WP2 final-gate, third review pass):
the payload
srmblock is now window-independent (current experiment health) —flag/pvaluecome from the latest persisted row overall via a newreadout.srm_summary, not the latest charted row. A pinned or empty replay window therefore never silences a failing SRM gate (§6 must-fix) and the flag/pvalue stay coherent with the whole-cohortobserved; the chart and verdict remain as-of the window.readout’s experiment-level SRM aggregation is extracted intosrm_summary(no behavior change toevaluate). §5.3 amended.
- M3 WP2 — the experiment-primary report payload (per
docs/specs/m3-implementation-plan.mdD6):abkit.reporting.builder:build_report_payload(experiment, tables, ...)— one versioned JSON-serializable payload per experiment from persisted_ab_resultsrows, the shared contract for the WP3 readout renderer and the WP6/WP7 explore shell: WP1 verdict block, experiment-level SRM block (driver-mirrored zero-filled exposure counts), M4-shapedcalibration: null,look: {n, planned}from the one-enumeration planner grid, terse ms-epoch series points, NaN and ±inf → null, provenance projection (rendered SQL never enters the payload; onemetric_queryper metric), metric descriptions from the metric YAML, caller-suppliedgenerated_at, inclusivestart/endwindow pinning (historical readout replay), a global point budget with trailing-window clipping + a loud payload warning, and the full-key empty-experiment contract. Zero statistical numbers changed.InternalTablesManager:results_table_exists()/exposures_table_exists()— the never-run-project guards for read-only surfaces (reporting never creates schema). (A short-liveduntil=bound onget_exposure_countswas added here and then removed in the review passes below — the SRM block is whole-cohort/window-independent; see the Fixed entries.)- Review-driven consistency rules (adversarial review, 4 lenses): rows for
variant pairs outside the declared arms are excluded from every payload
surface with a loud warning (never silently mixed into look/period/BH);
the driver’s orphaned-
method_config_idscan is surfaced as a payload warning on the read path too. - Specs amended (data-contract-and-reporting.md §5: subsections numbered 5.1/5.2, the D2 explore data-source rewording, new §5.3 payload contract; §2 metric-description sourcing note).
- M3 WP1 — the readout decision core (per
docs/specs/m3-implementation-plan.mdD5):abkit.pipeline.readout: pure read-time WIN/LOSE/FLAT/INCONCLUSIVE verdicts over persisted_ab_resultsrows — SRM hard gate; pre-horizon withholding (extends to FLAT); elapsed-time stabilization over the trailingreadout.stabilization_days(default 7, floored at 3 informative cutoffs); FLAT gated onmin_effectvs the pair MDE with a read-time MDE fallback for t-test/z-test rows (the z-testnobsinverted from the persisted SE, never the unit count); guardrail regression under the owner-ratifiedguardrail_policy: block | warn; read-time Benjamini-Hochberg rescoring (pulled forward from the M5 roadmap line — compute-time BH rows carry the raw alpha); orphaned/unconfigured row filtering with warnings. Verdicts are read-time only, never persisted. Zero statistical numbers changed.- Experiment config:
readout: {stabilization_days, guardrail_policy}and per-comparisonmin_effect/desired_direction(read-time only — never part ofmethod_config_id); specs amended (data-contract-and-reporting.md §1, declarative-config.md §2).
- M2 — declarative config + DB layer + the recompute pipeline (per
docs/specs/m2-implementation-plan.md):abkit.core: duration parser (N{s,m,h,d,w}),TableModel/ColumnDefinition(+max_lengthfor MySQL key budgets), andperiod_planner— ONE pure grid enumeration (scalar + dense-early schedule cadence, experiment-tz midnight snapping, DST-safe, horizon always flagged) consumed by BOTH the validator’s look gates and the planner anti-join;data_lag: 0+ half-open windows reproduce*_wo_curr_dayexactly.abkit.database: generic CH/PG/MySQL managers with the quorum atomic lock primitive (PG single-statementINSERT…ON CONFLICT…DO UPDATE…WHERE; MySQL row-alias upsert with the claim verdict latched into a session variable; ClickHouse advisory claim with a deterministic read-back tie-break) and the greenfield_ab_*schema:_ab_experiments,_ab_exposures(persisted cohort),_ab_unit_state(replace-not-sum, keyed per source-table+column-set+unit+day; twice-run invariant tested),_ab_results(the BI contract incl. newwarnings/diagnosticsJSON columns — spec §2 amended),_ab_aa_runs,_ab_tasks; strictly-monotonic distinctcreated_atvianext_version_ts().abkit.config: pydantic Experiment (primary entity; cadence duration-or-schedule union; sub-day gates) / Metric (type + column roles) / Method (delegates validation ANDmethod_config_idto the stats factory — one hashing path; quarantined branches fail at validate time) / Project (statistical defaults +max_looks/warn_looks/min_units_per_arm) / Profiles (env-interpolated, lazy driver imports); the full declarative-config §8 level-2 validation matrix incl. the macro-usage lint and the peeking warnings; project-root discovery + the two-level selector.abkit.loaders: StrictUndefined Jinja with the authoritativeab_*built-ins and the packaged assignment macro (ab.exposed_units()— dialect-aware cohort dedup, both window predicates, exposure filter); exposure loader (idempotent per experiment; unit-in-two-variants is a hard error) and metric loader (one-row-per-unit REJECTED on violation with the GROUP BY hint).abkit.pipeline+abkit.compute: the v1 full-window recompute pipeline — lock → catalog → exposures once → SRM gate (blocking-but-non-dropping, broadcast to every row) → per-comparison anti-join plan (Python-computed watermark) → analyze (declarativeinput_kind/is_paireddispatch; two-tier Bonferroni; deterministic per-row bootstrap seeds;insufficient_datademotion) → enrich (the full contract row) → LWW persist; worker pool across experiments; backlog + orphaned-series warnings.abkCLI:run(validate/plan/load/compute steps,--full-refresh --from/--to, the inspectable effective-alphas echo, the redSRM FAILEDgate line),unlock,clean(method_config_id drift GC + orphaned experiments; dry-run default), andinit— a runnable example (z-test fraction + CUPED sample metrics, assignment SQL, a deterministic ClickHouse seed dataset, Prefect flow example) that round-trips through the real config classes and the L2 validator at scaffold time.- Tests: 905 (incl. an in-memory SQL-semantics fake backend, a synthetic warehouse that aggregates a real event log per rendered window, the machine-independent first-run e2e mirroring the seed generation rule, and a testcontainers ClickHouse e2e gate that runs where Docker is available).
- M2 stats-core additions (zero number changes; goldens untouched):
COVARIATE_LOOKBACK_PARAMon the two CUPED methods (the lookback is identity-bearing — a different pre-period is a different covariate series); declarativeBaseMethod.input_kind/is_pairedcapability attributes.
Changed (M2 recorded deviations — no statistical numbers changed)
Section titled “Changed (M2 recorded deviations — no statistical numbers changed)”-
Jinja precedence flip vs the detectkit donor:
ab_*built-ins WIN over caller context; a colliding context key raises instead of silently moving the analysis window. -
CLI exit codes: every
abkcommand exits non-zero on failure (the donor echoed and returned 0) — the CLI is the Prefect unit of automation. -
CUPED covariate mechanics (declarative-config §3/§4 amended): the covariate comes from a SECOND render of the same metric SQL over the fixed pre-period window with the exposure filter dropped (legacy semantics — the covariate is the same metric pre-period); the original
ab.covariate_window()conditional-aggregate sketch is superseded (its own spec example would have double-counted the pre-period under plainsum()). -
_ab_resultsgains nullablewarnings/diagnosticscanonical-JSON columns (plan R7) — the stats core’s human-readable failure signal is persisted, not lost to stderr; data-contract-and-reporting.md §2 amended in the same change. -
M1 — the pure statistical core
abkit.stats(importable standalone; numpy/scipy/statsmodels only). Data model:Sample/Fraction/RatioSample, sufficient statistics with the exact legacy mixed-ddof convention (np.var→ddof=0,np.cov→ddof=1),JointMoments,PairedSufficientStats, Welford/Chan-stable merges (accumulate). Plugin method registry + factory + canonicalmethod_config_id(sha256 over registry name + sorted non-default identity params, version appended only when >1; byte-exact-tested;seedidentity-excluded). Closed-form methods (t-test,paired-t-test,z-test,cuped-t-test,paired-cuped-t-test, the newratio-delta) with dual entry (from_samples≡from_suffstats); bootstrap family (bootstrap,paired-bootstrap,poisson-bootstrap,paired-poisson-bootstrap,post-normed-bootstrap,paired-post-normed-bootstrap) on a vectorised block-streaming engine with deterministic per-seed draws. Power/MDE (t-test, CUPED-deflated, proportions), Bonferroni (incl. the legacy two-tier scheme) + read-time Benjamini-Hochberg, SRM chi-square gate, deterministic seed derivation (rng.derive_seed). -
Tests (760+): golden tests vs an independent transcription of the legacy engine at rel-1e-9 (incl. the CUPED θ golden and a heavy-tailed sparse-revenue fixture), byte-exact identity-hash tests, bootstrap byte-stability / block-invariance tests, quarantine and known-answer tests (
ratio-delta≡t-testat denominator ≡ 1), A/A calibration smoke.
Changed
Section titled “Changed”- Engine-hygiene fixes H1–H10 applied per
statistics-changes.md§7 (M1 implementation record): Generator-based RNG + deterministic per-row seeds, baseline-faithful sign p-value default with the H4 plug-in as opt-inpvalue_kind: plugin, Hamilton stratum apportionment (quorum-mandated), Poisson mean-only guard, H5 zero-denominator NaN+warning policy, H9 point-estimate effect convention, named-stat registry (register_stat) replacing rawstat_funccallables; broken legacy ratio methods quarantined (never silently substituted). - Adversarial post-M1 review (8 finder angles → 30 verified findings) applied:
registry alias-shadowing guard + reload-safe re-registration; param range /
finiteness validation at construction (
power,n_samples,max_block_bytes);weight_methodremoved from Poisson schemas and rejected withoutstratify(a no-op value could forkmethod_config_id); two-tier Bonferroni supports main-metric-only experiments; paired methods drive through the uniformcompare()(a sequence ofPairedSufficientStatsis a list of ready comparisons); bootstrap memory cap accounts for index matrices + fancy-indexing temporaries; Poisson engine reuses one float64 weight buffer; stratified planning is a singlenp.uniquepass; power/MDE effect-size solves are LRU-cached;TestResult.to_dictderives from dataclass fields; purity ofabkit.statsenforced by test. - Project initiation contract. Architecture synthesized from the legacy
ab_testingengine (statistical baseline) and detectkit (architecture / DX), validated by a 5-lens adversarial subagent quorum (all approve-with-changes). See the master plandocs/ru/project-initiation-spec.mdand the specs index: architecture, statistics baseline + changes + legacy method catalogue, cumulative-intervals/compute strategy, declarative config, data contract & reporting, A/A false-positive matrix, CLI & DX, branding & site, and the quorum must-fix gate. - Development scaffolding (this session): packaging (
pyproject.toml,setup.py,MANIFEST.in,requirements.txt),pre-commit, GitHub workflows (CI, publish-to-PyPI on tags, website), a minimal importableabkitpackage with a workingabkCLI entry point (abk --version), and smoke tests.
Decisions
Section titled “Decisions”- Sub-day cumulative intervals (abk-intervals, 2026-07).
cadenceis a true duration with schedule support (dense-early grids first-class); NO hard time floor — the hard gate ismax_looks(look count is the dangerous variable, not the time unit);data_lagcompleteness watermark required below1d; window contract keyed on exclusive UTCend_tswith derivedend_date(daily parity byte-clean); fixed-horizon sub-day = monitoring mode (readout still refuses pre-horizon WIN/LOSE),sequential: always_validis the sanctioned early-decision path; early rows demoted viainsufficient_data, never hidden; anytime-valid sequential SRM below1d; A/A peeking-FPR runs the actual cadence grid + gains an exaggeration-at-stop column; unit-state stays day-grained (sub-day reads = closed-day state + current-day tail). Full record:docs/specs/cumulative-intervals.md§6. - CUPED covariate window resolved to fixed lookback (whole days, cadence-
independent) — the legacy growing window is incoherent at sub-day grain.
Record:
docs/specs/statistics-changes.md§5.
Locked decisions
Section titled “Locked decisions”- Greenfield storage (legacy dashboard is reference only); statistical math preserved as a baseline then improved deliberately.
- Fixed-horizon CI by default with honest cumulative-peeking FPR in
abk validate; sequential (always-valid) CIs opt-in. - ClickHouse-first; PostgreSQL/MySQL supported. Read-only exposures.
This section was authored pre-release and is cut into the [0.1.0] heading above
— the first tagged PyPI release (M1–M6). Roadmap: ROADMAP.md.