sots-re/findings/subsystems/research-replace.md

251 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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.

# Taking `TechTree::ProcessResearch` + the unlock cascade from `compare` to `replace` (lane CR)
**Lane CR, 2026-09-09. Guest: VM145 (`sots-re-win10-145`, `re@192.168.10.145`) — held by this lane.**
VM140 is untouched (it is the exclusive reference guest).
The board's headline metric is `tools/displacement.py`: **3 replaced / 7 compared / 7 modelled /
3 mapped**. `TechTree::ProcessResearch + unlock cascade` is the best-evidenced `compared` row — 35
calls, three workloads, 0 divergences, `tracecmp` exit 0, and an advance prediction that held on a
*changed* workload. This lane asks the only question that moves the metric: **can our code run
INSTEAD of the original's, live, with a byte-level oracle holding afterwards?**
Everything above the `## 4.` heading was committed **before** the shim was built or staged
(rule 2). Nothing above that line is edited afterwards; corrections are made below it and named as
corrections (rule 11).
---
## 1. The state, and why this one
### 1.1 The candidate the brief names, and why it is not sufficient on its own
`ref-turn2.sav` (`ab4ac2d7…`, = `verify/results/saves/turn2-state.sav`) is the campaign's oldest and
most reproduced oracle: one End Turn by the **load** route, `(Autosave EndTurn)` `bb4fd9ac…` /
`(Autosave)` `978041ac…`, **5 + 1 processes**, certified-pairs row 1.
**It does not exercise a research completion.** This is not an inference from the exit code — it is
read off lane U's own instruments, both of which are in this repo:
* `verify/results/compare/unlock-b3-t1.md`: 3 calls, 3 compared, 0 diverged, and
**`0 undeclared write(s) in 0 call(s)`**. Every completion in every measured run produces
undeclared writes on the `player` and `tree_header` guards, because that is where
`OnTechResearched`'s tech effects and the tree's order counter land. Zero undeclared writes is
zero completions.
* `verify/results/shim/unlock-shim.log`: the first three `research: cascade ok=1 …` lines of the
five-turn continuation read `completions=0 unlocked=0 otch_appends=0`. Those three lines *are*
the `ref-turn2` End Turn.
Reading the trace rather than the verdict (rule 1) says what that turn *does* do: call 0 advances
the generator by one word (`left` 432 → 431), pushes node 144's progress 2879 → 5768, sets its flag
1 → 2 (`OverBudgetNotified`) and posts one event (`next_id` 3 → 4). Calls 1 and 2 change **nothing
at all**. So `ref-turn2` + one End Turn is a real workload for the *pass* and an empty one for the
*completion path*. **A replace-mode oracle taken there would be exactly the green verdict on a hook
that compared nothing that rule 1 exists to catch.**
### 1.2 The state this lane uses
**`verify/results/saves/turn3-state.sav`** — 67,219 B, sha256
`978041acd168b56ed8eb3f5e42e78d5e70eae6e6517d75e659a5eb7ca3d60921`, `Summary.Turn = 3`,
`Sim.ModCount = 24`. It is *the output of certified-pairs row 1*: the `(Autosave).sav` that
`ref-turn2` + one End Turn produces. It is already on VM145 under that hash as `(Autosave).sav`.
**Procedure: one End Turn. Route: LOAD.** Stated because the route is part of the pair
(rule 26(c) fourth term; lane BS): the same turn reached by continuation and by load is two
different, individually reproducible things, and this lane deliberately does **not** inherit the
continuation's turn-4 numbers as an assumption — it predicts them and lets the load route falsify
them (P1).
Why this turn: in lane U's five-turn continuation the **fourth-turn** call is the completing one.
The call that enters with `turn = 4` allocates 2,898 points to tech 144, completes it, and runs the
cascade — `completions=1 unlocked=3 otch_appends=1` in the shim log, `next_id` 5 → 7 and
`observed_techs` 440 → 484 bytes in the trace. `turn3-state.sav` is the state that turn starts from.
### 1.3 The exposure screen (rule 26(c)) — a screen, not a decision procedure
Read from `turn3-state.sav` with `verify/save-reader/save_reader.py`. Eight players; per lane BP the
engine builds AI clients for net ids **32, 496, 512** only, and the four factions at 528–576 carry
their signature **inertly**.
| player | species | `ResTNm` | `Status` | AI client | pick-turn signature? |
|---|---|---|---|---|---|
| 16 (human `re`) | 0 | `''` | 4 | **no** (local human) | **false positive** — no client |
| 32 | 2 | `IND_Waldo` | 4 | yes | **no** — mid-research |
| 496 | 0 | `DRV_PlsFiss` | 4 | yes | **no** — mid-research |
| 512 | 2 | `BIO_GnMod` | 4 | yes | **no** — mid-research |
| 528, 544, 560, 576 | 4 | `''` | 0 | **no** | **false positive** — no client, `Status 0` |
**No player that actually runs enters this turn with an empty research target.** That is the best
the screen can say. It cannot say the control will agree — rule 26(c)'s own retraction is explicit
that `candidatesTried` is not readable from a save, that there is a combat term, and that the load
route is a fourth term that is not a predicate on the save at all. **So the screen is why this turn
is worth trying, and the two-process control in §4.1 is the only thing that decides it.**
The lineage where exposure is saturated (`ad-…`/`ar-…`, every turn carrying a signature) is
deliberately not used here.
### 1.4 What is being asked of `hooks=off`
Three fresh-process runs are planned before any replace result is read:
| run | config | what it establishes |
|---|---|---|
| **C1**, **C2** | `shim.cfg.croff` (`hooks=off`, the proxy loads and installs nothing) | the control agrees with itself across two fresh processes (rule 26) |
| **N** | `shim.cfg.crcompare` (one detour, `compare`) | the instrument is behaviour-neutral on *this* save and route (rule 19), **and** it is where the completion count comes from the hook itself |
Only then the replace runs. C1/C2 use the *same proxy DLL* as every measured run, so the only
difference between control and measurement is the config (lane CB's shape).
---
## 2. The four bytes of every float literal on this path (rule 23)
Two float literals are on the research pass's arithmetic path, and **both are widened `float`s**.
Read here directly out of `dumps/sots.exe` (PE image base `0x00400000`, `.rdata` at `0x009dd000`
file offset `0x5dbe00`), not taken from an earlier note:
| VA | bytes (LE) | value as `double` | is it `(double)float`? | engine constant |
|---|---|---|---|---|
| `0x009e20c8` | `00 00 00 a0 99 99 e9 3f` | `0.80000001192092896` | **yes** (low 29 bits zero) | `kEarlyCompletionRatio = 0.800000011920929` |
| `0x009e5060` | `00 00 00 a0 99 99 a9 3f` | `0.05000000074505806` | **yes** | `kDecayFraction = 0.05000000074505806` |
Both source literals were re-parsed and re-packed: `0.800000011920929` → `000000a09999e93f` and
`0.05000000074505806` → `000000a09999a93f`. **Bit-identical to the image.** So the two constants
`sots-engine/src/game/sim/research.cpp` relies on are exactly the image's, not the exact decimals
that bit the money chain twice.
Two literals that are **not** on this path, and why:
* the tech-cost multiplier (`1.0 − 0.25·n`, floor `0.25`) — `ours` never evaluates it. It calls the
game's own read-only `TechTree::Cost` for every cost it needs, in both modes (`g_env.cost`). This
is a genuine, declared dependency on the original: the effective cost is **not** displaced.
* `ResearchSpendFloor` / `Ceiling` are integer `×50/100` and `×150/100` with a 32-bit wrapping
multiply — no float involved.
---
## 3. Predictions, committed before the build
Falsification symptoms are given for each. Predictions are made **per site** and not at a bracket
total (rule 23's 2026-09-09 corollary).
### P0 — the control agrees with itself
`turn3-state.sav`, one End Turn, load route, `hooks=off`, **two fresh processes** produce identical
`(Autosave EndTurn).sav` and `(Autosave).sav`.
*Falsified if:* the two processes differ. Then this workload is exposed, the screen in §1.3 was
insufficient (which is what rule 26(c)'s retraction predicts is possible), and the honest move is to
report that and pin the source of variation — **not** to pick the run that suits.
### P1 — the turn is not quiet, and the load route reproduces the continuation's turn-4 call
Exactly **3** `ProcessResearch` calls, one per AI client, in the same owner order. Allocations:
**A → {tech 144, 2898 points}**, **B → {tech 90, 0 points}**, **C → {tech 9, 0 points}**. Exactly
**one completion** (tech 144) and the cascade unlocks exactly **three** nodes:
| node | `state` | `cost_rp` | `turn_available` |
|---|---|---|---|
| 132 | 0 → 2 | `INT_MAX` → **10000** | −1 → **4** |
| 136 | 0 → 2 | `INT_MAX` → **16000** | −1 → **4** |
| 142 | 0 → 2 | `INT_MAX` → **8000** | −1 → **4** |
*Falsified if:* a different allocation, a different completion count, or a different unlock set. That
would be a **route** result — the load route not reproducing the continuation — and it is worth as
much as the replace result. It is exactly what rule 26(c)'s fourth term says can happen.
### P2 — the arithmetic, hand-computed on the boundary
For node 144: `cost = 5000`, `lo = ResearchSpendFloor = 2500`, `hi = ResearchSpendCeiling = 7500`.
Entering progress **5768**, allocation **2898**:
* `spent = min(2898, 7500 − 5768) = 1732` — the cap binds, so this call lands **exactly on the
ceiling**, which is the boundary rule 23 says to test by hand rather than trust a compare on.
* `progress 5768 → 7500`, `overbudget 0 → 1166`.
* `progress (7500) < hi (7500)` is **false**, so the odds/roll branch is skipped: **zero RNG draws**.
I predict `rng.left` is **unchanged at 413** across all three calls and the `mt` digest does not
move on any of them.
* `ratio = (float)(7500/5000) = 1.5`, and `1.5 < 0.800000011920929` is false → **not** completed
early → `flag` **unchanged**.
* `state 3 → 4`, `turn_researched −1 → 4`, `order −1 → 22`, and the tree's order counter
`22 → 23`.
*Falsified if:* any single one of these moves. A wrong `hi` shows up as a wrong `overbudget`; a
wrong early-completion literal shows up as `flag` moving.
### P3 — the instrument is neutral, and the completion is counted by the instrument
Run N (`crcompare`, one detour) produces autosaves **byte-identical to C1/C2**, and `tracecmp`
reports **3 calls / 3 compared / 0 diverged / exit 0**. The hook's own per-call log line reads
`completions=1 … unlocked=3 otch_appends=1` on the completing call and all-zero on the other two.
Undeclared writes: **6 spans in 1 call** —
`player +0x10c/3`, `player +0x110/3`, `player +0x114/3`, `player +0x124/3`, `player +0x294/4`
(`ResT`, the research target being cleared) and `tree_header +0x20/1` (the order counter).
*Falsified if:* the autosaves move (then rule 19 bites and nothing below is readable), or the
undeclared-write set differs (then the workload or the model moved).
### P4 — replace with the cascade OFF (`crreplace0`, the shipped behaviour) diverges
The autosave is **not** byte-identical. Predicted diverging leaves, by name:
1. `Player[32]` tech tree: node 144 `turn_researched` and `order` unstamped (−1/−1); nodes 132, 136
and 142 unchanged at `state 0`, `cost_rp INT_MAX`, `turn_available −1`.
2. the tree's completion-order counter left at 22.
3. `Player[32]` events: `next_id` short by **2**, two event records absent.
4. `Player[32]` `otch`: one `ObservedTech` element absent (**44 bytes**).
5. `Player[32]` scalar fields at `+0x10c`, `+0x110`, `+0x114`, `+0x124` unchanged, and `ResTNm` still
reading `IND_Waldo` because `ResT` was never cleared.
What **will** be right even here: node 144's `state = 4`, `progress = 7500`, `flag`, the decay sweep
over every other node, and the `overbudget` accumulator.
### P5 — replace with the cascade ON (`crreplace1`) diverges, by strictly less
Same binary, one config line different (`research.replace_cascade=on`; see §3.1). Predicted:
**strictly fewer diverging leaves than P4**, with items 1 and 2 of P4 **closed** — node 144 stamped
`turn_researched 4` / `order 22`, nodes 132/136/142 at `state 2` with `cost_rp` 10000/16000/8000 and
`turn_available 4`, counter at 23 — and items 3, 4 and 5 **still open**.
### P6 — the headline, stated in advance
**`game/sim/research` does NOT move from `compared` to `replaced` this session, and the named cause
is `ServerPlayer::OnTechResearched`, not the research model.** The research pass itself — the spend
cap, the odds branch, the completion, the decay sweep, and (with the flag on) the whole
`SetResearched` cascade — is displaceable and I expect it to reproduce the original's TechTree state
exactly. What blocks the oracle is the *callback*: it posts events whose text comes from the game's
string table, appends an `ObservedTech` element `ours` decides but does not construct, and writes
~90 `ServerPlayer` fields that are a different subsystem's milestone (B2) and a separate `compared`
row on the board. A replace of the research pass is **gated on displacing `OnTechResearched`**, and
that is a statement about a boundary, not about the research model.
*Falsified if:* `crreplace1`'s autosave is byte-identical to the oracle. That would mean the
callback's writes do not reach the save on this workload, P6 is wrong, and the row moves to
`replaced` — which is the outcome I would rather have and do not expect.
### P7 — the numbers I expect to be able to quote honestly afterwards
**1** distinct completion, **1** distinct tech (144), **3** unlocked nodes, **3** calls of which
**2** allocate zero points and write nothing at all. That is thin, and it will be reported as
thin (rule 15/23) whatever the verdict.
### 3.1 The one engine change this lane makes, and why it is not a thumb on the scale
`sots-engine` worktree `wip/cr` (rule 21), branched from `main` at `e7e2bd6`.
* **`research.replace_cascade=on|off`, default `off`.** With it off the binary behaves *exactly* as
`main` does today, so P4 measures the shipped behaviour rather than a straw man. With it on, a
**replace**-mode call also writes the four `TechNode` words `SetResearched` stamps and the tree's
completion-order counter. It does **not** post events, does **not** move the `ObservedTech` vector
and does **not** apply a single tech effect — those stay gated on compare mode and stay declared
unmodelled. The flag exists so P4 and P5 differ by a **config line and not by a binary**, which is
what makes the leaf difference between their autosaves attributable to the cascade.
* **A per-call log line in every mode.** The old line was gated on the cascade having run, so a
replace run with the cascade off had no counter at all and "a completion happened" could only be
inferred from the save — the artefact under test. It now prints `steps`, `completions` (counted
from the pass's own step results), `overbudget` and the cascade counters, in compare **and**
replace. This is the instrument answering the brief's "show it fired — a count, from the hook
itself".
No new binary facts: `ghidra/addresses.d/cr.json` is **not** created, because this lane reads no new
address. Every offset used already exists in the generated header. Host build: 253 targets, **59/59
tests pass** (the denominator is the healthy 59, per rule 24's signature check); `clean_room_check`
OK; `tools/check_shim_configs.py` OK with 27 registered hooks and all four CR configs
(`croff`, `crcompare`, `crreplace0`, `crreplace1`) — the three `hooks=trace` ones marked
`# exhaustive` and naming all 27.
---
## 4. Results
*(added after the runs; nothing above this line is edited)*