sots-engine/docs/SD-predictions.md
alex b67362b999 SD: predictions for the ship-design composer, written before the build
The cost model for 0x006ad700 read from the instruction stream: nine live draw
sites, an exit taxonomy saying what each of the eight bail-outs has already
drawn, and a closed form for the loop-carried draw's trip count

  f  = 1.00 / 0.75 / 0.50 by request flags, hull size and one 0.3 coin
  M  = (int)(N * f)
  D' = max(1, (N + 1) / M)
  L  = #{ qualifying mounts j : PointDefence section, or j mod D' == 0 }

with the falsifiable corollary that the 0.3 coin can only move the word count
at N in {1,2,3,5}, and that N=1 with f<1 enters the loop and draws nothing.

Also predicts client 32's seven turn-1 words as two composer calls -- a
costOnly=1 price query (A+B+C) and a costOnly=0 build (A+B+C+F) -- which is
read statically off 0x006cda40's two push sites, not fitted to the number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 20:08:40 -04:00

197 lines
11 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.

# Lane SD — predictions, written before the shim build
Target: `AIComposeShipBlueprint 0x006ad700`, the AI ship-design composer. Lane PAR localised the
AI's whole RNG variance to it: on `turn1-state`, six of client 32's seven words are drawn inside it
and the seventh is drawn by a helper it calls. It holds nine live draw sites, including the only
loop-carried one.
Everything below is derived from the **instruction stream only** (`0x006ad700`–`0x006ae61a`, swept
to the next function start per rule 17; the body ends with a `ret` at `0x006ae61a` and `int3`
padding to `0x006ae620`, so Ghidra's 3864-byte size is right on this one). Nothing here has been
run yet. The instrument is `sots-engine` `wip/sd`, `aidesign=on` in `src/shim/shim.cfg.sd*`.
---
## The model
One call is `int AIComposeShipBlueprint(ECX agent, EDX parts[3], STACK req*, STACK costOnly)`.
`req = {int hullSize; float budget; int role; uint flags}`. It returns 0 on success and 1..8 for
eight bail-outs. Its **only** caller `0x006ae620` runs it once per candidate hull size — 1, 2 or 3
sizes depending on `flags & 1` and `flags & 2` — and **breaks on the first success**, so one design
request costs one to three composer calls and every failed attempt has already spent whatever it
drew before it failed.
Draw sites, in execution order, named by the return address the airng tables know them by:
| tag | ret | primitive | words | fires when |
|---|---|---|---|---|
| A | `0x006ad878` | `cl_RandFloat` → `NextFloat` | **1** | tech latch still ok **and** `(flags & 0x40) == 0` |
| B | `0x006ad94c` | `cl_RandRange(0, nH-1)` → `NextInt` | `E=(mask+1)/nH` | `parts[0].section == 0` and `nH > 0` (`nH` capped at 0x32) |
| C | `0x00691ea0` | `cl_RandRange(0, nC-1)` → `NextInt` | `E=(mask+1)/nC` | command slot empty, section not fixed, `nC > 0` |
| D | `0x006adf35` | `cl_Chance(0.5f)` | **1** | `K > 0`, where `K` = mounts with kind ∈ {0, 0xc, 0x17} and size 3 |
| E | `0x006adf44` | `cl_RandRange(0, K-1)` | `E=(mask+1)/K` | D accepted |
| — | `0x006adf62`, `0x006adf71` | | **dead** | `xor esi,esi / cmp esi,ebx / jle` with `ebx = 0` — confirmed by Ghidra removing both blocks |
| F | `0x006adfca` | `cl_Chance(0.8f)` | **1** | unconditional once the call gets this far |
| G | `0x006ae418` | `cl_Chance(0.3f)` | **1** | `(flags & 0x20000) == 0` **and** `req.hullSize > 0` |
| H | `0x006ae57a` | `cl_Chance(0.2f)` | **L** | **once per qualifying mount, see below** |
`RNG_Chance` costs exactly one word at every one of these `p` values (0.2/0.3/0.5/0.8 are all
strictly inside `(0,1)`, so neither of its zero-word early-outs is reachable here). Only B, C and E
can cost more than one word per call, and only through `NextInt`'s rejection loop.
### The loop-carried draw's trip count
The tail is `for part in 0..2 { for mount in 0..part.mountCount }`. The **outer count is a literal
3** and never varies. The inner draw fires for a mount iff:
* `mount.kind == 0 && mount.size == 1` (call these the **qualifying** mounts, `N` of them across all
three sections), **and**
* the section's name is `DEPointDefence` or `CRPointDefence` (case-insensitive) — those take every
qualifying mount — **or** the mount's **global** qualifying index `j` satisfies `j mod D' == 0`,
**and**
* the restricted weapon lookup at `0x006ae3c1` returned non-null (else the site short-circuits with
no call), and the default weapon lookup at `0x006ae3dc` returned non-null (else the whole tail
block is skipped), and `M > 0`.
with
```
f = 1.00 if (flags & 0x20000) == 0 and req.hullSize > 0 and site G accepted
= 0.75 if (flags & 0x20000) != 0, or G was drawn and refused
= 0.50 if (flags & 0x20000) == 0 and req.hullSize <= 0
M = (int)(N * f) float32 multiply, floor, truncate — non-negative, so one cast
D' = max(1, (N + 1) / M) integer division
L = #{ j in [0,N) : section(j) is *PointDefence, or j mod D' == 0 }
```
Consequences that make this falsifiable rather than decorative:
* `f = 1.00` ⇒ `D' = 1` ⇒ **`L = N`**.
* `f = 0.75` ⇒ `L = 0, 1, 2, 4, 3, 6, 7, 8` for `N = 1..8` — note it is **not** monotone: `N = 5`
costs 3 and `N = 4` costs 4, because `D'` jumps.
* `f = 0.50` ⇒ `L = 0, 1, 1, 2, 2, 3, 4, 4` for `N = 1..8`.
* `N = 1` with `f < 1` gives `M = 0` and **zero** draws — a whole loop that is entered and costs
nothing (method rule 20's shape, and the reason the probe logs `N` next to `H_obs`).
* The 0.3 coin at G therefore changes the word count **only when `N = 1`, `N = 2`, `N = 3` or
`N = 5`**. For `N = 4` and `N ≥ 6` both `f = 0.75` and `f = 1.00` give `D' = 1` and the same `L`.
### Exit taxonomy — what a failed attempt has already paid
| rc | where | drawn before it returns |
|---|---|---|
| 5 / 6 / 7 | a forced-tech lookup for flag bit 4 / 8 / 0x10 | **nothing** |
| 2 | no hull-section candidate | A |
| 3 | no command section | A + B |
| 4 | no engine section | A + B + C |
| 1 | over budget | A + B + C |
| — | `costOnly != 0` returns here | A + B + C |
| 8 | no weapon matched a mount | A + B + C + D + E + F |
| 0 | success | everything |
---
## Predictions
### P1 — the model holds, per call
On **every** composer row the probe emits, `H_pred == H_obs`, where `H_pred` is computed by the
probe from `N`, the three per-section point-defence flags and `f` (with `f` taken from the request
flags, the hull size, and G's **observed accept/refuse**, which the extended `cl_Chance` detour now
records).
*Falsified by:* any row printing `model=WRONG`. A row printing `model=GATED-OR-WRONG` (`H_pred > 0`
but `H_obs == 0`) is **not** a falsification on its own — it is the weapon-lookup gate — but it is
also not a confirmation, and if every row is `GATED-OR-WRONG` the model is untested and I will say
so rather than claim it held.
### P2 — one design request, two composer calls, and the first is a price query
`0x006cda40` calls the driver twice: once with `costOnly = 1` (statically read at `0x006cda9a`,
`push 0x1`) and, if that succeeded, once with `costOnly = 0` (`0x006cdb17`, `push 0x0`). So a turn
in which the AI designs one ship should show **two** composer rows: one with `dry=1` that returns
`rc=0` having drawn **A, B, C and nothing else**, and one with `dry=0` that goes further.
This is the arithmetic of client 32's seven turn-1 words, and it is the prediction I most want
checked because it was derived to explain a number rather than measured:
```
call 1 (dry=1): A 1 + B 1 + C 1 = 3 words
call 2 (dry=0): A 1 + B 1 + C 1 + F 1 = 4 words
total 7 <- PAR's measured 7
```
with `D`, `E`, `G`, `H` all zero: `K = 0` (no size-3 mounts on a turn-1 hull), and G silent because
`req.hullSize <= 0` on the smallest hull.
*Falsified by:* one composer row, or three; or `dry = 0` on both; or `F` firing on the dry call.
### P3 — pinned seeds reproduce exactly
Two runs of `turn1-state → turn2` with `airng.pin_seed=5A17C0DE` and `aidesign=on`, in two fresh
processes, produce **byte-identical `aidesign*` rows** — every field, every row, in order.
*Falsified by:* any differing field. That would mean the composer's path depends on something the
bracket-entry re-seed does not pin, which would be a bigger finding than the model.
### P4 — a different pinned seed moves the count, and moves it through the path
A run with `airng.pin_seed=B16B00B5` differs from P3's runs in at least one of:
* site A's coin (`RandFloat() >= 0.5`) landing the other way, which sets request-flag bit `0x40`,
forces a tech section and therefore **changes the sections, `N`, and possibly `H`**;
* the number of words `B` or `C` spends, `NextInt`'s rejection loop resolving differently.
The total for client 32 is **not** required to differ — PAR's §3.4 already showed a pinned run
holding the count at 3 while replacing the whole stream. What is required is that if the *path*
changes, `H_pred` tracks it: `model=HOLDS` must survive the seed change.
*Falsified by:* `model=WRONG` appearing only under the second seed, which would mean the model is
fitted to one design rather than derived.
### P5 — the instrument is armed, and says so
`shim.airng.txt` must contain
`aidesign: composer 0x006ad700 rva=0x002ad700 va=... create=MH_OK enable=MH_OK`.
Lane L3 found a configuration that printed `watch=on` and armed nothing; a missing or failed hook
here would produce **no `aidesign` rows at all**, which is indistinguishable from "the composer was
never called" unless the arming line is read. If that line is absent or not `MH_OK`, every zero in
this lane's output is void and the run is discarded, not interpreted.
Second arming check, independent of the log line: the `airngcall`/`airngsite` totals for the
bracket must equal the sum of the `aidesign` rows' `sub_words`. If the composer hook silently failed
the sub-bracket would be zero while the turn total stayed 7.
### P6 — rule 19: the composer detour does not change the game
With `aidesign=off` (and `airng=on`, unpinned), two fresh processes on `turn1-state` must both
produce the published post-turn autosave `d59bb9f2fd0eb535`. With `aidesign=on`, unpinned, two more
fresh processes must produce the same file.
The composer detour patches five bytes at a clean prologue boundary (`push ebp; mov ebp,esp;
push -1` is exactly 5 bytes, and the function has one caller and no internal branch target below
`0x006ad705`), but lane H's finding is that a *correctly placed* patch changed the autosave anyway
and the mechanism is still unknown. So this is measured, not argued.
*Falsified by:* any of the four runs producing a different file. If the `aidesign=off` pair
disagrees **with each other**, the workload is the problem and no control exists (rule 26); I will
say so and fall back to pinned comparisons only.
---
## How this could be wrong
1. **`N` is read after the call, from the design the call produced.** The composer counts `N` during
its weapon-assignment pass over slots that *received* a weapon; if a slot was skipped, my
post-hoc count is high and `H_pred` is too big. Symptom: `H_pred > H_obs` by a small amount on
rows with `rc = 8`. This is why the rc is in the row.
2. **`part.mountCount` is `min(hull mounts, 0x32)`.** I clamp to the hull's own vector length as
well; if the two disagree the probe prints both and I will notice.
3. **`f` for the `flags & 0x20000` case is an inference from one `fld` at `0x006ae3f8`.** If bit
`0x20000` is never set on this board the term is untested, and I will label it rule-6 rather than
claim it.
4. **The dead-code claim on `0x006adf62`/`0x006adf71`** is static. Ghidra independently removed both
blocks as unreachable, which is a second reading of the same instructions and not a second
instrument. If either ever appears in an `airngcall` row, the claim is wrong.
5. **The measurement could double-count.** PAR's instrument reported one word twice because `Chance`
calls `NextFloat` and both were detoured; it caught that only because it took two independent
measurements. This lane's sub-bracket takes two as well — `left` read off the generator object
(`words`) and the observer sum (`sub_words`) — and prints both on every row. They must agree.