From 4065808a0508d6a5d1fc33cabed79a7447444870 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 15:23:16 -0400 Subject: [PATCH] docs: EV prediction committed before the build -- P11 posts one event, closed 4/3, regressed 0 --- docs/EV-events.md | 128 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/EV-events.md diff --git a/docs/EV-events.md b/docs/EV-events.md new file mode 100644 index 0000000..29a3aa5 --- /dev/null +++ b/docs/EV-events.md @@ -0,0 +1,128 @@ +# EV — posting events into the save's turn bucket (P11) + +Roadmap item 5. The engine already **models** the event log (`game/events`: turn-bucketed +storage, per-bucket dedup, the `FLT_MAX` position sentinel, the `act == 0 && no subject && no +position -> 2` rule). What was missing is that the standalone never wrote any of it into the +save it produces. This lane wires that up for the one event a turn on the reference pair +actually posts that the standalone can compute, and reports the rest as blocked with the +blocker named. + +Nothing here carries the game's displayed text. The engine holds `EVENTSUM_*` / `EVENTMSG_*` +keys only; the text is resolved at run time through a lookup the caller supplies, which the +standalone builds from the operator's own installed string table (`--data`). + +--- + +## 1. What a turn actually posts (measured, whole corpus) + +Not derived — read out of the eleven saves in `verify/results/saves` with the state-checksum +tree. Only two players in any corpus save ever hold an event at all: the human (`PlyrIdx 0`) +and the one AI empire that owns colonies (`PlyrIdx 1`). Every other player's `EvNxID` is 0 on +every save, including the two AI shadow empires that *do* pick research targets. + +**Reference pair `turn1-state.sav -> turn2-state.sav` — the whole turn posts two events:** + +| player | bucket | id | image | act | loc | +|---|---|---|---|---|---| +| 0 (human) | `EvTurn=2` | 1 | `EVENT_NO_RESEARCH` | 1 | 0 | +| 1 (AI) | `EvTurn=2` | 1 | `EVENT_SHIPS_BUILT` | 0 | 288 | + +**Pair 2 `turn2-state.sav -> turn3-state.sav` — three:** + +| player | bucket | id | image | act | loc | +|---|---|---|---|---|---| +| 0 | `EvTurn=3` | 2 | `EVENT_NO_RESEARCH` | 1 | 0 | +| 1 | `EvTurn=3` | 2 | `EVENT_SHIPS_BUILT` | 0 | 288 | +| 1 | `EvTurn=3` | 3 | `EVENT_RESEARCH_OVERBUDGET` | 1 | 0 | + +**Order matters and is visible in the ids**: within player 1's turn-3 bucket the construction +event is id 2 and the research event is id 3, so the build pass posts **before** the research +pass. Ids are per player (`EvNxID` lives on the player's own storage), so no cross-player +order is observable and none is assumed. + +The bucket's `EvTurn` is the **post-increment** turn: a turn run from a save at turn *N* +posts into bucket *N+1*. In the standalone that is `game.sim.frame` after `H00`. + +Across the whole corpus the only other images seen are `EVENT_SHIPS_BUILT`, +`EVENT_RESEARCH_COMPLETE`, `EVENT_TECHS_UNLOCKED`, `EVENT_RESEARCH_OVERBUDGET`, +`EVENT_FLEET_ARRIVED`, `EVENT_FLEET_EXPLORED`, `EVENT_FLEET_MULTIPOINT_NONODE`, +`EVENT_LABACCIDENT_SMALL`, `EVENT_COLONY_NEWSETTLERS`, `EVENT_ENEMY_INCOMING_Human` and +`EVENT_NO_RESEARCH`. None of the tail's event phases (`T18`, `T21`, `T32`) fires on either +reference pair — measured, not assumed; on other corpus saves they clearly do. + +## 2. The no-research condition, corrected + +The standalone's condition was `no research target && at least one available tech`. That is +two thirds of the original's and it **over-fires by three players** on the reference pair. +The original's is three tests, all of which now have a name in `ghidra/addresses.json`: + +1. `ResT == NULL` — no research target; +2. `TechTree::CollectResearchedTechs(out, turn, INT_MAX, sort=1)` came back **empty**, i.e. + **no tech finished on this turn or later**; +3. `TechTree::FindFirstAvailableTech() != NULL`, i.e. at least one node is in state 2. + +Test 2 was missing here and it is not decoration. `zuul-turn23-fleet23.sav` exercises it: the +human posts `EVENT_NO_RESEARCH` on turns 2..15, posts `EVENT_RESEARCH_COMPLETE` on turn 22 +and **no** no-research event that turn, then posts `EVENT_NO_RESEARCH` again on turn 23. That +is exactly test 2 suppressing the event on the turn a tech landed. + +Tests 2 and 3 are computable from the wire: a `TechState` row's `St` is the node state +(0 hidden, 1 parent researched, 2 available, 3 available and selected, 4 researched) and +`TAcq` is the turn it was researched. + +Test 1 is **not** fully computable. `ResTNm` gives the target the save was written with, but +three of the four real players on the reference pair acquire a target *during* the turn, and +that is AI research selection (`game/ai`). The stand-in is the operator-supplied AI roster, +`--ai-player N`, which the standalone already carries for `T31`: + +> **HYPOTHESIS (workload: the whole 11-save corpus).** An AI-controlled player that begins its +> turn with no research target acquires one before the no-research check, so it never posts +> `EVENT_NO_RESEARCH`. Support: across the corpus no AI player posts that event on any of +> roughly ninety player-turns, while the human posts it on every turn it has no target. +> Falsified by a single AI player-turn carrying the event. + +So `P11` stays **blocked**, commits only under `--commit-blocked=P11`, and even then posts +nothing for a player the operator named as AI and nothing at all without a text table. + +## 3. Prediction (written before the build; rule 2) + +Baseline moved under this lane: `main` is now `eafbc5f` (lane T2). Re-measured with this +lane's own binary before any change: pair 1 **209 -> 131** (closed 78, regressed 0), pair 2 +**108 -> 72** (closed 36, regressed 0). The brief's 209 -> 157 / 108 -> 86 predate T2. The +numbers below are against the re-measured baseline, so the lane's own contribution is +pair 1 **131 -> 127** and pair 2 **72 -> 69**. + +**Run A — `--data --ai-player 1 --ai-player 2 --ai-player 3 --commit-blocked=P11`.** + +`P11` posts exactly one event, for player 0, into bucket `EvTurn = frame`: +`EvEID` 1 (pair 1) / 2 (pair 2), `EvDsc` and `EvMsg` from `EVENTSUM_NO_RESEARCH` / +`EVENTMSG_NO_RESEARCH`, `EvImg = "EVENT_NO_RESEARCH"`, `EvLoc = 0`, +`EvPos = {FLT_MAX, FLT_MAX, FLT_MAX}`, `EvAct = 1`, `EvCID = 0`; `EvNxID` 0 -> 2 (pair 1), +2 -> 3 (pair 2). + +* pair 1: **closed 4, regressed 0** — `Events/EvNxID`, the empty-collection leaf + `Events/Events/.`, the new count leaf `Events/Events/.[0]`, the new bucket + `Events/Events/.[EvTurn=2]`. +* pair 2: **closed 3, regressed 0** — `Events/EvNxID`, `Events/Events/.[0]` (1 -> 2), the new + bucket `Events/Events/.[EvTurn=3]`. + +Player 1's four (pair 1) / three (pair 2) event leaves stay: they need `EVENT_SHIPS_BUILT`, +which needs a ship the turn builds, which needs an AI-generated build order — the same +blocker `B6` and `T36` already name. + +**Run B — the control, no `--data`, no `--ai-player`, no `--commit-blocked`.** closed 0, +regressed 0; the phase reports the condition and posts nothing. + +### Falsification + +| # | if the model is wrong this way | symptom | +|---|---|---| +| F1 | the AI-picks-a-target hypothesis is wrong | with the roster supplied the event is missing for a player the oracle has one for; without it, four posts and roughly fifteen regressed leaves on pair 1 | +| F2 | the bucket turn is the input turn, not the incremented frame | the bucket lands at `EvTurn=1`; closed 1, regressed 2 | +| F3 | the position sentinel is written as `+inf` rather than `FLT_MAX` | bucket appears, `EvPos/.[0..2]` diverge; closed 3, regressed 3 | +| F4 | the `act == 0 -> 2` rule is applied where it should not be (`act` here is 1) | `EvAct` diverges; regressed 1 | +| F5 | the writer emits a trailing node on a record the engine synthesised rather than parsed | the post-turn save's coverage drops below the input's, visible in the report's `coverage` line | +| F6 | the "researched this turn" gate is wrong | no visible effect on either reference pair (nothing is researched on either turn); it would show as a spurious post on the turn a corpus save completes a tech | + +Run B is the control for F1: it is the same code with the input withheld, and its over-fire +count is a direct measurement of how much of the phase the AI roster is carrying.