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.
This commit is contained in:
parent
239a42c317
commit
8fae007e50
352 changed files with 2164 additions and 0 deletions
213
docs/DATASET.md
Normal file
213
docs/DATASET.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue