Skip to content

docs(decisions): 0252 — define dispersion and the two-week decline, ship the trend co-gate observe-only (#4766) - #5259

Merged
usirin merged 2 commits into
mainfrom
usirin/umut/define-grading-dispersion-and-decline-4766-CE855327
Aug 10, 2026
Merged

docs(decisions): 0252 — define dispersion and the two-week decline, ship the trend co-gate observe-only (#4766)#5259
usirin merged 2 commits into
mainfrom
usirin/umut/define-grading-dispersion-and-decline-4766-CE855327

Conversation

@usirin

@usirin usirin commented Aug 10, 2026

Copy link
Copy Markdown
Member

Two values the fabrika grading chain leans on — dispersion and a "two-week decline" — were named in the spec and never defined, and the second one was wired into a merge-blocking red. This PR writes both down as ADR 0252. It also rules that the trend co-gate ships observe-only: it reports its answer and cannot red a PR until the criterion has been watched against a real recorded series, which is the founder guardrail on this ticket.

Fixes #4766

What lands

  • .decisions/0252-grading-chain-dispersion-and-decline-criterion.md — the record.
    • dispersion is min(passed, runs − passed) over the graded axis's five runs: one integer, units of runs, 0–2 at five runs, derived from the five per-run verdicts alone. It rides the head-bound eval-result artifact as {"runs": 5, "passed": 3, "dispersion": 2, "verdict": "pass"}runs and passed too, so a reader re-derives it instead of trusting it.
    • It is defined, not dropped, and its consumer is named: the committed scorecard row and the human reading it. It gates nothing — no verdict in the merge gate branches on it. The spec's claim that the trend gate reads it is corrected; the trend gate reads the pass-rate series only.
    • A "two-week decline" is, over one (stage × surface × model) cell's pass-rate series across the committed scorecards: the trailing 14 days anchored on the newest point, split into two 7-day halves with at least 3 points each; declining iff the half-mean drop (rounded to 4dp) is strictly greater than 0.05 and max(new) < min(old); otherwise steady; fewer points is insufficient-data, which is never a pass. A worked five-row table is in the ADR so the discriminating unit test can be authored from it.
    • The co-gate ships observe-only. below-bar reds on the ruled 90% bar alone; the trend answer rides the same verdict as an advisory line and never changes an exit status. Arming it is a separate later ADR, ruled on the observe-only record.
  • packages/fabrika-cli/src/eval/README.md — a short pointer section so a builder inside the module finds the arithmetic.
  • .glossary/TERMS.md — canonical rows for dispersion and two-week decline.

Why the numbers are what they are

0.05 is the whole distance from a healthy 0.95 to the ruled 0.90 bar, and — the reason the comparison is strict — it is exactly one case in a twenty-case corpus. A threshold that fires on the corpus's smallest step is not a threshold, so the line is drawn just above it: one case regressing is steady, two cases (0.10) is declining. The comparison rounds the half-mean difference to 4dp first, because 0.05 is a value the input lands on exactly and in IEEE-754 that value straddles the constant — two arithmetically identical one-case regressions compute 0.04999999999999982 and 0.050000000000000044. The non-overlap clause is what separates drift from scatter — mean drop alone is cleared by a noisy flat series often enough to be useless, and it is also what makes the required "declining flags, flat or noisy does not" test writable. All three constants (0.05, 3 points per half, 14/7 days) are explicitly a starting point calibrated against the observe-only record before they may block anything.

Needs a ruling

Two forks this PR deliberately does not settle, because the ticket carries no ruling for them:

  1. When the trend co-gate gets armed. The ADR proposes the arming evidence — at least eight weeks of committed scorecards evaluated observe-only, zero flags on series a reviewer judges healthy, and either one correctly-flagged real decline or an explicit ruling to arm on the false-positive record alone. That is a proposal inside the record, not a ruled bar. The arming decision needs its own ADR and its own ruling.
  2. Whether below-bar regains the trend clause at arming, or stays a single-condition red permanently. This PR removes it for now on the guardrail's authority. The end state is the arming ruling's call.

Nothing here re-opens the 90% bar or the co-equal status of the trend gate (#4637 ruling 2). The trend still flags before the 90% line is crossed — flagging is what observe-only does. What is deferred is only its authority to block.

Deviations

Nothing else. Docs-only diff: pnpm typecheck green over the whole workspace, pnpm lint:worktree a clean skip (no biome-handled files changed), decisions-index validate clean. ADR number 0252 was picked after enumerating origin/main plus every open PR's .decisions/ additions (0235, 0237, 0250, 0251 are in flight).

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

No preview deploy

  • No preview deploy for this PR — its diff touches no deploy-relevant path, so no preview stack was minted and e2e is not applicable. (584c2e5)
  • web — Stage pr-5259 torn down.

@usirin

usirin commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

review-doc: FAIL @ c450c20 — changes-requested

Reviewed-head: @ c450c20

Class fanpipeline-cli class-probe classify --namespaces over the 3 changed files returns review-code + review-doc. Doc class here is .decisions/0252-grading-chain-dispersion-and-decline-criterion.md alone; .glossary/TERMS.md and packages/fabrika-cli/src/eval/README.md are code class (§DOC glossary + code-root carve-out) and carry their own review-code verdict in a separate comment.

§CP — both axes derived. Path clause: clean (live CODEOWNERS covers only /.claude/, /.claude-plugin/, /claude-plugins/kampus-pipeline/{skills,lib,agents,hooks}; no changed path matches). Content clause: guard-touchingpipeline-cli guard-content-probe classify --path .decisions/0252-… returns guard-touching (§CP, ADR 0164) [guard-vocabulary-match]. So the PR is blocking-set, by content. A @kamp-us/control-plane approval at head gates the merge regardless of this verdict; this FAIL is a changes-requested verdict on the artifact, not a merge-authority statement.

Read the PR head (§HEAD): every file under review sourced from c450c202 via git show "$PR_REF:<path>", never the launched checkout working copy.


The findings

F1 — BLOCKING. The 0.05 calibration sentence states the opposite of the criterion it justifies.

.decisions/0252-…md §3, Where 0.05 comes from, and how far to trust it:

Five points is one case in a twenty-case corpus and half the distance from a healthy 0.95 to the ruled 0.90 bar, so a single case regressing does not flag and two do.

Both halves are false against the ADR own arithmetic.

  1. "half the distance from a healthy 0.95 to the ruled 0.90 bar." That distance is 0.95 − 0.90 = 0.05. So 0.05 is the whole distance, not half of it. Half would be 0.025.
  2. "a single case regressing does not flag and two do." The ADR fixes the corpus at 20 cases, so one case regressing moves the pass rate by exactly 1/20 = 0.05. §3 states clause 1 as mean(old) − mean(new) ≥ 0.05, and the very next paragraph says clause 1 is inclusive at the boundary. A persistent one-case regression also clears clause 2: with the rest of the series flat, max(new) = min(old) − 0.05 < min(old). So one case regressing DOES flag. The stated calibration is the inverse of the specified behavior.

Why this blocks rather than being a copy nit. This is the only sentence justifying the only free constant in the record, and #4766 AC5 asks that the ADR make #4680 discriminating unit test authorable. A builder authoring that test from §3 would encode "one case regressing does not flag" as a steady fixture — and that test would fail against the criterion this same ADR specifies. That is the same "an implementer builds against an invented reading" failure #4766 exists to end, reintroduced one layer down. An accepted ADR body is immutable after merge, so this has to be right before it lands.

Remedy (author call, one of two):

  • make clause 1 strict (mean(old) − mean(new) > 0.05) and say so where the inclusivity is currently stated, which makes the sentence true as written; or
  • keep the inclusive boundary and rewrite the sentence to say one case regressing does flag (and drop or repair the "half the distance" clause, which is wrong under either choice).

F2 — the series-ordering key recordedAt is itself an undefined term.

§3 The input series: Order by the scorecard recordedAt, ties broken by file name ascending, and the whole window ([T − 14 days, T], split at 7 days) is anchored on the recordedAt of the most recent point.

Ground truth at this head: Scorecard (packages/fabrika-cli/src/eval/report.ts:106-113) carries decisionRef, framing, baseline, cellsno timestamp of any kind — and ScorecardCell carries none either. And this PR own .glossary/TERMS.md committed scorecard row (unchanged, three rows above the new ones) says verbatim:

Not ruled here: whether the file also carries the date / CLI-version / harness-version pins #4637 ruling 4 requires (the type carries none today) and how <date> is spelled — both are #4680 open calls

So the ADR window arithmetic depends on a field that does not exist, whose spelling the adjacent glossary row explicitly records as unruled. #4680 AC1 does require the scorecard be "dated", so the fix is cheap — but as written this is a criterion whose ordering key is an undefined name, which is the defect class this ADR was filed to close.

Remedy: one sentence naming recordedAt as the date pin #4680 must add (citing #4680 AC1), or naming a concrete fallback if the pin lands under another spelling. Either way it belongs in the record, not left implicit.

F3 — informational, not blocking.

The "twenty-case corpus" the 0.05 derivation rests on is a placeholder. packages/fabrika-cli/src/eval/README.md:472 says the 20-case figure is measured over a corpus-shaped stand-in, because the fabrika incident corpus (#4675) has not landed yet. Worth a half-sentence so a later reader knows the derivation basis moves. Not a FAIL — §3 already labels all three constants a starting point.


Acceptance criteria (#4766)

Doc hygiene

  • [PASS] House-format — frontmatter carries id/title/status/date/tags; ## Context / ## Decision / ## Consequences all present, house shape.
  • [PASS] Index row — no .decisions/index.md committed (correct, ADR 0126: discovery is ambient). Frontmatter status: accepted.
  • [PASS] ADR number 0252 is free — enumerated live: origin/main holds .decisions/ through 0250 (0250 landed via docs(decisions): 0250 — a fabrika hook whose verb cannot run fails open, loudly (#5079) #5250); every open PR .decisions/ addition is 0235 (docs(decisions): ADR 0235 — a fence carries zero expansions; everything dynamic lives inside the script #4614), 0237 (docs(decisions): 0237 — agent brevity binds messages, not board artifacts (#4694) #4703), 0251 (docs(decisions): 0251 — fabrika pins shared wire formats, and owns their tests (#4892) #5252). No 0252 on main or in flight. Frontmatter id: 0252 matches the filename.
  • [PASS] Links resolve0078-product-driven-decisions-by-default.md and 0092-gates-fail-closed-on-zero-scope.md both present at head; standard relative markdown, no wikilinks.
  • [PASS] No leaked local/home paths — the shared added-lines scan exits 0, clean.
  • [N/A] Supersession — 0252 supersedes no ADR; it corrects a claim in an issue body, which needs no ADR-side cross-link (the correction is recorded in §2 and carried to graded axis: the reused grader-agent judgment path at 5 runs and a median #4678 by Propagate ADR 0252 onto the grading-chain issue bodies (#4678, #4680, #4681) #5258).
  • [PASS] Status sanityaccepted is coherent: this is a ruled delegation discharged, not a proposal.
  • [PASS] ADR contradiction sweep (Step 4a) — 247 ADRs scanned, 208 live-accepted + uncited in scope, 8-entry shortlist opened and judged by hand: 0243 (stage × surface keying — agrees; the 0252 (stage × surface × model) cell key is the 0243 key), 0247 (a contract spec example value is derivable — scopes fabrika contract specs under cli-interface-convention.md, not ADRs; 0252 complies in spirit anyway, its printed values are re-derivable), 0244 / 0249 / 0236 / 0241 / 0229 / 0248 adjacent only, none rules on a question 0252 re-decides. No uncited same-question conflict ⇒ no amended-in-part remedy owed. Noting the tool own caveat: a lexical sweep cannot see a self-contradiction, which is what F1 is.
  • [PASS] Single Diátaxis mode — single-mode: explanation throughout; the worked table serves the argument rather than turning the page into a how-to.
  • [PASS] Clear, concise prose — active voice, concrete, no AI-tell density. F1 is a factual error, not a register problem.
  • [PASS] deviation-disclosure (§DEV) — the body carries three deviation classes in full Said/Did/Why/Disposition shape (scope narrowed against AC; an unruled judgment made inside the deliverable; a corrected upstream claim), each with its follow-up named. Nothing undisclosed that this gate could see.

Closing keywords

Whole-body case-insensitive grep for (fix|close|resolve)[a-z]* +#[0-9]+ returns exactly one hit: Fixes #4766. Closing set = {#4766} — correct. #4678, #4680 and #4681 appear in prose and code spans only, none reachable by a closing keyword, so none auto-closes on merge. #5258 exists (OPEN) and carries the three amendment blocks verbatim, so the residue is parked, not dropped.

The disarm, confirmed complete

No code path reds on the trend, because no trend code exists to red. Grep for below-bar|belowBar|two-week|twoWeek|declining across packages/fabrika-cli/src at head hits only this PR own README section and one unrelated prose line in src/ship/github.ts:538. Neither the #4680 trend nor the #4681 bar gate is implemented. The ADR (§4), the module README and the two-week decline glossary row all state the same thing — below-bar reds on the 90% bar alone, the trend answer never changes an exit status — so the three surfaces agree and there is no seam where an armed trend could survive. Disarm complete.

The "Needs a ruling" section is present in the PR body and names both open forks (when the co-gate is armed; whether below-bar regains the trend clause). Correct behavior — surfacing an open fork is not incomplete work, and it is not counted against this verdict.


Verdict: FAIL on F1 (blocking) and F2. Everything else on both checklists passes, and the substance of the record — the dispersion definition, the #4678 correction, the observe-only disarm, the worked table — is sound. Fix the §3 calibration sentence so it agrees with the criterion, and ground recordedAt; both are small edits to one section. The PR stays open, #4766 stays open and assigned. Re-request review at the new head.

review-doc does not merge, and this PR is §CP by content — a @kamp-us/control-plane approval at head plus the ship-it enqueue is the merge path (ADR 0135/0048), never a hand-merge.

Verdict-written: 2026-08-10T02:55:22Z

@usirin

usirin commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

review-code: FAIL @ c450c20 — changes-requested

Reviewed-head: @ c450c20

Class fanpipeline-cli class-probe classify --namespaces over the 3 changed files returns review-code + review-doc. My class is the two code-root/glossary files: .glossary/TERMS.md (§CLASS puts .glossary/** in has-code, #912/#919) and packages/fabrika-cli/src/eval/README.md (a *.md under a code root is code, not docs — the §DOC carve-out). The ADR .decisions/0252-…md is doc class and carries its own review-doc verdict in a separate comment. review-skill and review-design are not required: no claude-plugins/**/{skills,agents}/**, no .claude-plugin/, no apps/web/src/ path in the diff.

§CP — both axes derived. Path clause: clean — live CODEOWNERS covers /.claude/, /.claude-plugin/ and /claude-plugins/kampus-pipeline/{skills,lib,agents,hooks}, and no changed path matches. Content clause: guard-touchingpipeline-cli guard-content-probe classify --path .decisions/0252-… returns guard-touching (§CP, ADR 0164) [guard-vocabulary-match]. The PR is therefore blocking-set by content, so a @kamp-us/control-plane approval at head plus the ship-it enqueue is the merge path (ADR 0135/0048) regardless of this verdict. A clean path read settled nothing here — the content clause is what routes it.

Read the PR head (§HEAD): every file under review sourced from c450c202 via git show "$PR_REF:<path>", never the launched checkout working copy. No checkout, no working-tree mutation (§RO).

No build/test run, and none owed. The diff adds zero executable lines — two prose surfaces inside a code root. There is no behavior to exercise, so the gate is a read of the diff plus a ground-truth check of every factual claim it makes against the code at head.


The finding

F2 — the two-week decline row and the README section describe a window keyed on a scorecard field that does not exist, next to a glossary row that says so.

The new .glossary/TERMS.md two-week decline row and the new packages/fabrika-cli/src/eval/README.md section both specify a trailing-14-day window split into two 7-day halves. That arithmetic needs a per-point timestamp on every committed scorecard. At this head there is none:

  • Scorecard (packages/fabrika-cli/src/eval/report.ts:106-113) carries decisionRef, framing, baseline, cells. No date, no timestamp. ScorecardCell (report.ts:75-103) carries stage/surface/model/gradedRuns/passedRuns/passRate/spend/churn/netSaving/netNegative. No timestamp, and no dispersion either.
  • claude-plugins/fabrika/reports/eval/ — the committed-scorecard directory both new surfaces point the reader at — does not exist at this head. The series is a future artifact.
  • Most directly: the committed scorecard row already in .glossary/TERMS.md, three rows above the two this PR adds and untouched by it, says verbatim: Not ruled here: whether the file also carries the date / CLI-version / harness-version pins Decision (founder-decision-fork): what 'done' means for v2, and the eval bar #4637 ruling 4 requires (the type carries none today) and how <date> is spelled — both are committed scorecards, the trend co-gate, and the model-churn re-run contract #4680 open calls.

So this PR adds a glossary row defining a computation over a date field, three rows below a glossary row stating that the date field is unruled and absent. The two rows are read by the same person in the same file and they do not agree about whether the window is computable. The source ADR compounds it by naming the ordering key recordedAt as if it were a real field (that half is called out as F2 in the review-doc verdict).

Why this lands on the code gate and not only on the doc gate. .glossary/** is this gate class precisely because the glossary is where a term is supposed to be true against the code. A row that specifies arithmetic over a field the type does not carry is a claim-vs-ground-truth defect at exactly the surface this gate owns.

Remedy — small, and it belongs in the glossary row rather than only in the ADR: state in the two-week decline row that the ordering key is the date pin #4680 AC1 requires and that the pin is not on the Scorecard type yet, so the criterion reads as specified-ahead-of-its-input rather than as computable today. One clause. Alternatively amend the committed scorecard row to record that #4766 has now fixed the ordering-key requirement, so the two rows stop disagreeing.

Carried from the doc class, because it is the source these two files point at

Both new surfaces single-source their arithmetic to ADR 0252, correctly. But the ADR §3 calibration sentence is internally contradictory (F1 in the review-doc verdict): it says a single case regressing does not flag and two do, while its own inclusive mean(old) − mean(new) ≥ 0.05 clause plus a 20-case corpus makes one case regressing land on exactly 0.05 and therefore flag. Neither of my two files repeats that sentence — they carry only the criterion, which is stated correctly in both — so this is not a defect in the code class. It is why the PR as a whole is changes-requested, and why a PASS at this head would go stale the moment the ADR is amended.


Acceptance criteria (#4766) — as discharged by the code class

  • [PASS] Recorded where builders read itpackages/fabrika-cli/src/eval/README.md gains a section at the module a builder of graded axis: the reused grader-agent judgment path at 5 runs and a median #4678/committed scorecards, the trend co-gate, and the model-churn re-run contract #4680 actually opens, and it says outright to build the arithmetic from ADR 0252 not from the issue bodies, which still state it in prose. That is the right pointer shape: no re-derivation of the numbers at a second surface, so the two cannot drift.
  • [PASS] dispersion is defined where a reader of the code meets it — the .glossary/TERMS.md dispersion row gives the formula, the range, where it rides, and — load-bearing — its not-this column rules out the three wrong readings (a variance, a standard deviation, a per-run list) and explicitly rules out an input to the trend co-gate, citing the graded axis: the reused grader-agent judgment path at 5 runs and a median #4678 correction. That is the orphan-field defect closed at the vocabulary surface.
  • [FAIL] The two-week decline is written down at a surface a builder can implement from — the criterion itself is stated correctly and completely in both files, but the window is keyed on a scorecard timestamp that neither exists on the type nor is ruled, and the adjacent committed scorecard row says so (F2).
  • [PASS] The the ruled bar as the merge gate: 100% floor, 90% graded co-gated on trend, cost at or below baseline #4681 below-bar disarm is consistent across both code surfaces — the README section and the two-week decline row both state below-bar reds on the 90% bar alone and that the trend answer is advisory until a later ADR arms it. They agree with each other and with ADR 0252 §4. No third reading is introduced at either surface.

Code-class hygiene

  • [PASS] Glossary freshness contract (Step 3c) — the diff introduces no new code surface, so it owes no new TERMS.md row; it adds two rows for terms the chain already used undefined, which is the contract working in the intended direction. Both rows carry the house shape: definition, citation, and a populated not-this column.
  • [PASS] Links resolve.glossary/TERMS.md../.decisions/0252-…md resolves (.glossary/ → root → .decisions/). packages/fabrika-cli/src/eval/README.md../../../../.decisions/0252-…md resolves (eval → src → fabrika-cli → packages → root). Both targets are added in this diff and present at head. Standard relative markdown, no wikilinks.
  • [PASS] No leaked local/home paths — the shared added-lines scan exits 0, clean. All cited paths are repo-relative.
  • [PASS] Turkish/English law — both surfaces are technical English; no product/brand noun is anglicized and no technical identifier is Turkicized.
  • [PASS] No dependency / catalog surface touched — no package.json in the diff, so the catalog: invariant is not in scope.
  • [PASS] No fanned-mutation surface touched — no Fate.mutation, no worker feature code; the fanout contract is not in scope.
  • [PASS] Comments-earn-their-place — no code comments added; the ADR pointer lives in the module README, which is where a why-pointer belongs rather than inline.
  • [PASS] deviation-disclosure (§DEV) — the PR body carries three deviation classes in full Said/Did/Why/Disposition shape, each with its follow-up named (Propagate ADR 0252 onto the grading-chain issue bodies (#4678, #4680, #4681) #5258). Nothing undisclosed that this gate could see.

Checks the brief asked me to settle, and how they came out


Verdict: FAIL on F2. The code class is otherwise clean and genuinely well-made — the pointer discipline (arithmetic in one place, pointers everywhere else) is exactly right, and the dispersion row not-this column does real work. The one fix is a clause in the two-week decline row grounding the ordering key against a Scorecard type that carries no date today. The PR stays open, #4766 stays open and assigned.

review-code does not merge. This PR is §CP by content, so merge runs through a @kamp-us/control-plane approval at head and the ship-it enqueue (ADR 0135/0048), never a hand-merge.

Verdict-written: 2026-08-10T02:57:53Z

…ring key (#4766)

Repair round 3 on the two blocking findings against ADR 0252 §3.

F1 — the calibration sentence stated the inverse of the criterion. `0.95 − 0.90`
is the WHOLE 0.05, not half of it, and with an inclusive `≥ 0.05` clause one
regressing case in a twenty-case corpus lands exactly on the threshold and DOES
flag. Resolved by making clause 1 strict (`> 0.05`), which is the reading the
section's own "deliberately strict" stance and clause 2's strictness already
imply: 0.05 is the corpus's smallest step, and a threshold that fires on one
step is not a threshold.

Strictness alone was not enough. The boundary sits exactly on a value the input
can produce, and in IEEE-754 that value straddles the constant — two
arithmetically identical one-case regressions compute 0.04999999999999982 and
0.050000000000000044, on opposite sides of 0.05 under either polarity. So the
criterion now rounds the half-mean difference to 4dp before comparing, which is
~1e12 coarser than the double error and 1/500 of a case. Added the boundary row
to the worked table — the fixture a builder is most likely to guess wrong.

F2 — the window was ordered by a `recordedAt` the `Scorecard` type does not
carry. Rather than invent it: §3 now states the criterion is specified ahead of
its input and names #4680's date pin as the blocking dependency, with
`recordedAt` as the spelling to use (the word the spend ledger already stamps).
Carried to the `two-week decline` glossary row and the eval README so a builder
meets the same caveat at whichever surface they open.

The trend stays disarmed: no code path reds on it, and none is added here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@usirin
usirin force-pushed the usirin/umut/define-grading-dispersion-and-decline-4766-CE855327 branch from c450c20 to 584c2e5 Compare August 10, 2026 03:08
@usirin

usirin commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

review-doc: advisory — blocking-set PR (§CP — approval-gated)

Reviewed-head: @ 584c2e5

Repair round 3, re-gated fresh at the new head. Both prior verdicts (review-doc 5235371078, review-code 5235383641) were bound to c450c202f96164b7825233bab83ef9b04f6a6c06 and are superseded by the head move. Scope of this namespace: .decisions/0252-grading-chain-dispersion-and-decline-criterion.md.

§CP re-derived on both axes at this head. Path axis: clear — none of the three changed paths matches the canonical CONTROL_PLANE_RE (pipeline-cli control-plane-paths). Content axis: guard-touchingpipeline-cli guard-content-probe classify returns guard-touching (§CP, ADR 0164) [guard-vocabulary-match] on the ADR body. So this verdict is advisory (ADR 0111/0151 shape): it authorizes nothing on its first line. A @kamp-us/control-plane approval at this head is what authorizes the merge.

F1 — the calibration arithmetic and the strict/inclusive fork — [PASS]

The design call is right. Clause 2 was already strict; §3's stated stance is to prefer a missed decline over a false flag while the gate is unarmed; and 0.05 is exactly the corpus's smallest possible step (1/20). A threshold that fires on one step is not a threshold. Making clause 1 strict is the reading consistent with the rest of the section — the inclusive branch would have required rewriting §3's stance, not just one sentence. Taking the strict branch over the "keep the inclusive form, fix the prose" branch is the better of the two remedies the FAIL offered.

The prose now matches the spec exactly. Re-derived:

  • 0.95 − 0.90 = 0.05 = 5 points, so "the whole distance from a healthy 0.95 to the ruled 0.90 bar" is now arithmetically true (was "half").
  • 1/20 = 0.05, so "exactly one case in a twenty-case corpus" holds.
  • "one case regressing is steady, two cases (a 0.10 drop) is declining" — round(0.10,4) > 0.05 is true, so the second half holds; round(0.05,4) > 0.05 is false, so the first half holds.
  • The self-inverting claim from round 2 is gone: the criterion and the prose now agree that one case does not flag.

Worked table — all five rows re-derived independently, all correct:

row mean(old) mean(new) drop clause 1 round(d,4) > 0.05 clause 2 max(new) < min(old) stated verdict
1 0.97 0.89 0.08 true 0.90 < 0.96 true declining correct
2 0.95 0.95 0.00 false 0.96 < 0.94 false steady correct
3 0.94 0.873… 0.0666… (shown 0.067) true 0.99 < 0.85 false steady correct
4 0.95 0.90 0.05 false (strict) 0.90 < 0.95 true steady correct — the boundary row
5 insufficient-data (2 < 3 in newer half) correct

Row 4 is the fixture a builder would otherwise encode backwards, and it is now present, correct, and flagged in prose as the one row a reader is most likely to guess wrong. The table alone discriminates declining / flat-noisy / boundary / insufficient — the discriminating unit test #4680 requires is authorable from it.

The 4dp rounding rule (added beyond what the FAIL asked) — [PASS]

The straddle is real, and I reproduced it rather than taking the claim. Enumerating every one-case regression over a twenty-case corpus with three points per half (old half [a,b,c]/20, newer half [a-1,b-1,c-1]/20, IEEE-754 doubles), the arithmetically-identical difference computes as 17 distinct values spanning 0.0499999999999998220.050000000000000155. Both values the ADR names are in that set exactly: old 0.15, 0.95, 0.6 against new 0.1, 0.9, 0.55 gives 0.04999999999999982, and old 0.05, 0.05, 0.95 against new 0, 0, 0.9 gives 0.050000000000000044. Under a raw strict comparison, 8 of the 17 return true and 9 return false — opposite answers for the same arithmetic. The "straddles under either polarity" claim holds: the inclusive form splits the same set.

4dp settles it. round(x, 4) maps all 17 values to the single double nearest 0.05, so the comparison is false for every one of them — the boundary is deterministic regardless of which cases moved.

Both coarseness claims check out. Max observed deviation from exact is about 1.8e-16; 1e-4 / 1.8e-16 is about 5.6e11, so "about a trillion times coarser than the double error" is right to the order of magnitude ("about" is doing honest work). 1e-4 / 0.05 = 1/500 of a case — the suppression window is at worst 1/1000 of a case in either direction, far below anything a twenty-case corpus can express. Coarse enough to absorb the error, fine enough not to swallow a real signal: confirmed.

Two implementers converge. Math.round(d*1e4)/1e4 and Number(d.toFixed(4)) both yield the double nearest 0.05 for every value in the straddle set, and comparing that against the same double literal gives false on both. A decimal library gets exact 0.05 and also false. The comparison is written literally in the ADR, with the application point stated ("applied to the half-mean difference, immediately before the comparison") and the scope fenced ("the only rounding"). Clause 2 is correctly left unrounded — IEEE division is correctly rounded, so mathematically-equal rates land on identical doubles and the non-overlap clause has no straddle to settle.

Adding this beyond the FAIL's ask was the right call: strictness alone would have left the boundary non-deterministic, which is the same defect one layer down.

F2 — the ordering key — [PASS]

Verified in source at this head, not taken from the repair note:

  • Scorecard (packages/fabrika-cli/src/eval/report.ts) carries exactly decisionRef, framing, baseline, cells. No timestamp of any kind. The ADR's enumeration is verbatim correct.
  • No field was invented. recordedAt is grounded, not coined: RunLedger.recordedAt (packages/fabrika-cli/src/eval/spawn.ts, documented there as "stamped once and copied onto every spend row") and SpendRow.recordedAt, minted at packages/fabrika-cli/src/eval/command.ts:467. The ADR's "the same word the spend ledger already stamps on its header and every row" is exactly true — header and row, both.
  • The blocking dependency is named as blocking, not as a caveat: "specified ahead of its input: it cannot be evaluated until committed scorecards, the trend co-gate, and the model-churn re-run contract #4680 lands the date pin its own AC1 requires. That dependency is the blocker, not a caveat." Repeated in Consequences ("blocked on one input it does not own… this record is a specification, not a computation"), and again in the glossary row ("Not computable yet") and the module README ("You cannot build the trend read yet").

Cannot be misread as claiming the field exists today. The first mention ("Order by the scorecard's recordedAt") is followed immediately by a bolded paragraph stating the key does not exist, and the same correction lands in three further places. recordedAt is explicitly a proposed spelling, with the deference written in: "If #4680 rules a different spelling, the ordering key is whatever it rules; nothing else below changes."

Regression checks — [PASS]

  • ADR numbering. origin/main tops at 0250; 0251 is held by open PR docs(decisions): 0251 — fabrika pins shared wire formats, and owns their tests (#4892) #5252 (.decisions/0251-shared-formats-are-pinned-not-reimplemented.md, OPEN); no other open PR adds a .decisions/ file. 0252 is correct and uncontested. Frontmatter id: 0252, the H1, the filename, and both inbound links (glossary ../.decisions/0252-…, README ../../../../.decisions/0252-…) are consistent and resolve. The 0078 and 0092 link targets exist.
  • Closing set = exactly {#4766}. Case-insensitive whole-body keyword scan over the PR body returns one hit, Fixes #4766. #4678 / #4680 / #4681 / #5258 appear only as bare refs or links with no closing keyword adjacent. Both commit messages carry no closing keyword either (they carry (#4766) in the subject, which is not a closing form). The draft's the fixture #4680's… construction is gone — the surviving sentence is "That row is the one fixture the discriminating test in committed scorecards, the trend co-gate, and the model-churn re-run contract #4680 most needs", where the two tokens are not adjacent and no closing keyword precedes the ref. Propagate ADR 0252 onto the grading-chain issue bodies (#4678, #4680, #4681) #5258 is OPEN and carries the three amendment blocks.
  • "Needs a ruling" section present in the PR body, both forks intact (when the co-gate is armed; whether the bar gate regains the trend clause).
  • PR body amended, not rewritten. The two original Deviations entries stand verbatim; two (repair round 3) entries are appended below them. The stale inclusive threshold and the false "half the distance" sentence are gone (scan returns nothing for either).
  • No local, home, absolute or sibling-repo path in any of the three changed files.
  • CI at this head: 41 pass, 4 skipping, 0 failing — including the dead-internal-link check and the CLAUDE.md path-pointer check.

Acceptance criteria (issue #4766)

  • [PASS] AC1 — dispersion fully defined: measure (minority-verdict count), shape (one integer), units (runs), range (0–2 at five runs), derivation (min(passed, runs − passed) from the five per-run verdicts alone), and artifact representation (the three-integer JSON shape carrying runs and passed too, so a reader re-derives rather than trusts). Landed in the ADR plus glossary plus module README rather than as an amendment on graded axis: the reused grader-agent judgment path at 5 runs and a median #4678 — see the standing Deviation below.
  • [PASS] AC2 — the consumer question is settled both ways: a consumer is named (the committed scorecard row and the human reading it), and graded axis: the reused grader-agent judgment path at 5 runs and a median #4678's "the trend gate later reads it" claim is explicitly corrected. Neither an orphan definition nor an orphan consumer remains.
  • [PASS] AC3 — the decline criterion is written down in full: input series (one point per committed scorecard, one series per cell key, never mixed), window (trailing 14 days anchored on the newest point, split at 7), minimum sample (3 per half), threshold (both clauses, strict, with the rounding rule), and the insufficient-data behavior (a third answer, never folded into steady — ADR 0092).
  • [PASS] AC4 — the bar gate no longer restates the criterion in prose: §4 rules the trend clause out of the red entirely while the co-gate is observe-only, which discharges the AC's intent (no implementer's threshold inside a merge gate) by a route the AC did not anticipate. The deviation is declared in the PR body's "Needs a ruling" fork 2 and left explicitly for the reviewer.
  • [PASS] AC5 — satisfiable: the five-row worked table discriminates declining / flat-noisy / exact-boundary / insufficient-data, which is precisely what committed scorecards, the trend co-gate, and the model-churn re-run contract #4680's criterion demands.
  • [PASS] AC6 — recorded where builders read it, in-repo: ADR 0252 plus the .glossary/TERMS.md rows plus a pointer section in packages/fabrika-cli/src/eval/README.md. The issue-body amendments ride Propagate ADR 0252 onto the grading-chain issue bodies (#4678, #4680, #4681) #5258 (see Deviation).
  • [PASS] AC7 — spec defect: adr sweep's scoring is unspecified — the contract prints example scores it cannot derive #4735 neither absorbed nor folded; untouched by this diff.

Deviations — reviewed, accepted

The scope-narrowing Deviation (four criteria ask for amendments on the bodies of #4678 / #4680 / #4681; this lane was authorized for #4766 only, so the amendment text was filed as #5258 instead) is declared, justified, and correctly disposed. Routing around the cross-issue-write refusal would have been the wrong call; surfacing it fail-loud with the exact text staged is the right one. The unruled-judgment Deviations (picking the three constants; writing the arming bar as a proposal rather than a ruled bar) sit inside the founder's explicit delegation to engineering, and the one thing that delegation does not cover — when the gate may start blocking — is correctly left as an open fork rather than decided here. Accepted, unchanged from the prior rounds.

Non-blocking notes — NOT grounds for a fourth round

None of these blocks. Recorded so a later editor has them; the ADR is accepted as-is.

  1. Rounding mode is unspecified. round(…, 4) does not say half-up, half-even or half-away. It does not matter here — a tie at the fifth decimal needs the half-mean difference to be an exact odd multiple of 5e-5, which the rate shapes in play (differences of the form j/3n) cannot produce for any corpus size under discussion. A future editor could close the last degree of freedom with three words ("round half away from zero"). Not a defect today.
  2. The first mention of recordedAt could carry an inline tag. It is corrected by the immediately-following bolded paragraph and by three other surfaces, so a reader cannot come away misinformed. A parenthetical ("proposed — see below") on the first mention would make it skim-proof too.
  3. Table row 2's overlap cell says overlaps where rows 1, 3 and 4 show the comparison inline. Purely cosmetic.

Verdict

PASS (advisory). Both prior FAILs are fully discharged, and the repair's unrequested extension (the 4dp rounding rule) is independently verified as correct, necessary and well-calibrated. No blocking finding remains; the three notes above are non-blocking and explicitly not grounds for a fourth round.

This verdict authorizes nothing on its own (§CP — approval-gated). A @kamp-us/control-plane approval at 584c2e5cc6ac7c9206578af1d11a587837e1096f is what authorizes the merge; ship-it then enqueues (ADR 0135).

Verdict-written: 2026-08-10T03:25:59Z

@usirin

usirin commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

review-code: advisory — blocking-set PR (§CP — approval-gated)

Reviewed-head: @ 584c2e5

Repair round 3, re-gated fresh at the new head. The prior review-code verdict (5235383641) was bound to c450c202f96164b7825233bab83ef9b04f6a6c06 and is superseded by the head move.

Class fan. pipeline-cli class-probe classify --namespaces over the three changed files returns has-code, has-docsreview-code + review-doc, two required namespaces (exit 0, non-empty). No path matches the live UI_RE, so review-design is not required and is not dispatched. This namespace's scope is the two code-root files: .glossary/TERMS.md and packages/fabrika-cli/src/eval/README.md. The ADR itself is review-doc's scope and is verdicted separately at this same head.

§CP re-derived on both axes. Path axis: clear — none of the three paths matches the canonical CONTROL_PLANE_RE (pipeline-cli control-plane-paths). Content axis: guard-touchingpipeline-cli guard-content-probe classify returns guard-touching (§CP, ADR 0164) [guard-vocabulary-match] on .decisions/0252-…. Control-plane by content, so this verdict is advisory (ADR 0111/0151): it authorizes nothing on its first line. A @kamp-us/control-plane approval at this head authorizes the merge.

No executable code changed. The diff is +228 / −0 on a new ADR, +2 / −0 on the glossary, +21 / −0 on a module README. Zero .ts files touched, zero behavior changed, no dependency added. So the code-class checks reduce to the disarm assertion, the glossary-freshness gate, and doc-in-code-root consistency.

The disarm — no code path reds on the trend — [PASS]

Verified independently at this head, not taken from the repair note. Grepping packages/fabrika-cli/src for two-week decline, trend co-gate and declining:

  • src/eval/README.md — five hits, all prose (the new pointer section plus pre-existing scope text).
  • src/ship/github.ts:538 — one hit, unrelated: a comment about "the platform declining to" report mergeable_state. Not the trend, not a gate.

No other hit anywhere in the package. The claim holds exactly as stated: no code path can red on the trend, because no code path reads it. The disarm is structural (the read does not exist yet), not a flag that could be flipped by accident. This is stronger than the ADR's own observe-only ruling requires.

Glossary freshness (Step 3c) — [PASS]

.glossary/TERMS.md is touched, so the freshness gate passes on its own terms. Reviewing the two added rows on substance:

  • dispersion row — matches ADR §1/§2 exactly: min(passed, runs − passed), integer 02 at five runs, carried on the eval-result artifact and the scorecard row beside runs and passed, "recorded, never gating". Its not-this column correctly excludes a variance, a standard deviation, a per-run list, a flakiness gate, and — the load-bearing one — an input to the trend co-gate, citing that graded axis: the reused grader-agent judgment path at 5 runs and a median #4678's prose claiming otherwise is corrected by 0252. Consistent with both the ADR and the README.
  • two-week decline row — carries the strict > reading, the 4dp rounding ("the difference is rounded to 4dp before the comparison so the boundary is decidable"), the non-overlap clause spelled as max(new) < min(old), the 3-point minimum, insufficient-data as a third answer that is never a pass, and the observe-only status. Its not-this column explicitly rejects "an inclusive ≥ 0.05 threshold (one regressing case out of twenty lands exactly on 0.05 and does not flag)" — which is the round-2 defect pinned as a negative example, the strongest form.

The F2 contradiction is resolved. The two-week decline row now carries "Not computable yet: ordering the series needs a per-scorecard timestamp, and the Scorecard type carries none — 0252 names recordedAt as the spelling for the date pin #4680's AC1 must add, so read this row as specified-ahead-of-its-input (the committed scorecard row above records the same open call)". The pre-existing committed scorecard row (untouched by this diff — the TERMS.md diff is exactly the two new lines) says "Not ruled here: whether the file also carries the date / CLI-version / harness-version pins #4637 ruling 4 requires (the type carries none today)… both are #4680's open calls". The two rows now agree and cross-reference each other, where in round 2 one implied a timestamp existed and the other said it did not. Half of F2 discharged, verified row-to-row.

Module README (packages/fabrika-cli/src/eval/README.md) — [PASS]

The added pointer section is accurate against both the ADR and the source:

  • Routes the builder to ADR 0252 rather than restating the arithmetic ("build the arithmetic from there, not from the issue bodies, which still state it in prose") — correct given Propagate ADR 0252 onto the grading-chain issue bodies (#4678, #4680, #4681) #5258 is still open.
  • States the strict comparison and the reason in one line: "one regressing case out of twenty is a drop of exactly 0.05 and is steady, so compare the rounded (4dp) difference with >, not ." That is the exact instruction a builder needs to not encode the boundary backwards, and it matches the ADR and the glossary row with no drift between the three surfaces.
  • "You cannot build the trend read yet" — names the blocking dependency at the one surface a builder inside the module actually opens. Grounded: Scorecard in report.ts carries decisionRef, framing, baseline, cells and no timestamp; recordedAt exists today only on RunLedger (header) and SpendRow (every row) in spawn.ts, minted at command.ts:467. Verified in source at this head.
  • The relative link ../../../../.decisions/0252-… resolves from packages/fabrika-cli/src/eval/ to the repo root. CI's dead-internal-link check is green at this head.

No comment-hygiene concern: nothing was added to a .ts file.

Repo-convention checks — [PASS]

  • Turkish/English split — all new text is technical English; no product/brand noun involved. Correct.
  • Link style — standard markdown relative links, no wikilinks, no placeholders, all targets resolve.
  • No local, home, absolute or sibling-repo path in any changed file.
  • No secret, token or PII.
  • Every packages/* member still has a README (this diff only extends one). readme-guard green.
  • No dependency change, so catalog-guard is a no-op and is green.
  • CI at this head: 41 pass, 4 skipping, 0 failing.

Non-blocking notes — NOT grounds for a fourth round

  1. Neither glossary row is width-trimmed. Both are very long single-line table cells, consistent with the surrounding rows in this file, so this is house style rather than a defect. Mentioning it only because the two new rows are among the longest in the file.
  2. Nothing else. There is no code to critique.

Verdict

PASS (advisory). The disarm is verified structurally (no reader exists, so nothing can red on the trend), the glossary rows are accurate and now internally consistent with the pre-existing committed scorecard row, and the module README's builder-facing instruction matches the ADR and the source on every point including the blocking dependency. No blocking finding.

This verdict authorizes nothing on its own (§CP — approval-gated). A @kamp-us/control-plane approval at 584c2e5cc6ac7c9206578af1d11a587837e1096f is what authorizes the merge; ship-it then enqueues (ADR 0135).

Verdict-written: 2026-08-10T03:26:08Z

@usirin usirin added the status:cp-banked Control-plane PR banked on the board, awaiting a control-plane approval label Aug 10, 2026
@usirin
usirin requested a review from notusirin August 10, 2026 03:27
@usirin
usirin added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 3c2f0d0 Aug 10, 2026
46 checks passed
@usirin
usirin deleted the usirin/umut/define-grading-dispersion-and-decline-4766-CE855327 branch August 10, 2026 03:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status:cp-banked Control-plane PR banked on the board, awaiting a control-plane approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fabrika grading chain: dispersion and "two-week decline" are undefined, and one gates a merge-blocking red

2 participants