Skip to content

ci: the margin table was wrong twice — re-measure the pool, and size the job cap from maxima - #138

Open
wshallwshall wants to merge 12 commits into
mainfrom
claude/ci-margin-correction
Open

ci: the margin table was wrong twice — re-measure the pool, and size the job cap from maxima#138
wshallwshall wants to merge 12 commits into
mainfrom
claude/ci-margin-correction

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

#131 replaced a "~2x headroom" claim with a measured table. That table was wrong in every row. The first correction on this branch fixed the maxima but repeated #131's own failure mode in a new place — it reported a pool and an n that its stated pool cannot produce. This revision fixes that, and sizes job_timeout from measured maxima instead of typical values.

Two independent re-derivations agree on every figure below.

The measurement

Pool: every run of ci.yml created 2026-08-01 UTC — 70 runs, enumerated over a deliberately wider window and filtered locally on created_at, then cross-checked against the narrow ?created= query (same 70 ids, symmetric difference 0). Rows are each leg's Tests (pytest) step, kept when that step concluded success.

leg #131 said first correction true n old cap true margin
ubuntu-latest 12:27 12:31 12:31 42 19:00 1.518x
windows-2022 18:39 21:34 21:34 39 26:00 1.206x
windows-2025 24:35 25:51 25:51 36 26:00 1.006x

The maxima re-derive to the second. The pool size (101) and every n (57/52/49) do not reproduce under any pool definition tried.

These maxima are lower bounds — the pool is right-censored

Every run in it predates #131 (28d186b5, landed 2026-08-02T00:35:28Z), so Windows ran under step 26:00 / job 30:00. A run that wanted longer was killed at the cap and then — correctly, by the filter above — dropped for not concluding success. 25:51 is the largest step that FIT in 26:00, not the largest the suite wants. Sizing a cap as a multiple of a censored maximum under-provisions by construction. That is exactly how "1.06x" read as survivable right up until #119 died.

Three mechanisms, all cheap to repeat

The job cap

Both Tests (pytest) and Web console tests (pytest) carry step_timeout, so a job can hold 2 × step_timeout of gated work that step_timeout cannot bound. Run 30724385719 (main @ 8f01cef8), windows-2025 — all six timestamps confirmed:

Tests (pytest)              00:01:21 -> 00:27:12   25:51   SUCCESS    <- 9s under the 26:00 cap
Web console tests (pytest)  00:27:12 -> 00:30:14           CANCELLED
JOB                         00:00:06 -> 00:30:19   30:13   CANCELLED  <- job_timeout 30 fired

Read that exhibit for its mechanism, not as a verdict on the cap it replaces: it ran under the retired 26/30 pair and would have passed under #131's 40:00. What condemns 40:00 is the arithmetic below — and that is arithmetic; no job has been observed hitting 40:00.

Every addend is now that addend's measured maximum. The previous revision used 0:41 for setup, which is the median, and web-console figures (1:58/3:27) that are each only the third-highest on their leg:

leg step_timeout + web(max) + setup(max) old job new job
ubuntu 19:00 + 2:00 + 1:20 = 22:20 22:00 → −0:20 26:00 → +3:40 (1.16x)
windows-2022 36:00 + 2:33 + 1:09 = 39:42 40:00 → +0:18 46:00 → +6:18 (1.16x)
windows-2025 36:00 + 3:33 + 1:20 = 40:53 40:00 → −0:53 46:00 → +5:07 (1.13x)

ubuntu and windows-2025 were already negative — ubuntu as well, which an earlier revision had the wrong way round (it showed ubuntu at +21s). windows-2022 was the one row genuinely in the black.

The Windows setup addend was itself wrong twice: 0:41 (a median), then 1:04 — which came from restricting to rows where both gated steps succeeded, silently dropping the exhibit run whose web-console step was cancelled. Same censoring mistake as filtering the step table by job conclusion, made a second time. The true figure, 1:20, is visible in the exhibit printed directly above it.

