sots-engine/docs/EV-events.md
alex e30d43dd1b P11: post the no-research event into the save's turn bucket
The engine has modelled the event log since lane E, but the standalone never wrote
any of it into the save it produces. This wires the two together for the one event
on the reference pair the standalone can compute, and corrects the condition.

What a turn actually posts, measured over all eleven saves: two events on
turn1 -> turn2 and three on turn2 -> turn3, and only two players in the whole
corpus ever hold an event at all. Order is readable off the ids -- the build pass
posts before the research pass. See docs/EV-events.md section 1.

The condition had two of the original's three tests. The missing one is "no tech
finished on this turn or later", and it is what keeps the event off the turn a tech
lands; zuul-turn23 exercises it. The third input, whether the player holds a target
at the moment of the check, is not on the wire -- three of the four real players
acquire one during the turn, which is AI research selection -- so the operator's
--ai-player roster stands in for it as a stated hypothesis and the phase stays
blocked without it.

No prose in the engine: the record's text is resolved through a caller-supplied
lookup over the operator's own installed string table, and a run without a data
root posts nothing rather than writing a record it cannot fill.

Measured (CT111 host build, real data root), closed and regressed never netted:
  turn1 -> turn2  209 -> 127  closed 82 (+4 this lane), regressed 0
  turn2 -> turn3  108 ->  69  closed 39 (+3 this lane), regressed 0
The posted record agrees with the oracle on all eight of its fields. Controls: with
the roster withheld the phase over-fires and regresses 16 leaves on pair 1; with the
string table withheld it posts nothing and regresses none.

Gates run separately: tools/clean_room_check.sh OK; host ctest 50/50; CT111 shim
cross-build OK (exports 66 names identical to binkw32.dll, staged in
/srv/re-lab/shim/dist-ev); CT111 host ctest 50/50.
2026-09-08 15:39:42 -04:00

174 lines
11 KiB
Markdown

# 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 <root> --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.
---
## 4. Measured
`tools/standalone_report.py --binary <this build> --engine-arg=...`, all four runs against
the same binary. `closed` and `regressed` are never netted.
| run | pair 1 (209 baseline) | closed | regressed | pair 2 (108 baseline) | closed | regressed |
|---|---:|---:|---:|---:|---:|---:|
| before this lane | 131 | 78 | 0 | 72 | 36 | 0 |
| **A: `--data` + roster + `--commit-blocked=P11`** | **127** | **82** | **0** | **69** | **39** | **0** |
| B: nothing supplied (default) | 131 | 78 | 0 | 72 | 36 | 0 |
| F1 control: `--data`, **no roster** | 139 | 86 | **16** | 69 | 39 | 0 |
| F2 control: roster, **no `--data`** | 131 | 78 | 0 | 72 | 36 | 0 |
**The lane closes 4 leaves on pair 1 and 3 on pair 2, regressed 0.** They are exactly the
four and three paths the prediction named, and no other leaf moved. The prediction held.
Two things the run said that the prediction did not:
* **The oracle's record matches ours field for field.** The bucket exists in both saves after
run A, so the checksum descends into it, and nothing under it is reported — `EvEID`,
`EvDsc`, `EvMsg`, `EvImg`, `EvLoc`, all three `EvPos` words, `EvAct` and `EvCID` all agree.
The text came out of the operator's own string table through the key lookup, so F3 and F4
are not merely untriggered, they are actively disconfirmed.
* **F1's regression is 16, not the ~15 estimated, and it lands on pair 1 only.** Pair 2's
input save already carries a research target for all three AI players, so `ResTNm` alone
suppresses them there and the roster buys nothing. The AI roster is load-bearing exactly on
the turn after which the AI *first* picks a target — which is the reference pair, and which
is why the phase cannot be committed without it.
## 5. What is still not posted, and what blocks it
| event | player | pairs | blocker |
|---|---|---|---|
| `EVENT_SHIPS_BUILT` | 1 | both | a ship the turn builds. Both reference pairs carry **no build order anywhere** — three empty system queues, no ship-borne queue, and the only command block is the human's empty one — so the order behind that ship is generated by the AI during the turn. Same blocker `B6` and `T36` already name: **AI order generation**, not this phase. |
| `EVENT_RESEARCH_OVERBUDGET` | 1 | pair 2 | the research allocation, which needs `ComputeBudget`'s per-system money on the turn path (roadmap item 1). `game/events` already holds the posting rule and `game/sim` the over-budget decision; only the points are missing. |
| tail event phases `T18` / `T21` / `T32` | — | neither | **measured negative**: no `EVENT_ENEMY_INCOMING_*`, `EVENT_FLEET_EXPLORED` or `EVENT_FLEET_ARRIVED` appears in either oracle save, so these phases post nothing on the reference pair. Other corpus saves do carry all three, so they are live phases with an unexercised workload here, not dead ones. |
Two tail functions that sound as though they should be in this table are not, and lane K's
reading is why: the one named for generating a turn's events is 122 bytes whose copy loop is
provably dead, and the one named for building them is gated on a descriptor set only on the
load/rejoin path and references no event identifier at all. **No tail phase manufactures the
turn's events.** Every event a turn posts is posted by the subsystem that caused it — the
research pass, the build pass, movement, combat — which is what section 1 measures.