sts2-bot/docs/DATASET.md
0xrsydn 8fae007e50 feat(dataset): migrate game history and captures into a trainable corpus
Turn the data we already have into an open, educational dataset, so the trace we
are about to start collecting has somewhere to go.

`migrate.py` produces:
  * runs.jsonl          37 runs with outcome, killer, seed and final deck
  * decisions.jsonl     1052 decisions, each with its own outcome attached
  * states_index.jsonl  346 unique observations
  * states/             content-addressed gzipped blobs

Content addressing matters: measured, only 56% of captures are unique, so 44% of
storage is duplicates. 3.28 MB raw -> 0.42 MB stored.

The card-reward rows keep the REJECTED options, so this is a ranking dataset
rather than a classification one, and the per-fight `damage_taken` /
`turns_taken` pair is the dense reward signal a combat policy is judged on.

What it deliberately does NOT do: reconstruct per-step combat state/action pairs.
The session logs record the action but not the observation, and captures exist
only for combat, so a step has a state with no action or an action with no state
-- never both. Inventing them would poison the corpus. The gap is declared in
manifest.json instead, and collect.py will close it going forward.

Integrity checking is a separate entry point (`--check-only`) because `--verify`
alone rebuilds first and so can only ever see data that is correct by
construction -- a smoke test pretending to be a check. All six invariants were
verified by deliberately breaking the dataset and confirming the checker fails.
2026-09-22 06:09:16 +07:00

213 lines
8.1 KiB
Markdown