What this does not fix

The nesting invariant ci.yml asserts — "the step must expire BEFORE the job" — holds for Tests (pytest) on every leg and for Web console tests (pytest) on none of them: reaching that step already spends setup plus Tests, so its own cap can never fire first. Satisfying it would need job_timeout past 39:24 on ubuntu and 73:21 on Windows. A hang there still surfaces as an unattributed job-level kill. The file now says so instead of restating the claim — a compensating control must not rest on a false premise. The structural fix (stop the two steps sharing one budget) is BACKLOG #344.

The spread rule is not met, and the file now says so

An earlier revision claimed 36:00 "comfortably clears" the observed spread. Against real values it does not. The first day after the raise produced windows-2025 Tests steps of 26:23 twice, both passing — uncensored evidence that the population exceeds the old 26:00 cap. Against 26:23: headroom 9:37 vs spread 10:27. The rule would need ~37:00.

36:00 is kept, with the reason stated rather than dressed up: this cap exists to catch a whole-process deadlock, not slowness, so 1.365x over the worst observed run is ample for its actual job, and #131 already set this value. Re-derive if a windows-2025 step is ever seen above 28:00.

Also withdrawn — both were wrong: "26:07 does not exist" and "no identical-code spread is computable". Artifacts of the jobs endpoint defaulting to filter=latest, which hides a step killed at the cap behind its passing re-run. 26:07 is in this very pool (run 30717229521 attempt 1, killed at the cap), and seven same-commit pairs exist under ?filter=all.

Also corrected

Three pre-existing claims in this same file that match no configuration — the very defect class this note is about:

  • the pytest step's cap described as timeout-minutes: 13 (it is matrix.step_timeout, 19 or 36)
  • "the 15-min job cap" (it is matrix.job_timeout, 26 or 46)
  • the mechanical margin guard cited as BACKLOG #341, which does not exist on main; it is #344 item 1

Drops HANDOFF-ci-margin-correction.md: session ephemera, and no HANDOFF file has ever been tracked at the repo root on main.

The 36:00 step / 46:00 job decision is unchanged and remains correct. Only its justification moves — for the second time, which is itself the finding.

Verification

Nine agents; two re-derived the table from scratch under instructions to refute it. They confirmed every figure in the step table and refuted six things written around it — all six fixed in 5be4d1fe.

The sqlserver HYT00 is PRE_EXISTING_INTERMITTENT, not caused by this PR. Adversarially checked and upheld:

  • the identical failure occurred on branch adr0154-sync-reply @ 88f99b0c, ~10h before this PR was opened, carrying the pre-ci: the margin table was wrong twice — re-measure the pool, and size the job cap from maxima #138 ci.yml
  • sqlserver-store declares no timeout-minutes, never reads needs.changes.outputs.matrix (the only output this PR changes), and the repo has zero self-hosted runners, so there is no shared pool for a longer test job to starve
  • within this PR the 2022 leg ran 3× on 3 heads all carrying the identical change: 2 pass, 1 fail

Not claimed: that it is harmless. HYT00 is a query timeout — a fixed wall-clock bound, the same shape as #344.

…had already fired

#131 replaced a "~2x headroom" claim with a measured table. The table was
itself wrong -- every row, each in the safe-looking direction -- and the job
cap it left in place was already negative.

MEASURED over all 101 CI runs created 2026-08-01 (`gh api --paginate`),
timing each leg's `Tests (pytest)` STEP and filtering on the STEP's own
conclusion:

  leg              claimed        true (n)          old cap   true margin
  ubuntu-latest    12:27          12:31  (n=57)     19:00     1.518x
  windows-2022     18:39          21:34  (n=52)     26:00     1.206x
  windows-2025     24:35          25:51  (n=49)     26:00     1.006x

