Skip to content

test(cpu): pin the fixed point every benchmark pin relies on, and scope #1729 - #2150

Merged
justinchuby merged 2 commits into
mainfrom
roy-placement-scope
Aug 25, 2026
Merged

justinchuby merged 2 commits into
mainfrom
roy-placement-scope

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 25, 2026 •

Copy link
Copy Markdown
Owner

Why

A cross-agent note asked for every t>=8 row published before #1729 (6e8c31ebd) to be re-taken, on the grounds that the default decode pool pinned 16 workers to cpus 0–15 — 8 physical cores with both SMT siblings loaded.

The premise is correct, and it is the same defect this repo found independently and filed as #1680 (§24 of CPU_MATMUL_ASSIGNMENT.md). The blanket conclusion is too broad for the rows in that file, and — the point of this PR — which rows survive is decidable from the source, without spending host time re-measuring.

The argument

Every multi-thread timing in §23/§25 is taskset-pinned to the even CPUs (§24's closing note). On this host SMT siblings are adjacent pairs, so that mask is 16 CPUs that are already 16 distinct physical cores.

The spread half of #1729 is the identity on such a mask. Pre-#1729 the SPMD shard builder used allowed_cpus() in raw ascending order (confirmed at 6e8c31ebd^); post-#1729 the same list goes through order_pin_targets. Its Spread arm is leaders_within(cpus) plus the non-leader remainder — and on an all-even mask each core group contributes its single allowed member, the remainder is empty, and leaders_within ends sort_unstable(); dedup(). Same ascending list. build_decode_pool pins worker i to cpus[i % len] either way, so #1729 cannot move a number taken under that pin.

The reserve half is the identity on that mask too. This is a correction to the first version of this PR, which claimed t=16 went 16 workers -> 15. It does not. #1729 did not introduce the dispatcher reserve; it changed it from a logical-CPU rule to a physical-core rule:

pre  (6e8c31ebd^):  total < allowed ? total : allowed - 1
post (core budget): min(total, cores - 1).max(1)

When the mask holds one CPU per physical core, allowed == cores, and the two agree at every width: below saturation min(total, allowed-1) == total because total <= allowed-1; at and above saturation both give allowed-1. They diverge only when allowed > cores — a mask holding both SMT siblings, which is exactly the unpinned case #1729 was written to fix and exactly the case these benchmarks are not run in.

I got this wrong in the characteristic way: I read the post-#1729 formula carefully and assumed the pre-#1729 one, then published a delta with only one side measured. An adversarial review accepted the wrong table; fetching 6e8c31ebd^ is what caught it. It is in the ledger under its own heading because it is the same error class the rest of this file is about.

#1794 does not apply. It fixed default_persistent_threads returning available / 2. These sweeps set ONNX_GENAI_CPU_DECODE_THREADS explicitly, and an explicit count bypasses the default entirely. The rows that defect corrupted are pinned runs that left the width to the default — of which this file has none, because the pin and the explicit width were adopted together.

Disposition: no row taken under the even-CPU pin moves at any width — both halves of #1729 are the identity there, and #1794's defect lived in a default these sweeps never used. t=16 stays withheld for its own unrelated reason (its A/A null spans 0.969–1.295, ±30%, against 3.6% at t=1). The placement question and the instrument question are independent, and only the second ever disqualified that row.

This is a stronger claim than the one asked for, so it carries a stronger obligation: it holds because of the pin, and says nothing about an unpinned row. Unpinned numbers remain governed by §24's standing rule and are not rehabilitated by anything here.

The tests, and why they are the real content

Both arguments were load-bearing and untested. cargo test order_pin_targets matched zero tests, and nothing asserted the reserve invariant either.

1. a_one_cpu_per_core_mask_is_unchanged_by_either_placement_policy — a cpuset already holding one CPU per physical core is a fixed point of both placement policies. This is the guarantee every pinned benchmark in this repository rests on: the house rule for a clean multi-thread number is taskset to one CPU per core, and it is worth nothing unless the pool then pins workers to the CPUs that were reserved, in the order reserved. Also asserts the policies still disagree on a full mask, so it is a property of the mask rather than a policy that never reorders.

Live: reversing the leader order inside Spread fails it with `spread` reordered a mask that was already one CPU per core.

2. the_core_reserve_matches_the_logical_reserve_on_a_one_cpu_per_core_mask — carries the pre-#1729 logical rule as a reference implementation and asserts agreement across masks 1..32 at every total, plus the contrasting SMT-mask case where the rules genuinely differ ((16, 32, 16) -> 15 vs logical 16). A future change to the reserve that breaks this would silently invalidate a file full of measurements; this makes it break in CI instead.

Live: reserving two CPUs instead of one fails it at total=3 on a 4-CPU mask.

Also recorded

t=2 is closed, by two methods sharing no apparatus: 1.96x measured here on a quiet host (20.447 vs 40.039 ms/token, 0.6% A/A null, both workers 99% busy by per-thread attribution) and 1.94x / 97% efficiency reported independently by the runtime owner on a post-#1729 baseline. ~1% apart. The withdrawn "71% of one core" is now over-determined.

One standing caveat is reinforced rather than revised: t=1 runs path=flat and t>=2 runs path=spmd-pool, so a t=1 vs t=2 comparison crosses routes as well as widths. §20 already reads that row as "vs serial" rather than "vs a one-worker pool".

Validation

  • cargo test -p onnx-runtime-ep-cpu --lib — 1829 passed, 0 failed (two new tests), under scripts/hostlock.sh
  • cargo clippy -p onnx-runtime-ep-cpu --all-targets --all-features -- -D warnings — clean
  • cargo fmt --all --check — clean

No production code changed: two tests plus a documentation section.

#1729

A cross-agent note asked for every `t>=8` row published before #1729
(`6e8c31ebd`) to be re-taken, because the default decode pool put 16
workers on cpus 0-15 -- 8 physical cores with both SMT siblings loaded.
The premise is right and is the defect this repo filed independently as
#1680. The blanket conclusion is too broad for the rows in
`CPU_MATMUL_ASSIGNMENT.md`, and which rows survive is decidable from the
source rather than by spending host time re-measuring.

Every multi-thread timing in that file is `taskset`-pinned to the even
CPUs, which on this host is 16 CPUs that are already 16 distinct physical
cores. On such a mask `order_pin_targets` is the *identity*: each core
group contributes its single allowed member, the non-leader remainder is
empty, and `leaders_within` returns the set `sort_unstable()`-ed -- the
same ascending list the pre-#1729 builder used raw. Same set, same order,
same pins, so #1729's placement change cannot move those numbers. The
reserve half is not the identity, but on this mask
`reserve_single_group_headroom` returns `total.min(15)`, so it bites at
exactly one width: t=16 becomes 15 workers, and t<=8 is unchanged. #1794
does not apply at all, because these sweeps set the width explicitly and
an explicit count bypasses the default that defect lived in.

That argument was load-bearing and untested. `order_pin_targets` had no
test matching its name, and none of the placement tests covered the
fixed point -- so add it. It is the guarantee *every* pinned benchmark in
this repository rests on: the house rule for a clean multi-thread number
is one CPU per core, and it is worth nothing unless the pool then pins
workers to the CPUs that were reserved, in the order reserved. The test
asserts both policies leave such a mask alone, and asserts they still
disagree on a full mask, so it is a property of the mask rather than a
policy that never reorders anything.

Verified live: reversing the leader order inside the `Spread` arm fails
it with "`spread` reordered a mask that was already one CPU per core".

Ledger section records the disposition: t<=8 survives, t=16 does not --
and t=16 was already withheld for an unrelated reason (its A/A null spans
+-30%). Also records that t=2 is now closed by two methods sharing no
apparatus: 1.96x measured here, 1.94x/97% reported by the runtime owner.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codecov

codecov Bot commented Aug 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.10526% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.55%. Comparing base (f55520d) to head (0b0f6a6).
⚠️ Report is 11 commits behind head on main.

Files with missing lines Patch % Lines
crates/onnx-runtime-ep-cpu/src/decode_spmd.rs 91.30% 2 Missing ⚠️
crates/onnx-runtime-ep-cpu/src/decode_affinity.rs 93.33% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##           main    #2150       +/-   ##
=========================================
+ Coverage      0   80.55%   +80.55%     
=========================================
  Files         0      431      +431     
  Lines         0   217593   +217593     
  Branches      0   217593   +217593     
=========================================
+ Hits          0   175291   +175291     
- Misses        0    36473    +36473     
- Partials      0     5829     +5829     
Flag Coverage Δ
cli-ort-linux 72.51% <ø> (?)
cli-ort-windows 72.01% <ø> (?)
mlas 85.90% <ø> (?)
offline 80.67% <92.10%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
crates/onnx-runtime-ep-cpu/src/decode_affinity.rs 94.63% <93.33%> (ø)
crates/onnx-runtime-ep-cpu/src/decode_spmd.rs 90.50% <91.30%> (ø)

... and 429 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

… t=16 wrong

Self-correction found while checking a review note, by fetching the
pre-#1729 formula instead of assuming it.

The first commit claimed #1729 moved `t=16` from 16 workers to 15 and so
invalidated that row. It does not. #1729 did not *introduce* the
dispatcher reserve — it changed it from a logical-CPU rule to a
physical-core rule:

  pre  (6e8c31e^):  total < allowed ? total : allowed - 1
  post (core budget): min(total, cores - 1).max(1)

When the mask holds one CPU per physical core `allowed == cores`, and the
two agree at every width: below saturation `min(total, allowed - 1) ==
total` because `total <= allowed - 1`; at and above saturation both give
`allowed - 1`. They diverge only when `allowed > cores` — a mask holding
both SMT siblings, which is exactly the unpinned case #1729 was written
to fix and exactly the case these benchmarks are not run in.

So the disposition is stronger than first written: **no row taken under
the even-CPU pin moves at any width**, not "t<=8 survives". `t=16` stays
withheld for its own unrelated reason (A/A null +-30%).

The error is worth naming because it is this file's recurring one: I read
the post-#1729 formula carefully and *assumed* the pre-#1729 one, then
published a delta with only one side measured. An adversarial review
accepted the wrong table; fetching the old source is what caught it.

Pins the invariant so a future reserve change cannot silently invalidate
a file full of pinned measurements: the test carries the historical
logical rule as a reference implementation and asserts agreement across
masks 1..32 at every total, plus the contrasting SMT-mask case where the
rules genuinely differ (16/32/16 -> 15 vs 16). Live: reserving two CPUs
instead of one fails it at total=3 on a 4-CPU mask.

1829/0, clippy and fmt clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby

Copy link
Copy Markdown
Owner Author

Review accepted my table, and the table was wrong. Recording that, because it matters more than the parts that were right.

Chasing your Low finding (the "workers" column vs effective lanes under budget narrowing) sent me back into the reserve, and there I found something neither of us had checked: the pre-#1729 formula. I had read the post-#1729 one carefully and assumed the old one reserved nothing at saturation. It did:

pre  (6e8c31ebd^):  total < allowed ? total : allowed - 1
post (core budget): min(total, cores - 1).max(1)

#1729 did not introduce the dispatcher reserve. It changed it from a logical-CPU rule to a physical-core rule. On a one-CPU-per-core mask allowed == cores, and the two agree at every width — below saturation min(total, allowed-1) == total because total <= allowed-1; at and above saturation both give allowed-1. They diverge only when allowed > cores, i.e. a mask holding both SMT siblings: exactly the unpinned case #1729 was written to fix, and exactly the case these benchmarks are not run in.

So t=16 does not go 16 -> 15 under this pin. It was 15 both before and after. My claimed disposition was "t<=8 survives, t=16 does not"; the correct one is no row taken under the even-CPU pin moves at any width.

I published a delta with only one side measured. That is the exact error class this PR's ledger section is about, committed in the section itself, which is why it is now written up there under its own heading rather than quietly fixed.

Your Low finding was right and is what led me there, so it is worth being precise about where it lands. Budget narrowing does apply — with explicit THREADS=N and no DECODE_AFFINITY, bound_process_to_decode_budget confines the process to N CPUs, so allowed == N. Then reserve_single_group_headroom(N, N, N) = N-1, and dispatcher_owns_a_shard(N-1 < N) is true, so the structure is N-1 pinned workers + 1 dispatcher shard = N lanes. Your correction to the column header stands. What changes is that this is true identically on both sides of #1729, so it does not disturb the conclusion — it sharpens what the conclusion is about.

New in this revision:

the_core_reserve_matches_the_logical_reserve_on_a_one_cpu_per_core_mask — carries the pre-#1729 logical rule as a reference implementation and asserts agreement across masks 1..32 at every total, plus the contrasting SMT-mask case where the rules genuinely differ ((16, 32, 16) -> 15 against logical 16). Without that second half the test would also pass on a reserve that had simply stopped distinguishing the two masks.

Live: reserving two CPUs instead of one fails it at total=3 on a 4-CPU mask.

Re-validated: 1829/0, clippy -D warnings clean, fmt --check clean, full suite under scripts/hostlock.sh.

Two follow-ups on your other items, both agreed and neither changed:

  • The redundancy you flagged between my assert_ne! on a full mask and compact_and_spread_are_permutations_that_differ_on_an_smt_host is real but load-bearing in situ — without it the fixed-point assertion would also pass on a policy that never reorders anything. Kept, with the reason in the comment.
  • Item 7 came back clean from you and I have nothing to add to it.

Requesting a second pass, specifically on the corrected reserve argument and the new test — the first pass validated the version of this that was wrong.

@justinchuby
justinchuby marked this pull request as ready for review August 25, 2026 22:09
@justinchuby
justinchuby enabled auto-merge (squash) August 25, 2026 22:09
@justinchuby
justinchuby merged commit b054ff6 into main Aug 25, 2026
17 of 22 checks passed
@justinchuby
justinchuby deleted the roy-placement-scope branch August 25, 2026 22:20
justinchuby added a commit that referenced this pull request Aug 25, 2026
…t let it

#2150 (b054ff6) carried .roy-scratch/pr.md into main. That file is the
working copy of #2150's own pull-request description -- a build artefact of
writing the PR, not repo content.

The cause is a near-miss in .gitignore: my scratch patterns are `.roy_*`
(underscore) and I named the directory `.roy-scratch` (hyphen), so `add -A`
swept it in. Widening the pattern to `.roy-*` covers both spellings.

This is the third time a PR-authoring scratch file has reached main by this
exact route, and the second in my own namespace:

  roy_validate.log     added 74756f0, removed 9e32e2d
                       ("drop the accidentally committed validation transcript")
  .body.md             reached main in #2026 (79196f8), Leon's namespace
  .roy-scratch/pr.md   this one

Both prior cases are already memorialised as comments in .gitignore, and
Leon's names the mechanism: `git add -A` "does not distinguish a dotfile at
the root from repo content, and a name like `.body.md` looks plausible enough
in `git status` to skim past." That matches why this one also survived two
reviews -- a stray *added* file reads as intentional in a diff, because a
reviewer checks whether changed lines are correct and an unfamiliar new path
is not a changed line.

I caught it from `rm -rf .roy-scratch` reporting the file as tracked-deleted
rather than untracked -- a cleanup step, not a review step. The check that
would have caught it is confirming the PR's file list is the set of files I
meant to change, which costs one command and is now in my pre-merge sequence.

I found this while #2150's checks were still running and pushed a fix to that
branch, but auto-merge fired on the previous head first. Nothing was bypassed;
the race is between a push and an armed auto-merge, which is why this is a
follow-up rather than an amendment.

No production code, tests, or documentation are touched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 25, 2026
…ident

`.roy-scratch/pr.md` reached main in #2150 (b054ff6). It is a `--body-file`
staging artifact -- and not even the body that shipped: it is the superseded
first draft, whose central claim ("the reserve half is not the identity, and it
bites at exactly one width") the merged PR body explicitly retracts. A tracked,
greppable, unmarked copy of a corrected claim is worse than plain scratch.

`Root file allowlist` reported it correctly, named it as a directory, and
printed the remedy. It has been red on main and on every PR opened since --
0.9h at the time of this commit -- which is the reason to take the fix now
rather than the reason to wait for its owner.

Three changes:

- remove the file;
- add `/.*-scratch/` to .gitignore. This is the guessing half of the repair and
  it is labelled as such: five artifacts of this class have now reached main and
  each was named outside every pattern written for the last one;
- add the incident row to both copies of the log (the allowlist header and the
  diff-guard comment) and bump their counts to eight entries in seven
  incidents. A count that stops being true is how the log stops being read.

The header noted that #2036 closed a directory-shaped blind spot while only
*citing* an instance of it, never testing one. `.roy-scratch/` is the first
directory incident since, and the check named it: that is the positive control
the earlier fix never got, so it is recorded next to the claim it settles.

Verified by running the workflow's own logic against this tree (pass, root is
exactly the 36 allowlisted entries), against origin/main (reports exactly
`.roy-scratch`), and against an injected `.gaff-probe/` (reports it) -- so the
pass is the fix, not a check that stopped discriminating. `git check-ignore`
confirms the new pattern covers root `.roy-scratch/` and `.pris-scratch/` and
leaves `nested/.foo-scratch/` tracked.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 25, 2026
…t let it (#2156)

Removes `.roy-scratch/pr.md` from `main` and widens the `.gitignore`
pattern that failed to catch it.

**No production code, tests, or documentation are touched.** One stray
file deleted, three lines added to `.gitignore`.

## What happened

#2150 (`b054ff63e`) carried `.roy-scratch/pr.md` into the tree. That
file is the working copy of #2150's own PR description — an artefact of
writing the pull request, not repo content.

## Why it got in

My scratch ignore patterns are `.roy_*` — underscore. I named the
directory `.roy-scratch` — hyphen. `.roy_*` does not match
`.roy-scratch/`, so `git add -A` swept it in. The fix widens the pattern
to `.roy-*` so both spellings are covered.

## This is the third time, not the first

I originally wrote this up as the second instance of one failure mode.
Reviewing my own claim against the history, it is the **third**
repo-wide, and the second in my own namespace — and both priors are
already memorialised as comments in the very file I am editing:

| file | fate |
|---|---|
| `roy_validate.log` | added `74756f0d9`, removed `9e32e2d84` — *"drop
the accidentally committed validation transcript"* |
| `.body.md` | reached main in #2026 (`79196f89d`), Leon's namespace |
| `.roy-scratch/pr.md` | this one |

That changes the character of the fix. A one-off argues for deleting a
file; three occurrences across two namespaces argue that the ignore
patterns are the wrong shape — they enumerate *known* scratch names
instead of reserving a namespace, so every new scratch filename is a
fresh chance to miss.

Leon's comment already names the mechanism precisely:

> `git add -A` does not distinguish a dotfile at the root from repo
content, and a name like `.body.md` looks plausible enough in `git
status` to skim past.

## Why it survived two reviews

Worth stating rather than quietly deleting the file. A stray **added**
file reads as intentional in a diff: a reviewer assesses whether changed
lines are correct, and an unfamiliar new path is not a changed line — it
looks like a deliberate addition whose contents happen to be prose.
Leon's note is the same observation from an independent author, which
suggests it is a property of the review surface rather than of any one
reviewer. I missed it too.

I caught it from `rm -rf .roy-scratch` reporting the file as
tracked-deleted (` D`) rather than untracked — a *cleanup* step, not a
review step. The check that would have caught it:

```
gh api repos/OWNER/REPO/pulls/N/files --jq '.[].filename'
```

One command, confirming the PR's file list is the set of files I meant
to change. It is now part of my pre-merge sequence, and I ran it on this
PR before requesting review.

## Why a follow-up and not an amendment

I found this while #2150's required checks were still running and pushed
the fix to that branch — but auto-merge fired on the previous head
first, so the correction missed by about two minutes.

Nothing was bypassed: `--squash --auto` waited for `Fast (Linux x86_64)`
and `Rust quality` as required. The hazard is narrower and worth
recording — **once auto-merge is armed, the head can merge at any
moment, so the branch is no longer a safe place to stage a correction.**
Staging a fix there is a race against your own merge. A follow-up PR is
the only reliable move.

## Review

Opus review returned no blocking issues, and confirmed adversarially
that:

- `.roy-*` is **not** too broad. No tracked path has a `.roy-`
component. The nearest miss,
`.squad/decisions/inbox/roy-session-kv-cache.md`, has no leading dot and
is unmatched — verified with `git check-ignore`.
- Leaving the pattern unanchored is consistent with its neighbours
(`.rg_*`, `.rv_*`, `.roy_*`, all unanchored). The root-anchored block
later in the file is anchored for a stated reason that does not
transfer: those names are ordinary nouns a nested crate might want,
whereas `.roy-` is a namespace prefix.
- Nothing in the repo references the deleted path — the only occurrence
of `roy-scratch` after this change is the new `.gitignore` comment.

The review also caught a factual error in my first write-up: I said the
`roy_validate.log` comment sits three lines *above* the `.roy_*`
patterns. It sits one line *below* them. Corrected here and in the
commit message. Fitting, in a PR about a claim that no one checked.

## Verification

- `git ls-tree -r --name-only origin/main | grep roy-scratch` → present
before, absent after.
- Ignore fix confirmed live rather than assumed: this PR's own scratch
directory is on disk right now and `git check-ignore -v
.roy-scratch/body.md` attributes it to the new `.roy-*` rule, with `git
status --porcelain` clean.
- Diff against `main` is exactly the two files above.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 25, 2026
…ident (#2161)

## What

`.roy-scratch/pr.md` reached `main` in #2150 (`b054ff63e`, 22:20:23Z).
This removes it, and records the incident in the two places that keep
the log.

`Root file allowlist` has been **red on `main` and on every PR opened
since** — it inherits, so it is currently red on #2157 and on anything
else branched after 22:20Z. Fixing it is not a judgement on #2150; the
check fired correctly and named the exact file and remedy.

## Why it is worth a PR rather than a `git rm`

The file is a `--body-file` staging artifact — and **not the body that
shipped**. It is the superseded first draft:

| | claim about the reserve half |
|---|---|
| committed `.roy-scratch/pr.md` | "The reserve half **is not** the
identity, and it bites at exactly one width." |
| #2150's merged body | "The reserve half **is** the identity on that
mask too. **This is a correction to the first version of this PR**…" |

So `main` currently carries a tracked, greppable, root-level copy of a
technical claim its own author retracted, with nothing marking it stale.
That is worse than ordinary scratch: ordinary scratch is merely noise.

## The three changes

1. **Remove the file.**
2. **`.gitignore`: `/.*-scratch/`** — root-anchored, per the `/*.log`
convention. The comment labels this as *the guessing half* of the
repair, because that is what the record shows it is: five artifacts of
this class have now reached `main` (`.msg.txt`, `.commitmsg`,
`.pris_v4.log`, `.body.md`, `.roy-scratch/pr.md`) and **each was named
outside every pattern written for the previous one** — `.body.md` landed
hours after two separate fixes to that very block, and the name it
should have had (`.pr-body.md`) was already ignored.
3. **Record the incident** in `.github/root-file-allowlist.txt`'s header
*and* the `diff-guard.yml` comment, and bump both counts: seven entries
/ six incidents → **eight / seven**. Both files state their count in
prose; a count that quietly stops being true is how a log stops being
read. Per #2036's precedent the row carries the introducing and removing
commits and the dwell time.

## One thing this settles rather than asserts

The allowlist header says #2036 shipped a directory-shaped blind spot
**while citing an instance of it in its own header** — "naming an
instance is not the same as testing its shape". `.roy-scratch/` is the
**first directory incident since that gap was closed**, and the check
named it, as a directory, with the right remedy:

```
::error::Root-level entr(ies) not on the allowlist:
::error::  .roy-scratch/   (directory)
```

That is the positive control the earlier fix never got, so it is
recorded next to the claim it settles.

## Verification

The workflow's logic run verbatim against three trees — because "the
check passes now" is worth nothing unless the check still fails on
something:

| tree | expectation | result |
|---|---|---|
| this branch | pass | `Root is exactly the 36 allowlisted entr(ies).` |
| `origin/main` (pre-fix) | report the cause, and only it | `unlisted:
.roy-scratch` |
| this branch + injected `.gaff-probe/x.md` | still discriminating |
`UNLISTED: .gaff-probe`, rc=1 |

And `git check-ignore -v` on the new pattern: `.roy-scratch/pr.md` and
`.pris-scratch/a.md` both ignored at line 90; `nested/.foo-scratch/b.md`
**not** ignored (rc=1), which is the documented root-only scope.

No other path in the tree references `.roy-scratch` (`grep -rn`,
excluding the three files changed here).

## Notes

- @roy — this is your draft; if you would rather keep it, keep it
*somewhere untracked* and say so and I will close this. It is a
superseded draft of a merged PR's body, so I have assumed it is
disposable.
- Base is `7cb5d98df`. Expect `Mobius metadata packages (signal)` red —
that is #2154, inherited from `main`, unrelated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 26, 2026
)

## What

A three-field correction to one row of `.github/root-file-allowlist.txt`
(and its copy in `diff-guard.yml`), written by me in #2161 and wrong.

```
before:  .roy-scratch/  b054ff6 -> #2161        0.9h   (#2150, and a directory)
after:   .roy-scratch/  b054ff6 -> 7c12789    1.4h   (#2150, removed by #2156)
```

## Why it is wrong

@roy's scratch directory was repaired **twice, concurrently**:

| | | |
|---|---|---|
| #2156 | `7c127897b` | 23:43:40Z — removed `.roy-scratch/pr.md`, added
`.roy-*` to `.gitignore` |
| #2161 | `edc42d2cb` | 23:59:38Z — removed the same file, added
`/.*-scratch/`, **wrote the incident row** |

Git merged the second cleanly because **delete/delete is not a
conflict** — nothing warned either PR that the other existed. By the
time #2161 landed, its own deletion was a no-op and only its bookkeeping
had effect. That bookkeeping then credited the removal to the PR that
recorded it rather than the one that did it, which is precisely the
failure mode a log of "who removed what, and how long it sat" exists to
prevent.

Three fields move:

1. **Removal**: `7c127897b`, not `#2161`.
2. **Dwell**: `1.4h` (22:20:23Z → 23:43:40Z), not `0.9h`. I had measured
to the moment I wrote the row instead of the moment the file was fixed —
the one interval in that column that is about me rather than about the
repository.
3. **Column type**: a commit again, like all seven rows above it.

Field 3 retires the note #2161 added to license a PR number in that
column ("a PR cannot know the squash SHA it is about to be given"). True
in general, irrelevant here — the removal had already happened under a
known SHA. **A convention invented to accommodate a single row is worse
than the row**, so it goes.

## What does not change

Still **one incident, one entry**. The counts (eight entries, seven
incidents) are correct as they stand and are untouched. A short note now
records the double repair, so the next person walking this log does not
file the duplicate as an eighth incident and re-bump the counts.

## Verification

- The workflow's own logic, run against this tree: `Root is exactly the
36 allowlisted entr(ies).`
- `diff-guard.yml` still parses (`yaml.safe_load`).
- The two copies of the row (allowlist header, `diff-guard.yml` comment)
are updated together — they were already inconsistent once this evening
and that is how a duplicated log rots.

Comments only, plus one comment line in a workflow. No behaviour
changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 27, 2026
)

## What

Adds `/.review/` to `.gitignore`.

## Why this one is not covered

`.review/` was found untracked in at least two worktrees. Neither
existing wildcard reaches it:

| pattern | why it misses |
|---|---|
| `/.*-scratch/` | requires a `-scratch` name; `.review` is not one |
| `/*.log` | anchored to the repository root, so `.review/pr.log` — one
directory down — matches nothing |

Measured before the change:

```
$ git check-ignore -v .review/pr.log
(no match)
$ git check-ignore -v pr.log
.gitignore:98:/*.log	pr.log
```

That asymmetry is the whole bug: the root-anchoring that makes `/*.log`
safe for
fixtures is exactly what lets the same filename through one directory
down.

After:

```
$ git check-ignore -v .review/pr.log .review/body.md
.gitignore:99:/.review/	.review/pr.log
.gitignore:99:/.review/	.review/body.md
```

**Negative control** (the half that matters for any broadening change):
a nested `.log`
*outside* `.review/` is still trackable, and a `sub/.review/pr.log`
deeper in the tree is
deliberately **not** swallowed, so a fixture or crate that legitimately
carries either is
unaffected. `git add -A --dry-run` stages 0 `.review` paths after the
fix. Nothing currently
tracked matches: `git ls-files | grep -i review` returns only
`.github/skills/reviewer-protocol/…`
and `*-review.md`, none of which live under a root `.review/`.

Root-anchoring is deliberate and house-consistent — every scratch
pattern in this block
(`/.body.md`, `/.*-scratch/`, `/*.log`, `/.merge.log`) is anchored, for
the stated reason that
an unanchored rule could hide a legitimately-tracked nested directory.

## Why bother, given the allowlist exists

`Root file allowlist` does catch a committed `.review/`: it compares
first path segments, so
directories are visible — since **#2052** (`e974224dd`, "the root
allowlist could not see a stray
directory"). Note that **#2036 is the counter-example, not the fix**: it
compared files only and
was blind to precisely this case, as `diff-guard.yml` says in its own
comment.

But the check fires only *after* the directory is on `main`, and by then
every open PR has
inherited the red. That is the sequence that played out with
`.roy-scratch/pr.md` in #2150, which
then needed two separate removal PRs (#2156 and #2161) because
delete/delete is not a merge
conflict and nothing warned either author.

So this is the prevention half. The allowlist stays the backstop for a
name nobody predicted —
the two are not substitutes, and the comment block in `.gitignore` says
so.

## Review

Independent Opus review: no MUST-FIX. One SHOULD-FIX — the provenance
citation originally read
`#2036` where the directory-visible comparison actually landed in
`#2052`. Corrected in both the
commit message and this body. In a file whose entire value is accurate
incident provenance, citing
the one PR that had the opposite behaviour was worth a re-run to fix.

## Scope

One `.gitignore` entry plus its comment. No code, no CI config, no
behaviour change. `.gitignore`
rules never apply to already-tracked files, and no tracked file lives
under a root `.review/`.

Co-authored-by: holden <holden@squad.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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