docs(research): synthesis of game, state and primitives; next iteration

Join the three evidence sources that were kept apart, and extract the useful
result: each source independently decides what belongs in code and what belongs
to the model, and every serious bug we have found broke one of those rules.

  * Game mechanism  -> arithmetic and rules belong in CODE
  * Internal state  -> anything directly readable or writable belongs in CODE
  * TypeSafe        -> judgements go to the MODEL, in the primitive matching the
                       answer's shape

  * never blocked in boss fights        -> game mechanism (arithmetic left to the model)
  * card quality asked as a Noul        -> primitives (a spectrum forced into yes/no)
  * indices read from the wrong array   -> internal state

The counter-example is kept too: the Score we tried for fight-level planning was
unusable, so the rule is "match the primitive to the shape, and verify" rather
than "use Score more".

Also records the ordered plan (measurement first, then re-run, then primitives,
then deck composition), the continual-learning design, and why it is blocked:
with 0 wins in 37 runs the reward has no gradient, so training would fit the bug
rather than the game.
This commit is contained in:
0xrsydn 2026-09-22 06:06:21 +07:00
commit 65fe1171cd
2 changed files with 357 additions and 3 deletions

View file

@ -0,0 +1,335 @@
# 10 — Synthesis: game, state and primitives → next iteration
Status: **forward-looking**. This doc is the join of three evidence sources that
until now were kept apart:
| Source | Doc | What it gives us |
|---|---|---|
| STS2 game mechanism | `01`, `08` | What actually decides a run |
| Internal game state we control | `04`, `08` | What is observable, and what we can change |
| TypeSafe / System One primitives | `02`, `09` | What shape to ask the model |
It records the design conclusions and the planned iteration. Nothing here is a
measurement unless it says so; the measurements live in `05`, `07`, `08`, `09`.
---
## 1. The synthesis in one line
**Each source independently decides what belongs in code and what belongs to the
model — and every serious bug we have found is a violation of one of those three
rules.**
| Source | Rule it imposes |
|---|---|
| Game mechanism | Anything that is arithmetic or a rule belongs in **code**. HP, block, lethal, damage projection, energy. |
| Internal state | Anything we can read or write directly belongs in **code**. Index spaces, save files, prefs, action enumerations. |
| TypeSafe primitives | Anything that is a *judgement over unstructured meaning* goes to the **model** — and must use the primitive that matches the answer's shape. |
### The three bugs, mapped to the three rules
| Bug | Rule broken | Doc |
|---|---|---|
| Bot never blocked in boss fights; 9 runs lost to `THE_KIN_BOSS` | **Game mechanism.** Blocking is arithmetic (`projected_incoming > affordable_loss`) and we left it to a damage-biased model question. | `08` |
| Card quality asked as a `Noul` and `argmax`'d | **Primitives.** A spectrum judgment forced into yes/no. `primitives.md`: "A Noul value of 0.5 means the model gives yes and no equal probability. It does not mean the candidate has a medium skill level." | `09`, §2 |
| Card indices read from the wrong array (5 spaces, similar names) | **Internal state.** We misread what the index pointed at. | `04` |
That mapping is the useful part. It turns three unrelated bug hunts into one
checkable rule, and it predicts where the next bug is: **any place we ask the
model something that is really arithmetic, or use the wrong primitive for the
answer shape.**
### The counter-example that keeps us honest
`09` §3 records a `Score` we tried for fight-level planning ("Race / Trade /
Control") that was **unusable on 4 of 6 states**, confidence as low as 0.01.
So the rule is not "use Score more". It is "match the primitive to the answer
shape, and verify the shape works before adopting it."
---
## 2. What the game actually rewards (from `08`)
Established by mining 37 run files, not by reading guides:
- **81% of runs (30/37) die in Act 1.** Deck composition is downstream of that.
- **43% die to an Act 1 boss; `THE_KIN_BOSS` alone kills 9.**
- The bot is **not** bloating, descriptively: 23% of rewards were skipped (249 of
323 rewards took a card), one card per reward at most. The per-card rate
(249/1087 = 23%) is mechanically diluted by offer size and does not diagnose
skip policy; we have no STS2 benchmark for a good skip rate.
- The Kin fights lasted **510 turns** and cost **4480 HP**, ~1013 a turn,
with block cards in hand. That is a defense failure, and it is now fixed.
**Consequence for planning:** any work on archetypes, synergy scoring or deck
"lean weight" is tuning a system the bot does not survive long enough to use.
The order is: survive Act 1 → then optimise the deck.
---
## 3. What the state gives us that we are not yet using
Three levers found but not yet exploited:
### 3.1 The run files are a labelled dataset
`history/*.run` records, per map point: every card offered with `was_picked`,
`damage_taken`, `potion_used`, `rest_site_choices`, `upgraded_cards`, and the
final deck. That is a supervised dataset of our own drafting and combat, and it
already invalidated one hypothesis (bloat) and found one bug (defense).
**Not yet used for:** labelling decisions, or measuring the gates.
### 3.2 Instant Mode is a one-line lever
`prefs.save` holds `"fast_mode"` as plain JSON, and the mod's Instant Mode
checkbox is only `PrefsSave.FastMode = FastModeType.Instant`. Setting it to
`"instant"` removes animations and should roughly halve wall-clock per run.
**Needs a game restart** — prefs load at startup.
This matters because the binding cost of every experiment is wall-clock. At
~10 minutes per run, an A/B of 6v6 is two hours.
### 3.3 Model answers are now logged with the decision (not in a side trace)
**Corrected 2026-09-22.** This section previously said
`JEV_TRACE=capture/jev_trace.jsonl` was "the entire change". It is not, and it
was never sufficient on its own: `jev.py`'s trace records a `HH:MM:SS` stamp,
the questions and the answers — **no run, session, step, state or action**. Two
calls in the same second are indistinguishable, so the file cannot be joined to
a decision or to an outcome, which is the only thing a label needs.
What the decision log carries now, per decided action
(`run.py:decision_record`):
```json
{"session":"20260922T034419","step":55,"state_type":"monster",
"run":{"act":1,"floor":5},"action":"play_card","source":"jev",
"params":{"card_index":1,"target":"SHRINKER_BEETLE_0"},
"confidence":0.85,"reason":"jev chose Dismantle","error":null,
"jev":{"model":"jev-1.13.0","latency_s":0.71,
"questions":{...},"answers":{
"best_play":{"kind":"choice","choice":"card1","margin":0.79,"gated":true,
"probabilities":{"card0":0.01,"card1":0.89,...}},
"use_potion":{"kind":"noul","noul":0.41,"yes":false},
"which_potion":{"kind":"choice","choice":"potion0","margin":1.0,"gated":true}}}}
```
- `session` is a unique id per process (`<timestamp>-<6 hex>`). It is the join
key only because three places now carry it:
1. every decision row in `capture/decisions.jsonl`;
2. `capture/sessions.jsonl`, written by `run.py` at exit (via `atexit`, so it
covers every exit path) and mapping the session to the history run file(s)
it produced, with `win`, `killed_by`, `seed`, `deck_size`, `map_points`;
3. the A/B attribution row — `policy \t run \t code-hash \t session` — and the
report prints each arm's session ids.
The game's own run file does **not** know our session id and the harness
originally mapped only a policy to a filename, so without (2) and (3) nothing
lined up and there were still no labels.
- `jev` is `null` when the decision asked nothing (code paths, fallbacks), which
is a fact about the decision rather than a gap.
- `RecordingClient` in `run.py` captures the answers, so nothing in `brain.py`
had to change.
`JEV_TRACE` still works and is still worth setting, but it is now redundant for
labelling. It is no longer the prerequisite.
---
## 4. Where our Jev usage stands (from `09`, plus this audit)
Measured usage of the documented surface:
| Capability | Used |
|---|---|
| `Choice` | **7** questions — `best_play`, `which_potion`, `target`, `next_node`, `best_option`, `best_relic`, `best_bundle` |
| `Noul` | **6** question families — `use_potion`, `want_any`, `skip_all`, `good_card{i}`, `good_relic{i}`, `good_card{i}` (card_select), `worth_item{i}` |
| `Score` | **0** — though `score()` is defined at `jev.py:169` |
| Structured criteria (`entry()`) | **0** |
| Contrastive Noul criteria (`true=`/`false=`) | **0** |
| `focus=` / `inspect=` / `compare=` | **0** |
| `gate_choice()` / `margin()` | **0** in `brain.py` (`gate()` is used; `margin()` only inside `jev.py`) |
Every question is a bare one-line string. The docs permit that only when the
question is *short and unambiguous*; ours are neither, e.g.:
```python
noul("Is any relic in `relics` worth taking over skipping?")
```
`09` §2 measured structured criteria on card play: **same card chosen 6/6**,
margin 0.425 → 0.473. A small effect — worth adopting where disambiguation
costs us, not as a blanket rewrite. That measurement stands.
---
## 5. The next iteration, in dependency order
Ordered by dependency, not by appeal. Each step states what it unblocks.
### Step 1 — Enable measurement (blocks everything else)
**Partly landed 2026-09-22.** The answers and the join are in; the corpus is not.
Done:
- every decision row carries the model's questions and answers, or `null` when
nothing was asked (`run.py:decision_record`, `RecordingClient`);
- a unique `session` id on every row, and `capture/sessions.jsonl` at exit
mapping it to the run file(s) produced, with the outcome fields;
- `ab_card_skip.sh` stamps the same session into its attribution rows.
Still to do:
- capture **all** state types, not just combat (`run.py` currently dumps
`live_{step}_combat.json` only);
- namespace captures per run so they stop overwriting across sessions;
- log the *features* the decisions turn on (the `features` block below is a
sketch, not what is written today — the questions and answers are the
evidence currently recorded).
```json
{"run_id":"1790...","step":42,"state_type":"card_reward",
"features":{"card0_power":2.1,"card0_synergy":1.4},
"action":"select_card_reward:0","source":"jev","confidence":0.71,
"gate":"pass"}
```
**Why first:** it is the prerequisite for every quality question in `09` and for
continual learning (§6). With the join in place, the first usable dataset is one
A/B run away; what is missing now is coverage (all state types, namespaced per
run) and volume, not mechanism.
### Step 2 — Re-run and check whether the ceiling moved
The defense fix (`08`, failure #35) is the single highest-leverage change made
so far. Verify it with the A/B harness, now that `--stop-on-run-end` makes one
session equal one run:
```bash
./ab_card_skip.sh 6 # 6 runs per arm, one run per session
```
**The question:** does `THE_KIN_BOSS` stop killing 9 of 37? If yes, runs start
reaching Act 2+ and the reward signal finally varies. If no, the defense fix is
wrong and nothing downstream is worth building.
**Why second:** it is the test of the only substantive finding, and it gates §6.
### Step 3 — Match primitives to answer shapes
Only after Steps 12 give labels:
- Card quality: **`Score` with explicit ordered levels**, per card, several
atomic axes, combined with weights in code (composite scoring). Replaces the
`Noul` + `argmax` that the docs say is the wrong shape.
- Structured criteria on the fuzzy Nouls (`worth taking`, `stronger`).
- Measure each change against the Step 1 trace before keeping it.
### Step 4 — Then deck composition
Archetypes, synergy, removal priority. Last, because `08` shows the bot does not
currently reach the point where it matters.
---
## 6. Continual learning — the design, and the honest blocker
The docs give the mechanism directly:
> "Combine independent answers with deterministic rules or weighted sums. **For
> learned composition, use the probabilities as features in a downstream
> classical machine-learning model.**"
So continual learning here is **not fine-tuning Jev**. It is: Jev produces
features, code combines them, and **the weights are learned from outcomes**.
Four stages:
1. **Instrument** — Step 1 above.
2. **Label** — attach the run outcome to each decision row.
3. **Fit** — a small logistic regression or gradient-boosted tree on
`features → P(reach Act 2)`. The coefficients *are* the composite weights.
4. **Close the loop** — retrain periodically and A/B the new weights against the
old, using the `--stop-on-run-end` harness.
The docs also prescribe how to set our gates, which we have so far guessed:
> "Test thresholds by plotting confidence against accuracy on your data."
`EVENT_TOP_MIN = 0.60`, `EVENT_MARGIN_MIN = 0.30` and `CONFIDENCE_FLOOR = 0.55`
were chosen by hand. With a trace they can be **measured**. (Note that
`CONFIDENCE_FLOOR` is inert where it is passed: `gate()` ignores its threshold
argument for a `ChoiceAnswer`, so the target question is gated by the margin
rule, not by 0.55.)
### The blocker, stated plainly
**We have 0 wins in 37 runs.** The top of the reward scale is unobserved, so
there is nothing to learn from yet. Our only varying signal is map points
reached — noisy, and confounded by seed luck.
Training on this data would fit the bug, not the game. **Step 2 is what creates
the gradient.** If runs start reaching Act 2 and Act 3, a label appears
(`P(reach Act 2)`) that actually varies, and Stage 3 becomes possible.
---
## 7. Refactor implications
`brain.py` is ~1700 lines with 13+ handlers, and it now mixes four concerns:
state parsing, arithmetic, question building, and policy. The three-rule framing
in §1 suggests the split that the code keeps trying to make:
```
sts2bot/
game/ card and encounter knowledge, mechanics constants
state/ observation parsing, the five index spaces, CombatFacts
policy/ one module per state_type; chooses among enumerated actions
model/ TypeSafe primitives, a shared question library, the trace
learn/ decision log, labels, weight fitting
eval/ A/B harness, metrics, run-file mining
```
Two rules that should hold after the split:
1. **`policy/` may not do arithmetic.** If a decision needs a number, the number
comes from `state/`. This is the rule the defense bug broke.
2. **`model/` may not be asked a question whose answer is arithmetic.** This is
the rule the `Noul`-for-spectrum bug broke.
Both are testable as structural assertions, in the same style as the existing
`test_brain.py` hostile-index tests (indices from data, `OFFSET = 5`).
**Do not refactor yet.** The measurement in Step 1 should land first: it is
cheap, it is a prerequisite for everything, and moving files while the corpus
format is still changing would mean doing it twice.
---
## 8. Open questions carried forward
| Question | Blocked on |
|---|---|
| Do the gates (0.45 / 0.60 / 0.30) match their accuracy? | **per-decision labels** — replay, expert judgement, or a code-verifiable ground truth. Run outcomes cannot answer this: one run result attached to a step does not say whether that step's answer was correct. |
| Is structured criteria worth adopting beyond card play? | trace, then A/B |
| Does the two-stage cascade beat one broad question? | an optimal-action label |
| Does map beam search beat the single-step Choice? | `map_point_history` labels |
| Is `TREMBLE` (offered 45×, taken 0×) correctly rejected, or is that a bug? | a card-quality label |
| Does the event keyword veto reject benign options? | event captures + outcomes |
| Is the escalation tier (act / review / escalate) needed? | trace |
---
## 9. Summary
- **Three rules**, one per evidence source, and every serious bug broke one.
- **One fix landed** (defense) with the highest measured leverage: 9 runs, one
boss, one cause.
- **One blocker**: 0 wins means no reward gradient, so continual learning is
designed but cannot start.
- **One prerequisite**: enable `JEV_TRACE` and log decisions. Cheap, and it
unblocks every quality question above.
- **One test that matters next**: re-run and see whether `THE_KIN_BOSS` deaths
drop. That single number decides whether the rest of this plan is worth
building.

View file

@ -18,6 +18,9 @@ guess, it says so.
| 05 | [Failure modes](05-failure-modes.md) | Every infinite loop found live, with its fix | | 05 | [Failure modes](05-failure-modes.md) | Every infinite loop found live, with its fix |
| 06 | [Decision architecture](06-decision-architecture.md) | The three-layer design and why the split is where it is | | 06 | [Decision architecture](06-decision-architecture.md) | The three-layer design and why the split is where it is |
| 07 | [Run log](07-run-log.md) | Results per run, with seeds and outcomes | | 07 | [Run log](07-run-log.md) | Results per run, with seeds and outcomes |
| 08 | [What actually wins](08-what-actually-wins.md) | Why the web meta is unusable, and the measured cause of 9 run losses |
| 09 | [TypeSafe best practice](09-typesafe-best-practice.md) | Doc-by-doc gap analysis: what we match, what measurement killed, what is missing |
| 10 | [Synthesis and next iteration](10-synthesis-and-next-iteration.md) | Game + state + primitives joined; the three rules; the planned iteration and refactor |
Architecture summary lives in [`../DESIGN.md`](../DESIGN.md). Architecture summary lives in [`../DESIGN.md`](../DESIGN.md).
@ -28,6 +31,15 @@ Architecture summary lives in [`../DESIGN.md`](../DESIGN.md).
3. Record the **date** and the **game build** (`v0.107.1` today). Both move. 3. Record the **date** and the **game build** (`v0.107.1` today). Both move.
4. When a finding is later disproved, do not delete it. Mark it superseded 4. When a finding is later disproved, do not delete it. Mark it superseded
and say what replaced it. The wrong turn is often the useful part. and say what replaced it. The wrong turn is often the useful part.
5. **Re-measure numbers before you copy them.** Test counts, latencies and run
totals in these docs have gone stale more than once. The suites print their
own totals; use those, not a figure quoted from an earlier session.
> Worked example: a hand-off summary recorded `29` and `118` assertions.
> Re-running the suites showed `50` and `131`, and the difference was two
> regression sections that already existed in the files. The stale numbers
> would have gone into this index unchallenged. Run the suite; do not trust
> the summary.
## Environment these notes were taken on ## Environment these notes were taken on
@ -57,11 +69,12 @@ Then restart the game. Mods load only at process start.
## Testing ## Testing
Two suites, both offline. Run them before every session. Three suites, all offline. Run them before every session.
```bash ```bash
python3 test_brain.py # 54 assertions — structural / programmatic python3 test_brain.py # 131 assertions — structural / programmatic
python3 test_facts.py # 29 assertions — arithmetic and parsing python3 test_facts.py # 50 assertions — arithmetic and parsing
python3 test_run.py # 21 assertions — the decision log and its joins
``` ```
`test_brain.py` is the important one for catching usage bugs. It asserts that `test_brain.py` is the important one for catching usage bugs. It asserts that
@ -69,6 +82,12 @@ every `state_type` produces an action **legal for that state**, that every
action the decision layer can emit is declared somewhere, and that every action the decision layer can emit is declared somewhere, and that every
fallback respects its own inputs. It needs no model and no running game. fallback respects its own inputs. It needs no model and no running game.
`test_run.py` drives `main()` end to end with a fake `sts2` and a stub client,
so it pins the decision log without a network: a model decision's row carries
that decision's answers, and a decision that asked nothing logs `jev: null`
rather than inheriting the previous step's. Removing the reset before each
`decide()` makes it fail.
It was added after a session in which four separate infinite loops and three It was added after a session in which four separate infinite loops and three
hardcoded fallbacks were found by hand. Most of them would have been caught hardcoded fallbacks were found by hand. Most of them would have been caught
here. here.