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

11 KiB
Raw Blame History

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.