# STS2 decision dataset — schema and usage
An open, educational dataset of Slay the Spire 2 decisions, collected by driving
the game with a TypeSafe System One (Jev) policy and mining the game's own run
history.
> Slay the Spire 2 is © Mega Crit. Game-derived identifiers are included for
> research and education. This project is not affiliated with Mega Crit and is
> not commercial.
## Build it
```bash
python3 migrate.py --verify # existing data -> dataset/
python3 collect.py # going forward, records live decisions
```
`migrate.py` is idempotent: it rebuilds `dataset/` from scratch every run.
## Layout
```
dataset/
manifest.json provenance, counts, limitations
runs.jsonl one row per run
decisions.jsonl one row per decision, with its outcome attached
states_index.jsonl one row per unique observation
states/ab/<sha>.json.gz content-addressed raw observations
```
States are **content-addressed**: the digest is the sha256 of the raw bytes.
Measured on the first migration, **56% of captures were unique**, so 44% of
storage is saved by deduplication and identical observations join for free.
## Current corpus
| | |
|---|---|
| Runs | 37 |
| Decisions | 1052 |
| Unique states | 346 |
| Storage | 0.42 MB (from 3.28 MB raw) |
| Wins | **0** |
Decisions by kind:
| Kind | Rows | What it is |
|---|---|---|
| `card_reward` | 323 | Card offered after a fight; 1 of 3 picked, or skipped |
| `potion_reward` | 190 | Potion offered |
| `relic_reward` | 162 | Relic offered |
| `event` | 120 | Event option chosen (raw shape, varies by event) |
| `rest_site` | 97 | `SMITH` / `REST` / other |
| `upgrade` | 73 | Which card was upgraded |
| `ancient` | 43 | Ancient choice (raw) |
| `shop_purchase` | 19 | Shop transaction |
| `remove` | 10 | Card removed |
| `transform` | 8 | Card transformed |
| `enchant` | 7 | Card enchanted |
## Schemas
### `runs.jsonl`
```json
{"schema":"sts2.run/1","run_id":"1789944034","seed":"...","character":"IRONCLAD",
"ascension":0,"win":false,"killed_by":"VANTOM_BOSS","killed_by_event":null,
"progress":{"acts_entered":1,"map_points":17},
"final":{"deck":[{"id":"STRIKE_IRONCLAD","count":4}],"deck_size":19,
"relics":["BURNING_BLOOD"],"potions":[],"max_potion_slots":3}}
```
### `decisions.jsonl`
Every row carries its own outcome, so no join is required to train.
```json
{"schema":"sts2.decision/1",
"decision_id":"1789944034:act0:pt0:card_reward",
"run_id":"1789944034","kind":"card_reward",
"context":{"act":0,"map_point":0,"map_point_type":"monster","hp":74,"max_hp":80,
"gold":109,"encounter":"NIBBITS_WEAK","room_type":"monster",
"monsters":["NIBBIT"]},
"options":[{"id":"SETUP_STRIKE","picked":true,"floor_added":1},
{"id":"TREMBLE","picked":false,"floor_added":null},
{"id":"BLOOD_WALL","picked":false,"floor_added":null}],
"chosen":"SETUP_STRIKE","skipped":false,
"fight":{"damage_taken":12,"turns_taken":2,"hp_healed":6,"gold_gained":10},
"outcome":{"run_win":false,"run_map_points":17,"run_killed_by":"VANTOM_BOSS",
"run_acts_entered":1}}
```
Fields worth knowing:
- **`context`** is the observable state at that map point. It does **not**
include the deck — the game does not record deck composition per map point.
- **`fight`** is the dense reward signal: what the policy actually paid in HP
and turns. `damage_taken` and `turns_taken` are the two numbers a combat
policy is judged on.
- **`outcome`** is the sparse, run-level signal. It is attached to every row.
- **`options`** for reward kinds includes the rejected options, so this is a
ranking dataset, not just a classification one.
### `states_index.jsonl`
```json
{"digest":"sha256:abc...","path":"states/ab/abc....json.gz",
"bytes_raw":5382,"bytes_gz":712,"state_type":"monster","menu_screen":null,
"seen_in":["live_042_combat.json"]}
```
## Known limitations
These are recorded in `manifest.json` too, so the corpus states its own gaps.
1. **No per-step combat state/action pairs.** The session logs record the action
and reason but not the observation, and captures exist only for combat. A
combat step therefore has a state with no action, or an action with no state
— never both. **They are deliberately not reconstructed.** `collect.py`
records them going forward.
2. **No per-map-point deck composition.** The game records observed deltas
(`cards_gained`, `cards_removed`, `upgraded_cards`) and the final deck.
Folding forward from the known starting deck is left to the consumer,
because doing it here would silently bake in an assumption.
3. **Zero wins.** The reward signal has no positive class. See "Training" below.
4. **Policy bias.** Every decision is what *this* policy did, which is not the
same as what was correct. There is no optimal-action label.
5. **`event` and `ancient` rows are stored raw.** Their option shape varies by
event and has not been normalised.
## Splitting
**Split by `run_id`, never by row.** Two decisions from the same fight share
almost all of their context; a row-level split leaks and reports a fake score.
```python
import json
runs = [json.loads(l) for l in open("dataset/runs.jsonl")]
train_ids = {r["run_id"] for r in runs[:30]}
rows = [json.loads(l) for l in open("dataset/decisions.jsonl")]
train = [r for r in rows if r["run_id"] in train_ids]
```
## Training — read this before using it
The corpus supports three uses today, and one that is blocked.
| Use | Status |
|---|---|
| Offline policy evaluation | ✅ replay states, score any policy, no game needed |
| Regression corpus | ✅ freeze states, assert decisions do not change |
| Gate calibration | ✅ confidence vs accuracy on real decisions |
| Supervised fine-tuning | ⚠️ **imitates a policy that never won a run** |
| Reinforcement learning | ❌ **no reward gradient — 0 wins** |
**The trap:** this is a recording of a losing player. Distilling it into weights
teaches the bot to reproduce the mistakes that lost 9 of 37 runs to a single
Act 1 boss. The data is evidence about *what the policy did*, not about *what
works*.
For RL the blocker is worse than sample size. With `win=false` on every row the
value function has nothing to climb — a reward that is 0 everywhere teaches that
all actions are equally bad.
**What unblocks it:** the corpus starts being useful for learning once runs
reach Act 2 and Act 3, so the reward varies. That is a game-play problem
(fixing why runs end early), not a data problem.
The practical middle path, and the one the TypeSafe docs recommend, is to use
Jev's probabilities as **features** in a small classical model rather than
fine-tuning the model itself:
> "For learned composition, use the probabilities as features in a downstream
> classical machine-learning model."
## Integrity checks
```bash
python3 migrate.py --verify # rebuild, then check
python3 migrate.py --check-only # check an EXISTING dataset, no rebuild
```
**Use `--check-only` to verify.** `--verify` alone rebuilds first, so it can only
ever see data that is correct by construction — it is a smoke test, not a
check. This was found by trying to corrupt a row and watching `--verify` pass
anyway.
The checker asserts:
- every state blob exists and **re-hashes to its recorded digest**
- every `card_reward` with a pick has exactly one `picked: true` option
- `chosen` is always one of `options`
- no absolute paths and no API keys in the output
- every row carries an `outcome`
- every `run_id` in `decisions.jsonl` exists in `runs.jsonl`
Each check was verified by deliberately breaking the dataset and confirming the
checker fails. Measured, all five trip:
| Injected fault | Result |
|---|---|
| `chosen` set to a value not in `options` | `FAIL ...: chosen not in options` |
| 1 byte appended to a state blob | `FAIL hash mismatch states/2b/2bb595...json.gz` |
| state blob deleted | `FAIL missing state states/1c/1cd4fc...json.gz` |
| `/Users/...` written into `runs.jsonl` | `FAIL absolute path leaked into the corpus` |
| `outcome` removed from a row | `FAIL ...: no outcome attached` |
Exit code is `0` when clean and non-zero when not, so it works as a gate in CI
before publishing.