Nine seconds, not 85. Two mechanical causes, both cheap to repeat:

  * THE POOL WAS A PAGE, NOT A QUESTION. It came from `gh run list --limit
    20` -- a default-sized listing reported as though the sample had been
    chosen. 20 of 101.
  * FILTERING ON *JOB* CONCLUSION DROPS THE TIGHTEST STEPS BY CONSTRUCTION.
    A step near step_timeout is the one most likely to push its job into
    job_timeout, so the job is cancelled while the step concluded success.
    Five such rows exist that day and they include the maximum.

Only the windows-2025 row was ever a maximum; ubuntu's and windows-2022's
figures were that same run's other two legs.

AND THE TWO-GATED-STEPS HAZARD IS NOT LATENT -- IT FIRED. Both `Tests
(pytest)` and `Web console tests (pytest)` carry `step_timeout`, so a job can
hold 2x step_timeout of gated work that step_timeout cannot bound. Run
30724385719 (main @ 8f01cef):

  Tests (pytest)              25:51  SUCCESS    (9s under the 26:00 cap)
  Web console tests (pytest)         CANCELLED
  JOB                         30:13  CANCELLED  <- job_timeout 30 fired

A green first step, then an unattributed job-level kill during the second --
precisely what ci.yml:218's nesting note exists to prevent, by the path it
does not consider. It cannot happen when a step is KILLED (that ends the job
and skips what follows), only when the first step PASSES near its budget.

Sizing job_timeout to hold both gated steps plus setup, rather than
step_timeout plus a constant:

  leg       step + web(max) + overhead   old job      new job
  ubuntu    19:00 + 1:58 + 0:41 = 21:39  22:00 +21s   26:00 +4:21 (1.20x)
  W22/W25   36:00 + 3:27 + 0:41 = 40:08  40:00  -8s   46:00 +5:52 (1.15x)

All three legs sat inside a minute of their job cap and Windows was already
negative: the +4 convention was carried through two cap changes without
anyone summing what it had to cover.

The 36:00 step decision is unchanged and remains correct -- 1.393x over the
true 25:51 maximum, still comfortably above the 5:26 observed spread. Only
its justification moves.

Found by the ADR 0158 verification pass (eight agents re-deriving every
claimed number against the API; 50 claims checked, 6 refuted), reported by
the intersession-communication-hooks session, and re-derived here before
acting. BACKLOG #344 still restates the superseded figures; that edit is
blocked on three live sessions holding docs/BACKLOG.md and follows separately.
@wshallwshall
wshallwshall enabled auto-merge (squash) August 2, 2026 03:40
Written at the owner's stop-work instruction (usage cap). Chat does not
survive; a claim that lives only in a transcript reaches nobody.

Carries: #138's state and its SQL Server blocker (NOT called a flake --
unproven), the corrected margin table, four retractions of my own findings
with their corrected forms, and seven traps stated as fact-plus-measurement.

The load-bearing line: the cap raise in #131 is correct and unchanged; its
justification was wrong in every row, and #138 fixes it.
@wshallwshall
wshallwshall disabled auto-merge August 2, 2026 04:11
…om maxima

The correction in this PR fixed #131's maxima but repeated #131's own failure
mode in a new place: it reported a pool and an n that its stated pool cannot
produce. Two independent re-derivations agree the day holds 70 ci.yml runs, not
101, and per-leg n = 42 / 39 / 36, not 57 / 52 / 49. The three MAXIMA
(12:31 / 21:34 / 25:51) re-derive to the second and are unchanged, so the
36:00 step / 46:00 job decision stands on the same numbers it always did.

What actually changed:

* POOL AND n. 70 runs created 2026-08-01 UTC, cross-checked four ways;
  n = 42 / 39 / 36. A table whose own point is "state your pool and your n"
  has to carry an n the next reader can recompute.

* THE POOL IS RIGHT-CENSORED, and nothing said so. Every run in it predates
  #131 (28d186b, 2026-08-02T00:35:28Z), so each observation survived a 26:00
  cap. 25:51 is the largest step that FIT, not the largest the suite wants, and
  a multiple of a censored maximum under-provisions by construction. This is
  why 1.06x read as survivable right up to the moment #119 died.

