508 lines
32 KiB
Markdown
508 lines
32 KiB
Markdown
# Earned rules
|
||
|
||
Rules this campaign paid for. Each one exists because it was violated first and something wrong got
|
||
published, or nearly did. Lane briefs should reference this file rather than restating it.
|
||
|
||
Ordered by how much damage the violation caused.
|
||
|
||
---
|
||
|
||
## 1. A green verdict is not evidence. Coverage is.
|
||
|
||
`tracecmp` exit 0 means "the declared regions agreed". It does **not** mean the hook compared
|
||
anything. B4 found three hooks that each printed `0 diverged` while comparing nothing:
|
||
|
||
- `describe_args` ran **before** `regions()`, so every logged argument was one call stale;
|
||
- the `StrategyServer` has **two bases 4 bytes apart** — reading the wrong one yields an empty
|
||
player vector, zero declared regions, and a confident "1 call, 0 diverged";
|
||
- a Ghidra-base number was used as a raw struct offset (`0x64` vs `0x60`), enumerating a vector's
|
||
spare capacity.
|
||
|
||
Structural fixes now in place: compile-time-required `Coverage` on every descriptor (a hook without
|
||
one does not compile), guard regions that **localise** undeclared writes by offset rather than
|
||
reporting that a hash moved, replace-mode records, and `tracecmp --strict-coverage`. A hook claiming
|
||
"complete" while a guard caught an undeclared write counts as a **divergence**.
|
||
|
||
**Read the trace, not the verdict.** Every hook bug above was visible to a reader and invisible to
|
||
the exit code.
|
||
|
||
## 2. Write the prediction down before the run
|
||
|
||
Commit the expected result *before* building, not before running. Lane U committed its prediction
|
||
before the build was even staged; lane P wrote its expected residual (`next_id` short by exactly 1
|
||
on every completion call) and lane V confirmed it live.
|
||
|
||
The strong form: **a prediction that survives a changed workload tests the model, not the
|
||
recording.** Lane U's call 9 was not the call its prediction was written against — the AI had picked
|
||
a different tech — and it unlocked three nodes at costs appearing in no earlier report. Numbers that
|
||
never occurred cannot have been memorised.
|
||
|
||
Include a falsification section: how the model could be wrong, and the symptom of each way.
|
||
|
||
## 3. Static reading finds what behavioural comparison cannot
|
||
|
||
Both of these compared clean and were still wrong:
|
||
|
||
- `ApplyTechEffect` had an early return that would have made **every real call a no-op**;
|
||
- the RNG divisor was wrong on 0.78% of draws and flipped **zero** decisions in 10⁶ trials.
|
||
|
||
Conversely, behavioural compare found the `MoveFleet` rounding that static reading had missed. They
|
||
are different instruments. Use both; trust neither alone.
|
||
|
||
## 4. Prefer the instruction stream to the decompiler for control flow
|
||
|
||
**An inlined `std::vector` destructor looks exactly like a branch.** The `je` skips only the
|
||
`operator delete`; both arms converge a few hundred bytes later. This has produced two wrong
|
||
published findings — `turn-spine.md`'s "deferred end-of-turn tail" (there is no such branch) and a
|
||
near-miss on `Streamable::Write`.
|
||
|
||
Any decompiler `if` that wraps a whole tail is suspect until you have found the converge point in
|
||
the disassembly.
|
||
|
||
## 5. Size structs by enumeration, never by what the code touches
|
||
|
||
`_Alval` is `std::allocator<char>`, an **empty class**: it occupies a word and is never loaded or
|
||
stored, so it is invisible to touch-based analysis and undercounts by exactly 4. That produced a
|
||
`std::string`-is-0x18 scare against a correct campaign-wide 0x1c.
|
||
|
||
An **enumeration** — serializer, constructor, copy constructor, or container stride — can show
|
||
**absence** where a touch-scan cannot. Same trap is live for `std::vector` here:
|
||
`{_Myfirst,_Mylast,_Myend,_Alval}` = `0x10`, **allocator-last**, the opposite of the shape the MSVC
|
||
textbooks describe.
|
||
|
||
Related: the on-disk primitive is **not** the memory kind. A member held as `int16`/`int8` is
|
||
written by `WriteInt` and is **four bytes on the wire**.
|
||
|
||
## 6. A path no save exercises is a hypothesis
|
||
|
||
Label it as one. `spies2`, `SysMem` and `mts` were "closed" with count 0 in every save — no element
|
||
value has ever been observed. Lane A later found the element typing was wrong, and it was invisible
|
||
*precisely because* nothing exercised it. The flagging discipline is what made that checkable.
|
||
|
||
**Saves can be manufactured.** No species-5 save existed, so lane V built one and closed a row that
|
||
had been disassembly-only for a day. Prefer building the workload over weakening the claim.
|
||
|
||
## 7. Round-trip byte-identity is not coverage
|
||
|
||
`ar.any` bodies round-trip trivially by copying bytes nobody understands. Measure the split between
|
||
items a field **names** and items a node merely **carries**. That reframing moved a "100% round-trip"
|
||
result to an honest 38%, and then to 99.9% by actual work.
|
||
|
||
## 8. Two independent-looking checks sharing a hidden assumption are one check
|
||
|
||
`save_reader.py` types items from its **own** kind catalog, not the schema passed to it — so on real
|
||
saves the catalog and the schema agreed with each other and were **both wrong**. Its failure mode is
|
||
silent agreement, not a desync.
|
||
|
||
## 9. Rankers rank; they never filter
|
||
|
||
A cohort/frequency ranker would have **discarded the correct answer**: the function that appends to
|
||
`ObservedTech` touches only two offsets on `ServerPlayer`, so every `--min>=1` cohort filter drops
|
||
it. What closed the case was a plain query plus one call-graph lookup.
|
||
|
||
Displacement scan for recall, call graph for disambiguation. Neither alone sufficed. And report the
|
||
false-positive rate honestly — that scanner's class-level precision is ~13% by function; its value
|
||
is a 900× search-space cut, not accuracy.
|
||
|
||
## 10. Derive experiment parameters from the ISA, not from assumed mnemonics
|
||
|
||
An x87 sensitivity experiment was briefed as `0x027f / 0x127f / 0x137f`. In fact `0x027f` is 53-bit
|
||
(differing from `0x127f` only in infinity control, ignored since the 387) and `0x137f` is 64-bit
|
||
extended. Run literally it returns "all three identical" — true, and the conclusion drawn from it
|
||
would have been wrong on both axes, because it tests single precision not at all and rounding not at
|
||
all. The real probes are `0x007f` and `0x1a7f`.
|
||
|
||
**Brief lanes to challenge the parameters, not just execute them.** Lane F did, and lane G rejected
|
||
its brief's premise outright and was right to.
|
||
|
||
## 11. Correcting the record beats defending it
|
||
|
||
Claims downgraded on evidence, by later lanes: "RNG matched 15/15" (workload luck, not a property);
|
||
B1's and B4's coverage after the harness audit; `StreamableEnum` typing; the deferred end-of-turn
|
||
tail; "the research roll draws exactly one NextFloat" (the branch it fires draws a second).
|
||
|
||
If you find an earlier finding wrong, say so plainly and correct it in place. Nothing here is
|
||
anyone's reputation.
|
||
|
||
## 12. Fix the oracle openly, never quietly
|
||
|
||
Four defects were found in the reference save reader. The right move was **not** to patch them
|
||
mid-campaign in silence: they were recorded, then fixed openly with a test per defect and
|
||
byte-neutrality proven at item granularity (offset sequences identical across ~39,000 offsets per
|
||
save; typed-value deltas balancing to the byte).
|
||
|
||
## 13. Run verification gates as separate commands
|
||
|
||
Never `&&`-chain them. Once, a clean-room failure short-circuited the chain, skipping build and test
|
||
while a separate push still ran — an unverified tree reached `main`.
|
||
|
||
On one merge **both** gates failed and both were real: a raw `FUN_xxxxxxxx` identifier had reached
|
||
`docs/` in the public-capable engine repo, and the **shim cross-build** hit `-Werror=unused-function`
|
||
on a WIN32-only TU. Host `ctest` passed cleanly both times and would have hidden both.
|
||
|
||
**If a lane cannot cross-build its own shim TU, it must say so, and the integrator must run the
|
||
CT111 shim build before pushing.**
|
||
|
||
## 14. Never hand-resolve a generated header
|
||
|
||
Always regenerate from `ghidra/addresses.json` plus the `ghidra/addresses.d/<lane>.json` fragments.
|
||
Hand-resolving a merge conflict there silently dropped a new entry and broke the build — twice.
|
||
|
||
Per-lane fragments exist because a shared single file caused three cross-lane sweeps in one day.
|
||
Duplicate names across fragments are a **hard error**, never last-wins: two lanes disagreeing about
|
||
an address is exactly what must not be papered over.
|
||
|
||
## 15. Report thin coverage as loudly as divergences
|
||
|
||
A green run that establishes twenty facts is not a strong result. `ComputeBudget` compared 4,284
|
||
calls with 0 divergences — but only **20 distinct states**, with 4,278 of them the UI polling one
|
||
player, and **13 of 22 slots zero on every call**, including 5 of 6 declared input-boundary slots.
|
||
|
||
The most useful sections of the best reports here have been the honest lists of what was *not*
|
||
covered.
|
||
|
||
## 16. Inlined draws are invisible to call-graph sweeps
|
||
|
||
The combat resolver's `NextFloat` is **inlined**, so the only call-graph edge it leaves is
|
||
`caller -> RNG_Twist`, which reads as a bare Twist and is not one — it is the lazy twist *inside*
|
||
`NextFloat`. Lane K's "no NextFloat in that subtree" was wrong for exactly this reason.
|
||
|
||
An image-wide scan for the MT tempering immediates at instruction boundaries finds **14 game
|
||
functions with inlined draws that no RNG call-graph sweep can see**, two of them inside
|
||
`ProcessTurn`'s closure at depth 4.
|
||
|
||
Scan for the **constants at instruction boundaries**, not for calls. Any RNG accounting built from
|
||
the call graph alone is a lower bound.
|
||
|
||
## 17. A truncated range makes a loop look like a sequence
|
||
|
||
Lane J read a function as straight-line because it dumped the range at **Ghidra's reported size**,
|
||
and the outer back-edge fell just outside it. A delegated sweep made the same mistake independently.
|
||
That changed the draw count of the very site it was there to measure.
|
||
|
||
This is the same defect lane X hit from the other side: clipping sweeps at `fva + sizeInBytes` lost
|
||
**11%** of functions to mid-instruction truncation, and sweeping to the *next function start* took
|
||
coverage 89% -> 100%. Ghidra's size is also simply wrong sometimes — it gave 7,499 bytes for a body
|
||
that is 7,641 and ends mid-instruction.
|
||
|
||
**Never trust a function's end. Disassemble to the next function start and find the real boundary.**
|
||
|
||
## 18. Measure first. The lab exists.
|
||
|
||
We have the game running under a hook framework, a debugger, 11 curated saves, a byte-identical
|
||
autosave oracle and a state-checksum tool that localises to named leaves. **Use them before deriving
|
||
anything.**
|
||
|
||
The RNG gap is the case study. Static analysis spent several lanes on it and produced a complete
|
||
draw-site inventory with an unexplained count — genuinely good work that could not close. The answer
|
||
came from ten minutes of live hooking: return-address capture on the entry points attributed every
|
||
word in one turn, and the dominant consumer turned out to hang off a **virtual** edge that no
|
||
direct-edge closure could ever have reached.
|
||
|
||
The general shape: **static reading is for explaining what you measured, not for predicting it.**
|
||
Derivation is the fallback when the path cannot be reached, not the default.
|
||
|
||
A question of the form *"what writes this?"*, *"how many times does this run?"*, *"does this branch
|
||
ever fire?"* is a **watchpoint or a hook**, not a week of reading. Open items that fit that shape
|
||
right now: the `Player.Status` writer between tail phase 31 and the autosave; the `TShn`/`ltis`
|
||
writer; `Summary.Checksum`'s inputs; whether a trade-raid roll ever succeeds (a word count cannot
|
||
separate "no success" from "empty candidate list" — a hook on the callee separates them instantly).
|
||
|
||
Corollary, from the same session: **search the notes before the binary.** The dominant RNG consumer
|
||
was already identified in `strategic-turn-internals.md` months of lanes earlier. What was missing was
|
||
never the identification — it was the connection.
|
||
|
||
## 19. The instrument can perturb the thing it measures — check, do not assume
|
||
|
||
Lane H bisected an autosave that differed by 4 bytes from a byte-identical input, across
|
||
configurations, and found the cause: **a MinHook detour on one function changed the game's
|
||
behaviour.** The patched 5 bytes land on a clean prologue boundary with no branch target inside,
|
||
and the *suppressed* draw belongs to a function that runs earlier in the turn than the hooked one.
|
||
The mechanism is still undetermined.
|
||
|
||
Two things follow. First, the ledger built with a different hook set was checked and came out
|
||
**behaviour-neutral** — its numbers stand. Second, that check had never been run before; it came
|
||
out right by luck, not by design.
|
||
|
||
So: **before trusting a measurement, take the same measurement with the instrument removed.**
|
||
`hooks=off` in two fresh processes is cheap and it is the control. A hook that changes the autosave
|
||
has invalidated every number taken with it installed, and you will not notice from inside the run.
|
||
|
||
Corollary for the oracle: re-run it after any change to the hook set, not only after engine changes.
|
||
|
||
## 20. A count cannot separate "did not fire" from "fired and found nothing"
|
||
|
||
`CreateRaidEncounter` cost 0 words on every measured turn, which was read as "the roll never
|
||
succeeds (~11%, unremarkable over three turns)". An entry probe showed it is **entered 2x on one
|
||
turn and 1x on the next, drawing 0 every time** — the rolls succeed at about the predicted rate and
|
||
the *candidate list is empty*. Also, three quiet turns at that rate is 1-in-720, not unremarkable:
|
||
when a prior says "unremarkable", compute it.
|
||
|
||
Same shape elsewhere in the same lane: four tail callees were "never observed firing" and turned out
|
||
to be **entered every single turn and gated inside** — a much stronger negative than reachability.
|
||
And a residual measured at "2 words per turn" on two saves is **one word per player that passes a
|
||
gate**, where exactly one player passes on both corpus saves. A constant fitted to two observations
|
||
is not a constant.
|
||
|
||
Instrument the **entry**, not just the cost.
|
||
|
||
## 21. A lane never touches the shared working directory — worktree or clone, always
|
||
|
||
Two incidents, one session. A VM lane ran `git checkout --` in the shared `sots-engine` worktree and
|
||
discarded another lane's in-flight generated header. An AI lane ran `git checkout -b` there, which
|
||
**moved the repo's HEAD**, so the integrator's concurrent commit landed on the lane's branch instead
|
||
of `main` and was orphaned when that branch was deleted. Nothing was lost either time, but only
|
||
because both lanes reported it.
|
||
|
||
`git worktree add` or a separate clone. This is the same failure the `addresses.d/README` documents
|
||
for `git add`, one level up: **shared mutable state plus concurrency, with no lock.**
|
||
|
||
The integrator's counterpart: after any concurrent round, check `git log --oneline` on `main` before
|
||
pushing, and confirm the commits you believe you made are the ones that are there.
|
||
|
||
## 22. Union-resolving a merge is not textual concatenation
|
||
|
||
Two independent modules both added a branch to the same `if`/`else if` chain and a file to the same
|
||
CMake source list. Concatenating the conflict halves produced a `)` in the middle of the list and an
|
||
`if` body with no closing brace — the host build passed (those files are Windows-only) and **the
|
||
shim cross-build failed**, which is exactly the gap rule 13 exists for.
|
||
|
||
When both sides add a *member of a construct*, the resolution is to merge them **into that
|
||
construct** — one list, one chain — not to paste one after the other. Read the resolved region
|
||
before committing, and let the cross-build be the judge.
|
||
|
||
## 23. A live-verified module can still be wrong — thin coverage is how
|
||
|
||
`ComputeBudget` was compared against the original on **4,437 live calls with 0 divergences** and was
|
||
still wrong: its interest literals are **widened floats** in the image (`(double)0.01f`,
|
||
`(double)0.15f`) and then truncated, so a treasury of exactly 50,000 earns **499, not 500**. Our code
|
||
used exact decimals. Sixteen hand-computed test expectations moved by one when it was fixed.
|
||
|
||
It survived because those 4,437 calls presented only **20 distinct states, none of them on a
|
||
boundary** — the exact thin-coverage caveat recorded against that lane at the time, later vindicated
|
||
by a different lane doing arithmetic the compare never exercised.
|
||
|
||
So "live-verified, 0 divergences" is a statement about **the states that occurred**, never about the
|
||
function. Two defences, both cheap: report the distinct-state count next to the call count (rule 15),
|
||
and test the boundaries by hand — a value that lands exactly on a cap, a treasury that divides
|
||
exactly, an empty container.
|
||
|
||
**A float literal in this image is a widened `float`, not a `double`.** It has now bitten the money
|
||
chain, the bankruptcy divisor and the gate constant. Read the four bytes; do not assume the decimal.
|
||
|
||
> **Corollary (2026-09-09).** *Predict at the site, not at the bracket.* Lane AP predicted every
|
||
> spy-half draw exactly and still got its headline prediction wrong: it had forecast the whole
|
||
> `OnAllCombatDone_Tail` total, and the total moved because an **unrelated subsystem entered the
|
||
> bracket** — the trade-raid roll, growing 2 → 8 words as the AI's navy went 157 → 251 ships. A
|
||
> bracket total is a sum over every subsystem inside it, so quoting one commits you to modelling all
|
||
> of them. Predict and report **per site, by return address**; let the bracket total be the check on
|
||
> your arithmetic rather than the claim. (The consolation is that the surprise was itself a result:
|
||
> an independent live re-confirmation of another lane's per-fleet model, on a different map.)
|
||
|
||
## 24. Never reuse a build directory across trees — a stale binary measures cleanly
|
||
|
||
The integrator's gate rsync excludes `build*` to save transfer time, so the remote build directory
|
||
survived from an *earlier* tree. rsync preserves mtimes, so `cmake --build` saw objects newer than
|
||
sources, relinked nothing, and produced a **stale binary that measured perfectly**.
|
||
|
||
The reported figure was 128 diverging leaves. The real figure for that tree was **124**. Two lanes'
|
||
merged work was invisible, and both had independently reported the better number from their own
|
||
branches — which is the only reason it was caught: two lanes reporting 126 from a 128 baseline while
|
||
`main` also read 128 is arithmetically impossible.
|
||
|
||
The failure mode is the dangerous kind: **no error, no warning, and a plausible number.** It is the
|
||
same shape as querying a build directory while a background job owns it (which also looked exactly
|
||
like nondeterminism in the engine).
|
||
|
||
So: **a fresh build directory per measurement**, or `rm -rf` the build tree before building. And when
|
||
a merged result does not reproduce a lane's own number, suspect the build before suspecting the lane.
|
||
|
||
**Implementation, both halves — I got this wrong once by doing only the second.** Exclude local build
|
||
directories from the transfer *and* remove the remote ones:
|
||
|
||
```sh
|
||
rsync -a --delete --exclude .git --exclude 'build*' ./ host:/path/ # do not ship local build dirs
|
||
ssh host 'cd /path && rm -rf build-host build-shim && cmake --preset host && …'
|
||
```
|
||
|
||
A `CMakeCache.txt` records the **absolute path it was created in**, so a local build directory copied
|
||
to another machine poisons the build there — `CMake Error: … is different than the directory … where
|
||
CMakeCache.txt was created`, and every test fails.
|
||
|
||
**The signature tells you which failure you have:** a real regression changes the *pass* count
|
||
(`57/58`); a broken configure changes the *total* (`0% of 48`, where a healthy run is 58). If the
|
||
denominator moved, stop reading the failures and fix the build.
|
||
|
||
## 25. Commit with a pathspec — the index is shared, staging by path is not enough
|
||
|
||
Four times in one session, a lane's `git commit` swallowed another lane's staged files in the shared
|
||
`sots-re` clone. The last one was plain `git commit -m ...` with no `-A` anywhere: **every lane had
|
||
staged by path exactly as instructed, and it still happened**, because `git add <path>` puts a file
|
||
in *the repo's one index* and the next `git commit` takes everything in it.
|
||
|
||
The fix is a form of the command, not more discipline:
|
||
|
||
```sh
|
||
git commit -m "message" -- path/one path/two # commits ONLY those paths
|
||
```
|
||
|
||
With a pathspec, git commits the named paths from the working tree and **leaves the rest of the
|
||
index untouched** — verified: with two files staged, `git commit -- a.txt` committed only `a.txt`
|
||
and left `b.txt` staged. That is exactly the isolation lanes need, and it costs nothing.
|
||
|
||
So: **lanes commit with a pathspec, always.** `git add` by path stays good hygiene for reviewing a
|
||
diff, but it is not the protection — the pathspec on `commit` is.
|
||
|
||
The deeper fix, if this recurs: give each lane its own clone of `sots-re`, the way each already gets
|
||
its own `git worktree` of `sots-engine` (which is why that repo has never had this problem). Rule 21
|
||
covers the worktree side; this is its counterpart for the shared evidence repo.
|
||
|
||
## 26. A control must agree with itself before it exonerates anything
|
||
|
||
Rule 19 says take the measurement with the instrument removed. It did not say what to do when **the
|
||
control itself varies**, and this session that gap produced two wrong readings.
|
||
|
||
Three lanes ran `hooks=off` on `turn1-state` and got **three different files**. So no single
|
||
`hooks=off` run on that workload was ever a control — it could not exonerate or indict anything.
|
||
Separately, one lane observed its instrumented run coming out byte-identical to a `hooks=off` run
|
||
and read that as the instrument being clean; with an outcome set of size k that agreement is a
|
||
**~1/k coincidence**, not evidence.
|
||
|
||
So: **a control must be reproduced in two fresh processes and agree with itself before it is used as
|
||
a control.** If it does not agree, the workload is non-deterministic and the honest move is to pin
|
||
the source of variation (here: the per-client AI seed) and re-run, not to pick the run that suits.
|
||
|
||
Corollary, from the same episode: **"one of N moved" says nothing about mechanism.** Only one of
|
||
three AI empires visibly varied, which looked like a tiebreak among near-equal candidates. In fact
|
||
all three streams differed every run; two empires never reach the seed-sensitive code path at all.
|
||
Before reasoning from *which* items varied, establish whether the others were even exposed to the
|
||
mechanism.
|
||
|
||
> **(c) A control that agrees certifies a procedure, not a state — and it means "no exposed
|
||
> decision was reached", never "the AI is deterministic here."** Three lanes in one day:
|
||
> `ad-turn27 → 28` varied in 94 leaves (the AI's build, fleet-assignment and colony choices on a turn
|
||
> where thirteen ships completed, a design was created and a system colonised); `as-turn15 → 16`
|
||
> varied in 22 (a research pick, on a turn the AI entered with `ResTNm == ''`); `ar-turn37 → 39`, the
|
||
> same lineage as AD ten turns on, agreed byte-for-byte across three processes — on two turns where
|
||
> the target was held then completed, no design was created and two freighters a turn joined
|
||
> existing fleets. The per-process seed was present in all three. It reaches the save only through a
|
||
> decision whose outcome set is not a singleton given the state, and whether such a decision is
|
||
> reached is a **predicate on the pre-turn save** — rule 28's shape: `ResTNm == ''` names a pick
|
||
> turn **for a player that has an AI client** (lane BP, 2026-09-09: `ad-turn27` has seven
|
||
> non-human players and the engine builds **three** AI clients — `RunAI` fires for net ids
|
||
> 32/496/512; the four NPC factions 528–576 get no client and their `ResTNm == ''` is inert. A
|
||
> save-field predicate must be read against **who actually runs**, which is a property of the player
|
||
> kind, not of the field. **`NumOwn == 0` is NOT shown to be a gate** — 496 and 512 have
|
||
> `NumOwn == 0` *and* clients; my earlier wording said it was, and that was an over-read of BP's
|
||
> report); `NumDes` moving names a design turn; a batch of completions names a fleet-assignment turn;
|
||
> one consumer (`0x0069dbb0`) has a 3–40-turn fuse and cannot show inside two turns of a load.
|
||
> So: record the pair as (save, procedure, hashes) and write the exposure facts next to the hashes;
|
||
> never extend a certified pair by a turn without its own two-process agreement (`ar-oracle-A-post`
|
||
> enters turn 40 with `ResTNm == ''` and is predicted to vary); and when a control *does* vary,
|
||
> decode which consumer was reached before reasoning from the leaves — "the AI varies per process"
|
||
> is a fact about a turn, not about the engine.
|
||
|
||
> **(c) is weaker than it was published as — read this before using it.** Written 2026-09-09 from
|
||
> three states read after the fact; **its first forward test falsified it twice the same day**, and
|
||
> the honest statement is now:
|
||
>
|
||
> * `ResTNm == ''` **predicts that a pick is made, not that it varies.** Exposure needs the candidate
|
||
> list to have **more than one entry**, and lane BQ measured `candidatesTried = 1` on a turn whose
|
||
> pick agreed across two processes. **That count is not readable from the pre-turn save.**
|
||
> * There is a **false-positive class visible before any run**: players carrying the signature who
|
||
> have no AI client or `NumOwn == 0`. Read the predicate only for a non-eliminated AI player that
|
||
> actually runs.
|
||
> * **There is no combat term and there needs to be one.** A turn was exposed through
|
||
> `RunCombatRound`, and the save names that condition as plainly as it names `ResTNm`: an AI fleet
|
||
> standing at an enemy colony. `Auto Resolve Peacefully` means *"I commit no ships"*, **not**
|
||
> *"no combat occurs"* — the defender's planet still fires, and its kill count varied.
|
||
> * **A fourth term is not a predicate on the save at all.** The same turn ran one way by
|
||
> continuation and another way after a load, **both reproducibly** — so exposure is a property of
|
||
> `(save, procedure, load history)`, not of `(save, turn)`. **The mechanism is now measured
|
||
> (lane BS): the difference is that the load route does not run the machinery at all.** A
|
||
> `Colonize` task alive in the AI agent by continuation is simply *absent* after a load —
|
||
> `AITColonize::Execute` fires twice one way and zero times the other, and the fleet-gathering hub
|
||
> is never reached. AI agent state that the save does not carry is a real input, and no amount of
|
||
> reading the save will find it. The instruction to record a pair as
|
||
> `(save, procedure, hashes)` was already right, and is now load-bearing for a reason it did not
|
||
> anticipate.
|
||
>
|
||
> So (c) is a **useful screen, not a decision procedure**: it can tell you a control is *likely* to
|
||
> vary, and it cannot certify that one will agree. A resolver holds the restatement.
|
||
|
||
> **(d) When the instrument cannot be removed, make it reproduce a natural run.** A seed pin needs
|
||
> hooks, so "the un-instrumented pinned game" does not exist and rule 19's control is unavailable.
|
||
> Lane BP's answer: run the save once *unpinned* with the seeds logged, pin the next processes to
|
||
> **those observed values**, and require a pinned run to reproduce the unpinned one **byte for
|
||
> byte** before anything is read from a pinned pair. That agreement is the pin's own control — a
|
||
> perturbing pin could match a natural run only by a 1/k coincidence — and it is a **noise mask**:
|
||
> any word that differs between two runs with identical outputs cannot be a word the applier reads
|
||
> (lane CB's argument). On `ad-turn27` that mask reduced a 9-block, 81-element comparison to three
|
||
> words. The wildcard form of a pin (every client to one constant) is a valid intervention and a
|
||
> worse one: it certifies nothing about a turn that happened, and it would have hidden that the
|
||
> save's seven non-human players produce three clients. Pin to what was observed; count what ran.
|
||
|
||
## 27. A ratchet is meant to break when the corpus grows — do not move it, and scope your control
|
||
|
||
The save corpus went from 11 to 19 in one evening as lanes finally manufactured workloads nobody had
|
||
before. The coverage ratchet broke immediately: named coverage fell to 97.6% against a 99.99 bar,
|
||
because the new saves carry content no schema names — `<rest:prep>`, `TacReports`, `Lay`,
|
||
`<rest:trdmgr>`, `fwarn`.
|
||
|
||
**That is the ratchet working.** The reader is sound on every one of them (`round trip: tree
|
||
identical, typed identical`); it simply does not *name* the new items. The temptation is to lower the
|
||
bar so the suite goes green. Type the content instead — a ratchet you move on contact is a
|
||
decoration.
|
||
|
||
Two integrator lessons from the same episode:
|
||
|
||
- **A new save can break a gate without any code changing.** Before blaming a merge, run the suite
|
||
against the corpus as it was. Both times this happened the merge was clean.
|
||
- **Scope that control by an explicit list, not by a name filter.** My first control excluded
|
||
`*traderoutes*` and still failed, which briefly looked like the merge *was* at fault — the lane had
|
||
added more saves under other names while I worked. Pin the control to the exact set you mean.
|
||
|
||
## 28. A zero on a gated path is not a negative until you have the predicate
|
||
|
||
Four lanes measured one call site at zero. Each reported it correctly. One lane read it statically and
|
||
placed the draw. The campaign nevertheless recorded "the tail does not draw" — and it does.
|
||
|
||
The site is gated on a fleet standing at a trade-sector node whose owner's bit is set in a mask that
|
||
reads **252 in every save we own**. Nobody had the predicate, so everybody hunted the nearest visible
|
||
container: trade routes, then freighters, then a deployed spy. The actual switch is a **tech**
|
||
(Commerce Raiding, one turn's research) that flips the mask to 253. One lane's stated "next
|
||
condition" was falsified by its *own* save, which had a freighter on an active route the whole time
|
||
with the site still at zero.
|
||
|
||
So, four practices:
|
||
|
||
1. **A zero is a negative only once the gate is a predicate on save fields and the corpus has been
|
||
counted against it.** Until then it is "not reached in the states measured" — which is a different
|
||
sentence and should be written as one.
|
||
2. **Name the next workload from the failed conjunct, not from the nearest container.** Decode the
|
||
gate first, then build the state it names. Lane AC disassembled the chain *before* building
|
||
anything and found the predicate had nothing to do with what three lanes were chasing.
|
||
3. **A field constant across the whole corpus is a coincidence until its writer is found.** `tscr` at
|
||
252 everywhere looked like a fact about the game; it was a fact about our saves. Compare rule 8's
|
||
eighteen fields that split a roster identically because nothing exercised them.
|
||
4. **Pair entry probes with the return-address ledger before calling a subtree draw-free.** An entry
|
||
probe on an inner function reading zero says nothing about a draw sited in its *caller* — which is
|
||
exactly where the spy half's detection roll lives.
|
||
|
||
**Confirmed live, 2026-09-09.** Lane AS measured both instruments on the same turn: the entry probe
|
||
on `SpyManager::Slot13RngCallee` read **0**, while the return-address ledger recorded the detection
|
||
roll firing for **one word** in the caller's body. The zero was never a statement about the subtree —
|
||
only about which function the call happens to sit in. (The roll is a plain call, not an inlined one;
|
||
the earlier claim that it was inlined was wrong, and the practice holds either way.)
|
||
|
||
5. **A fact about one map is not a fact about the game.** Practice 3's sibling, and it cost a lane a
|
||
whole workload plan. One lane reported that "an enemy colony with an asteroid belt does not exist in
|
||
this game", which was true of *its* map. All twenty-two corpus saves have a usable belt, two of them
|
||
already hold four AI-owned ones, and the next lane's very first generated map had two — **zero
|
||
regenerations**. Before building a state to satisfy a condition, count the corpus against it: the
|
||
state you need may already be sitting in the save set.
|
||
|
||
6. **Read the polarity before reading the count.** In the same chain, the jump goes *past* the roll:
|
||
with the belt gone there is **no draw and the spy is detected anyway**. A zero at that site therefore
|
||
means two opposite things, and a lane that assumed the intuitive polarity would have reported the
|
||
detection case as "did not fire".
|