sts2-bot/docs/research/06-decision-architecture.md
0xrsydn fb32822468 Add design doc and research notes
DESIGN.md covers the three-layer architecture (facts in code, Jev for
tactics, gated escalation for macro). research/ documents the engine and
mod surface, the Jev classifier's measured behavior, the STS2MCP HTTP
interface, state shapes, failure modes, decision architecture, and a
run log of the first four sessions.
2026-09-22 00:01:22 +07:00

174 lines
6.7 KiB
Markdown

# 06 — Decision architecture
## The core constraint that shapes everything
Jev is a classifier. It picks one option from a set **we** define. It cannot
plan, cannot invent actions, and cannot do arithmetic — and it reported 0.79
confidence on a wrong lethal check.
So the architecture is not "send state, get actions". It is:
```
game state ──▶ CODE computes facts and enumerates legal actions
──▶ JEV judges between the enumerated options
──▶ CODE gates on margin, executes exactly one action
──▶ re-observe
```
The arrow that matters is the first one. **The action space is generated by
code, never by the model.**
## Three layers
| Layer | Engine | Responsibility | Examples |
|---|---|---|---|
| Facts | pure Python | arithmetic, legality, thresholds | lethal, threat bucket, block deficit, deck counts |
| Tactics | **Jev** | preference between legal options | which card, which target, which node |
| Gate | code | when to trust Jev | margin thresholds, fallback heuristics |
### Why the split is exactly here
Jev is good at **semantic judgement over described options**. It is bad at
arithmetic, counting, multi-hop indirection, and large noisy states.
So the split puts every number in code, and every *preference* in Jev. The
model sees conclusions:
```json
{
"combat": {
"your_turn": true,
"energy": 3,
"your_health": "healthy",
"incoming_threat": "chip",
"lethal_available": false,
"enemies_you_can_kill_now": "none",
"enemies": [{"id": "NIBBIT_0", "name": "Nibbit", "hp_state": "healthy",
"incoming": 12, "intends": "This enemy intends to Attack for 12 damage."}],
"hand": [{"index": 2, "name": "Bash", "cost": 2, "type": "Attack",
"targets": "AnyEnemy", "text": "Deal 8 damage. Apply 2 Vulnerable."}]
}
}
```
Note `your_health: "healthy"` and `incoming_threat: "chip"` — buckets, not
numbers to compare. Note `lethal_available: false` — a conclusion computed by
`facts.py`.
## Decision precedence in combat
```
1. CODE: lethal proven by facts.py? -> execute the lethal line
2. CODE: no play phase? -> wait
3. JEV : which play is best? -> act if margin is sufficient
4. CODE: fallback heuristic -> act
```
Step 1 never consults the model. That is the direct consequence of the
arithmetic failure: `facts.py` proves lethal and a deterministic search
executes it. Across 759 loop steps, **33 lethal lines were executed by code**
and Jev was never asked "can I kill this".
Step 4 exists because a low-confidence answer must not become a guess. The
fallback is a documented heuristic adapted from the STS2MCP strategy notes.
## Current coverage
| Decision | Jev | Fallback | Status |
|---|---|---|---|
| Combat: card play + target | yes | heuristic | working |
| **Combat: potion use** | yes, plus a hard-need override | spend when lethal | working |
| Map pathing | yes | HP / gold heuristic | working |
| Card reward | yes (with `skip`) | take first | falls back often |
| Card select: upgrade/transform/remove | yes | select first | working |
| Event option | yes, stricter gate | keyword safety net | working |
| Relic select | yes | take first | shape unverified |
| **Shop purchase** | yes (re-ranking) | leave | working |
| **Treasure relic** | yes when >1 offered | take first | working |
| **Bundle select** | yes | first bundle | working |
| In-combat exhaust/discard select | **no** | Strikes, then Defends | heuristic |
| Rest site: heal vs upgrade | **no** | HP < 60% → heal | heuristic |
| Crystal sphere | **no** | skip | gap |
Combat now issues three kinds of action: `play_card`, `use_potion`, and
`end_turn`.
### Potion precedence, and why it is split
```
1. CODE: lethal by cards? -> play the lethal line
2. CODE: not play phase? -> wait
3. CODE: incoming hit lethal AND no card can prevent it
-> spend a potion (no model)
4. JEV : is a potion worth spending now? -> use it if confident
5. JEV : which play is best? -> play it
6. CODE: fallback heuristic -> act
```
Step 3 exists because Jev answered the soft potion question at **0.61**, just
under the 0.65 Noul floor, on a state where the next hit was lethal and no card
could prevent it. Delegating that decision would have lost the run. Code
decides **that** a potion must be spent; Jev decides **which**.
The gaps cluster in **resource spending** — potions, gold, and one-shot card
effects. That is the category that decides boss fights. Run 1 died to Vantom
holding all three potions, which is a direct consequence of the potion gap.
## Batching
All questions for one state go in **one** call. Questions are evaluated in
parallel, and measured latency barely moves with question count:
| Request | Time |
|---|---|
| 1 short question | 0.73 s |
| 3 questions, full combat state | 0.90 s |
So adding a potion question to the combat call costs **no extra latency**.
A question that is only sometimes relevant is still worth asking
(speculative fan-out); the code ignores answers it does not need.
Questions in one call are independent. If Q2 needs Q1's answer, use a second
call.
## The closed loop
```
observe -> decide -> execute ONE action -> observe again
```
Strictly closed, because **playing a card removes it from hand and shifts every
later index**. An action list computed up front would be wrong after the first
play. The same applies to reward lists, which re-index on every claim.
## Confidence gating
| Answer type | Gate |
|---|---|
| `Choice` | `top >= 0.45 and (top - runner_up) >= 0.20` |
| `Noul` | `abs(noul - 0.5) >= 0.15` |
| `Score` | `confidence >= threshold` |
Never gate a `Choice` on `confidence` alone. It measures peakedness and
therefore falls as the option count rises.
## Robustness rules
1. Never trust `status: ok`. Verify against the next state read.
2. Wait on transitions; do not stop.
3. Bound every wait, and bound consecutive rejections.
4. When the state repeats after a **successful** action, wait.
When it repeats after a **rejected** action, try something else.
5. Claim and select collections **right-to-left** when they re-index.
## Open questions
- Can a System One policy clear an act boss at all, or does the macro layer
need escalation to a reasoning model? Run 1 reached floor 16, so the plateau
is now measurable rather than assumed.
- Card reward fell back to "take the first" several times on thin margins.
Composite `Score` questions per axis, combined with weights in code, may
beat a single `Choice`.
- Deck tracking is currently a composition snapshot persisted to `deck.json`,
refreshed from combat states. It will drift if cards are removed outside
combat without an intervening fight.