* THE JOB-CAP ADDENDS WERE TYPICAL VALUES, NOT MAXIMA. 0:41 is the median
  setup; the web-console figures 1:58 / 3:27 are each only third-highest on
  their leg. Measured maxima are 2:00 / 2:33 / 3:33 (web) and 1:20 / 1:09 /
  1:04 (setup). Re-summed, ubuntu was -0:20 and Windows -0:37 against the old
  caps: BOTH were already negative, not just Windows.

* THE NESTING INVARIANT IS NOT RESTORED, and now says so. It holds for
  `Tests (pytest)` on every leg and for `Web console tests (pytest)` on none:
  reaching that step already spends setup plus `Tests`, so its own cap can
  never fire first. A hang there still surfaces as an unattributed job kill.
  Stating otherwise would rest a compensating control on a false premise.

* The "5:26 spread ... identical code" claim is withdrawn; neither endpoint
  re-derives. The true in-pool spread is 9:55, which is wider and supports the
  decision more strongly than the figure it replaces.

Also corrects three pre-existing claims in this file that match no
configuration -- the same defect class this note is about:
  - the pytest step's cap described as `timeout-minutes: 13` (it is
    matrix.step_timeout, 19 or 36)
  - "the 15-min job cap" (it is matrix.job_timeout, 26 or 46)
  - the mechanical margin guard cited as BACKLOG #341, which does not exist on
    main; it is #344 item 1

Drops HANDOFF-ci-margin-correction.md: session ephemera, and no HANDOFF file
has ever been tracked at the repo root on main.
Run 30724385719 ran under the retired 26/30 pair and would have passed under
#131's 40:00. It demonstrates that two steps sharing one step_timeout lets the
job cap fire behind a green step; it is not itself evidence that 40:00 is too
tight. That case rests on the arithmetic, which is arithmetic -- no job has
been observed hitting 40:00.
@wshallwshall wshallwshall changed the title ci: the #131 margin table was wrong in every row, and the job cap had already fired ci: the margin table was wrong twice — re-measure the pool, and size the job cap from maxima Aug 2, 2026
#344 is the item about bounds stated independently of the work they bound. Its
instance 1 still carried the retracted measurement -- 24:35 over "11 runs",
1.06x, raised to "1.46x" -- every figure of which was superseded, and the edit
had been outstanding since it was filed.

Instance 1 now reads 25:51 / 1.006x / 1.393x, and carries its pool (70 ci.yml
runs created 2026-08-01 UTC; per-leg n = 42 / 39 / 36) so the next reader can
recompute it rather than trust it.

Adds two things the re-measurement turned up:

* INSTANCE 3 -- the CI job cap. Two steps in that job carry step_timeout, so
  job_timeout must cover their sum, and the +4 convention that sized it was
  never summed against what it had to hold. Recomputed from measured maxima,
  ubuntu was -0:20 and windows-2025 -0:37: both already underwater. It presents
  as a GREEN first step followed by an unattributed job-level kill, which is a
  signature instance 1's own proposed margin check would not catch, because the
  step it measures passed.

* THE CENSORING TRAP. A max-passing figure is a LOWER BOUND: the pool is
  censored by whatever cap was in force when it was collected, so the runs that
  would have exceeded it were killed and are missing from exactly the tail being
  measured. This is why 1.006x looked survivable until #119 died.

Proposal 5 records the structural fix instance 3 does not make: give the
web-console step its own cap, because until then ci.yml's nesting invariant is
unenforceable for the second gated step on every leg.

Banner moves from "not started" to PARTIAL -- instances 1 and 3 are fixed,
instance 2 is not. One banner, still OPEN.

