sots-engine/docs/H-probes.md

241 lines
15 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Lane H — five live probes, predicted before the build
Lane H, 2026-09-08. Worktree `wip/probes` off `main` `b2bad30`. Every number below was written
**before** the shim was built and before any run (method rule 2). Each section ends with the ways
the prediction can fail and the symptom of each, so a wrong prediction is informative rather than
merely wrong.
The lane exists because of method rule 18: five open questions that are one probe away and that
other lanes have been circling statically.
---
## P1 — the determinism oracle, regenerated
Lane N's End Turns overwrote the three autosave *contents* on VM140. The file set is unchanged, so
every documented Load-dialog row position still holds, and `ref-turn2.sav` on the VM still hashes
`ab4ac2d7e2977260…` (checked before the run). What is gone is the byte-identical reference pair.
**P1. Loading `ref-turn2.sav` and pressing End Turn once, under build
`recap-7584bad-20260908T0615Z` with `hooks=trace`, writes**
```
(Autosave EndTurn).sav 66,732 B bb4fd9ac89f41e3bc0db2af08b18ce83417521ac4bcee695fc9fa6ce16e30948
(Autosave).sav 67,219 B 978041acd168b56ed8eb3f5e42e78d5e70eae6e6517d75e659a5eb7ca3d60921
```
These are the values `findings/subsystems/determinism-oracle.md` recorded on 2026-09-07 across five
runs in two processes, and which lane R reproduced under the recap build.
**Why this is worth a run of its own.** Everything since has changed the process the oracle runs in:
a different shim build with nineteen template hooks and seven entry-point detours installed, an
x87 control-word module, a Zuul game played to turn 64 in the same `SavedGames` directory, and eight
save files where there were four. If the pair still reproduces, the oracle is revalidated **across
all of that**, and every Phase-2 comparison that leans on it keeps its footing.
**Falsification.**
* *Either hash differs.* Then the oracle is not stable across the session's changes and nothing
downstream of it can be trusted until the cause is found. Candidate causes, in the order they
would be checked: the installed `binkw32.dll` is not the recap build (hash it); `shim.cfg` is not
the recap trace config (read it); `display.cfg` / `Profile_re.ini` changed (they are inputs to
the process, not to the save, so this would be a *new* finding); `ref-turn2.sav` is not the file
it was (already excluded — hashed before the run).
* *Only `(Autosave).sav` differs.* The pre-turn marker is written from loaded state and the
post-turn one from computed state, so a pre-turn match with a post-turn mismatch localises the
problem to the turn computation rather than to the load or the writer.
* *Only `(Autosave EndTurn).sav` differs.* That would be the more alarming of the two: it is the
load → save round trip, which `determinism-oracle.md` proved stable for the *pre-turn* form, and
it would mean the loader or the writer moved.
---
## P2 — the four tail callees that can draw, and have never been seen to
Lane V2 closed lane K's tier-4 blind spot: of the eleven phase-23/33 vtable targets on
`StrategyServer+0x158` / `+0x15c`, **four** reach a draw on the strategic generator at
`StrategyServer+0x16c`, at eight instructions —
| outer callee | slot | inner | draw sites |
|---|---|---|---|
| `ServerSpyManager_vslot13` 0x008877b0 | spy 13 | — | `Chance` 0x00887c8a |
| " | " | 0x008408e0 | `Chance` 0x00840929, `Chance` 0x00840a3c, `NextInt` 0x008409c7 |
| `ServerSpyManager_vslot14` 0x0088db80 | spy 14 | — | `Chance` 0x0088dc43 |
| `ServerTradeManagerImpl_vslot15` 0x0082cca0 | trade 15 | — | `Chance` 0x0082cdb8 |
| `ServerTradeManagerImpl_vslot13` 0x0088ef80 | trade 13 | 0x00820ca0 | `NextFloat` 0x00820e18 |
| " | " | 0x0088b440 | `NextInt` 0x0088b613 |
Lane Z measured **0 tail words on 8 turns**, so `tail-rng-ledger.md`'s "the tail draws nothing" is a
property of *eight turns*, not of the code. Our corpus has no live trade routes and no spy program.
**The distinction this probe exists to make** is the one a word count cannot: *not reached* versus
*reached and gated*. "Never observed firing" is currently ambiguous between them, and the two have
opposite consequences for a reimplementation — the first says the phase is conditional, the second
says the phase always runs and the gate is a data condition our saves never satisfy.
**P2a. Each of the four outer callees is entered exactly once per End Turn, on every turn, on every
save.** The phase-23 and phase-33 dispatch sites (0x007d97a7 … 0x007d9811, 0x007d989b, 0x007d98a8)
are unconditional `call [vptr+n]` in a straight-line block, so entry is not a function of game
state at all. Entry counters read `calls = 1` per End Turn for each of 0x008877b0, 0x0088db80,
0x0082cca0, 0x0088ef80.
**P2b. On the existing corpus none of the eight draw sites fires.** No `draw_sites` row appears with
`ret_rva` in {0x00887c8f, 0x0084092e, 0x00840a41, 0x008409cc, 0x0088dc48, 0x0082cdbd, 0x00820e1d,
0x0088b618} (the return address is the site plus the five bytes of the `call`), and the tail's
bracket cost stays 0 — reproducing lane Z's number under a strictly better instrument.
**P2c. The three inner functions 0x008408e0, 0x00820ca0 and 0x0088b440 are entered 0 times.** This
is the load-bearing half. Together with P2a it says the gate is *inside* the outer callee, above the
inner call — i.e. an empty container (no spy programs, no trade routes) short-circuits before any
draw is reached — and it localises the condition to one function each rather than to "somewhere in
a 200-function closure".
**Falsification.**
* *An outer callee is entered 0 times.* Then phase 23/33 is itself conditional, and lane V2's
"reached ONLY virtually, from `<site>`" needs a gate note added: reachability from that site is
not the same as being called. This is the single most useful way for P2 to be wrong.
* *An inner is entered but draws 0.* The gate is below the inner call, not above it. The word count
is still 0 and lane Z's table is unchanged, but the map moves.
* *Any of the eight sites fires on the existing corpus.* Then `tail-rng-ledger.md` §2's "the tail's
cost on these turns is 0" is still true of those eight turns and false of ours, and the ledger
needs the tail term after all — the standalone's generator model would be short by that many
words on exactly the turns that matter.
* *An entry counter reads more than 1 per turn.* The dispatch block would then be inside a loop
nobody has read, which changes the cost model from "0 or 1 draw per site" to "per iteration".
**What this probe cannot settle without a manufactured workload.** Whether the sites draw *when*
trade routes or spy programs exist. That needs a save with both, and building one is the expensive
part of this lane; if it is not built, P2 downgrades from "the tail never draws" to "the tail never
draws on a game with no trade and no espionage", which is a **narrower** claim than the one in the
ledger today and must be written that way (rule 6).
---
## P3 — `DetectEncounters`, the last non-research generator residual
Lane Y's standalone models 16 of a turn's ~20 words; the residual on its two calibrated pairs is 4
and 2, and **2 words on both are encounter detection** — sites 0x0050329d (`NextFloat`, depth 4) and
0x007929a4 (`NextInt`, depth 3), each 1 call and 1 word per turn on all three measured turns. Lane Y
refused to model it: *"fitting 2/turn to three observations is not a model."*
Lane I bounds the containing family at `|contacts| × |detectors|` and established that
`EncounterDetect_AssignContacts` draws one inlined `NextFloat` **per (contact, detector) trial,
before the accept test** — so an untec­hed detector has threshold `0.0f`, burns a word, and can never
accept. But lane Z's site run found `AssignContacts` **never ran**: the gate in
`EncounterDetect_ProcessTeamRecord` (0x007ca640) was not satisfied on any measured turn.
So the probe is on the **caller**. `EncounterDetect_ProcessTeamRecord` is bracketed with the three
quantities lane I derived from its instruction stream, computed at hook entry from the record:
* `gate` — ∃ entry of `rec->(+0x28 .. +0x2c)`, stride 0x44, with `entry[0]->+0xfc != 0`;
* `contacts` — entries whose object has `+0xfc != 0`;
* `detectors` — entries whose object has `+0xfc == 0` **and** `+0xfb == 0`;
* `bound` = `contacts × detectors`, the `AssignContacts` worst case.
**P3a. `ProcessTeamRecord` is called once per 0x74-byte `TeamRecord`** in the vector
`EncounterDetect_Run` receives — the same `StrategyServer+0x1e8` vector the tail hooks count as
`encounters`. On `ref-turn2` that vector holds **1** record by the time the tail runs, so the
expected call count per turn is small and single-digit.
**P3b. The turn's 2 detection words are consumed inside `ProcessTeamRecord`.** Both residual sites
sit at depth 3 and 4 below `DetectEncounters`, and `ProcessTeamRecord` is the only depth-2 function
on that path. The bracket over it therefore reads **2 words per turn**, and the detection residual
outside it is **0**.
**P3c — the one that changes the map. The 2 words are *not* the `AssignContacts` family.** `gate`
reads **false** on every record, `AssignContacts` is not called, and the `|contacts| × |detectors|`
bound is **irrelevant to the observed residual**. If that holds, lane Y's 2-word residual and lane
I's bound are two different things that have been discussed as one, and the residual is a *fixed*
per-record cost (one `NextInt` + one `NextFloat`) somewhere above the gate — which is a formula, and
a much cheaper one than the family bound.
**Falsification.**
* *`ProcessTeamRecord` costs 0 words.* Then the two residual sites are reached by some other path
out of `DetectEncounters` and P3b's depth reasoning is wrong; the next probe is a bracket on
`DetectEncounters` itself.
* *`gate` is true and `AssignContacts` fires.* Excellent outcome, not a failure: `contacts` and
`detectors` are then both recorded live and the bound becomes checkable against a measured word
count for the first time. P3c is wrong and lane I's family is the residual after all.
* *The cost is 2 per record rather than 2 per turn.* Then it scales with the record count and the
standalone needs `2 × |records|`, not `+2`. This is distinguishable only if some turn has more
than one record — which is exactly why the call count is recorded, not assumed.
* *My computed `contacts`/`detectors` disagree with the callee's own when `AssignContacts` does
fire.* Then lane I's classification rules are wrong or the record layout is, and the hook says so
by printing both numbers side by side rather than only mine.
---
## P4 — `CreateRaidEncounter`: separating two hypotheses a word count cannot
A *successful* trade-raid roll calls `ServerTradeManagerImpl` slot 17,
`ServerTradeManager_CreateRaidEncounter` 0x008938a0, which draws a `NextInt` at 0x008939ee to pick a
target but returns at 0x0089391c **without drawing** when the candidate vector is empty. It cost 0
words on all three of lane Z's measured turns.
Lane Z read that as "consistent either with no roll succeeding (≈11% on the defaults, so three quiet
turns in a row is unremarkable) or with an empty candidate list every time".
**A correction to that reading, before the run (rule 11).** 11% is the probability of a quiet
*turn*, not of three. With eight player entries and the image defaults the per-turn probability that
no roll at all succeeds is `0.8^8 × 0.95^8 = 0.1113`; three consecutive quiet turns is `0.00138`, or
about **1 in 720**. The two hypotheses were never equally likely. The expected number of successes
per turn is `8 × 0.2 + 8 × 0.05 = 2.0`.
**P4. `CreateRaidEncounter` is entered on essentially every turn — about 2 calls per turn — and
draws 0 words because the candidate list is empty.** Entry counter ≥ 1 on at least two of three
turns; no `draw_sites` row at `ret_rva` 0x008939f3.
**Falsification.**
* *0 entries across three turns.* Under the model that is a 1-in-720 event, so it would mean the
model is wrong, not that we were unlucky. Two concrete candidates, both checkable next: the odds
are not the image defaults on this save (a `StrategyVar` override in the game data), or one of the
two per-player skip gates lane Z found in `GenerateTradeRaidEncounters` (the `visitedMask` bit
test at 0x00893302, the `>2` pre-filter at 0x008933e0) suppresses the *call* while still spending
the `Chance` word — which would also mean the 16 words are not 16 independent rolls.
* *Entries > 0 and words > 0.* Then a raid target was picked, the candidate list is non-empty, and
the standalone needs the extra word **and** the target-selection draw — the row lane Y flagged as
"the one that will bite" becomes live rather than theoretical.
* *Entries far above 2 per turn.* The roll is not the only caller, or the odds are higher than the
defaults.
---
## P5 — the Zuul word count: what the raid loop actually iterates
Lane Z's free prediction, restated by lane Y as a consequence of its model: the two `Chance` sites in
`GenerateTradeRaidEncounters` cost one word per entry of `StrategyServer::Players`, which holds
**8** on the Human saves and **7** on the Zuul ones (`Sim.NumPlrs`: `#empires + one rebel AI per
distinct empire species + 4 NPC pseudo-players`). Lane N ran a Zuul End Turn but with the RNG hooks
**off**, so it does not answer this.
**P5. On a Zuul save each of 0x00893426 and 0x00893513 records `calls = 7`, `words = 7`, for 14
words at the trade-raid block instead of 16**, and the turn total is `14 + 2 (detection) + 0–2
(research)` = **16–18**, against 18–22 on the Human save.
**Falsification, and this is the point of the probe.**
* *8 calls per site on a 7-entry player vector.* The loop is not iterating the player vector. The
next candidates, in order: the `TeamRecord` vector it is handed as an argument, a species list, or
a fixed NPC count plus empires. This is the outcome that would matter most — lane Y's
`trade_raid.cpp` counts `NumPlrs` and would be wrong on every save.
* *7 calls at one site and 8 at the other.* The two sites have different loop bounds; the NPC site's
player-independent gate (`0.0f < S->+0x1a0`) is already known to be all-or-nothing per turn, so
this would mean something else again.
* *Fewer than 7.* One of the two per-player skip gates fires on the Zuul save and never fired on the
Human one — which would make the 16 an upper bound rather than the hard bound lane Z reported.
---
## What is being measured, and what is not
Every hook in this lane declares the strategic generator and **nothing else**, exactly as lane Z's
family does. None of them makes any claim about whether the turn was computed correctly. The entry
counters are register-transparent asm stubs that log and tail-jump to the trampoline: they cannot
be wrong about a calling convention because they never assume one, and they cannot report a word
count, only a call count — which is deliberate, because the word counts already come from two
independent instruments that agree.
A clean run here is a statement about RNG accounting and coverage, and about nothing else.