Also records, once, that instance 1's figures have now been published wrong
twice; the maxima survived both passes and the pools did not.
Nine agents re-derived this block; two re-derived the table from scratch under
instructions to refute it. They confirmed every figure in the step table and
refuted six things written around it. Fixing my own text, since the whole point
of this change is not to ship a third unchecked table.

* 26:07 EXISTS, and I said it did not. It is in this very pool -- run
  30717229521 attempt 1, sha 8c407fb, step conclusion FAILURE, killed at the
  26:00 cap -- hidden because the jobs endpoint defaults to `filter=latest`,
  which returns only the passing attempt-2 re-run. Same filter hid seven
  same-commit pairs, so the "no identical-code spread is computable" claim was
  also wrong. `?filter=all` shows both.

* THE SPREAD RULE IS NOT MET, and I claimed it was. The first day after the
  raise produced 26:23 TWICE, both concluding SUCCESS -- uncensored evidence
  that the population exceeds the old 26:00 cap. Against 26:23 the headroom is
  9:37 and the spread 10:27, so "headroom must exceed observed spread" FAILS at
  36:00; it would need ~37:00. 36:00 is kept and the reason is now stated
  plainly -- this cap catches a deadlock, not slowness, and 1.365x over the
  worst observed run is ample for that -- rather than the rule being asserted as
  satisfied. Re-derive if a windows-2025 step is ever seen above 28:00.

* WINDOWS setup(max) IS 1:20, NOT 1:04. 1:04 came from restricting to rows where
  BOTH gated steps succeeded, which drops the exhibit run printed 20 lines above
  (its web-console step was cancelled) -- the same censoring mistake as filtering
  the step table by job conclusion, made again. W25 is 40:53, so the old cap was
  -0:53, not -0:37. windows-2022 now gets its own row with its own addends
  instead of an unrecheckable 39:42.

* "the +4 convention" was Windows-only. ubuntu went 15/13 -> 22/19, so +2 then
  +3, never +4.

* "2 x step_timeout ... which job_timeout must cover" asserted a requirement the
  shipped caps do not meet (38 > 26, 72 > 46). Now says what they are actually
  sized against.

* The nesting invariant at the top of the block asserted a guarantee it does not
  provide for the SECOND gated step on any leg. Amended there, where a reader
  meets it, not only in a caveat 100 lines below.

Also: pytest_timeout / fault_timeout were quoted as flat 60s / 90s in three
places; they are matrix values (60/120 and 90/150) passed explicitly on the
command line, so each figure was false on two of three legs. And records that
`test` is the only one of this file's ten jobs with any cap at all -- the other
nine run on GitHub's 6h default.
…ing now has evidence

The windows-2025 job-cap shortfall is -0:53, not -0:37: the 1:04 setup addend it
was computed from excluded the exhibit run itself, because that run's
web-console step was cancelled. Same censoring mistake, one layer down.

Instance 1 now cites the uncensored observation rather than only arguing the
maximum must be a lower bound: the day after the raise, windows-2025 produced
26:23 twice, both passing -- runs the old 26:00 cap would have killed. Records
that the jobs endpoint hides a killed attempt behind its passing re-run unless
asked for ?filter=all, which is why nobody had seen them.
Reported by the announce-hook session, verified here: gh pr view 119 gives
MERGED, mergedAt 2026-08-02T01:45:00Z. What the 26:00 cap killed was a RUN, not
the PR.

"#119 died" reads as never-landed, and that reading had already propagated --
docs/WORKTREES.md asserted #119 "never merged (it died on an unrelated CI
timeout)", which that session corrected against the API. This block was one of
the places the phrasing came from, so it is fixed at the source rather than
only downstream.
Caught by running an exact-arithmetic assertion over every figure in the block
rather than re-reading it: 2160/1583 = 1.3644978, which rounds to 1.364. I had
rounded it up by hand in both places.

Trivial in size and not in kind -- this is a change whose entire subject is
numbers published without being recomputed, so it does not get to ship one.
Every ratio, sum and delta in the block is now covered by that assertion and all
fifteen are exact.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant