The episode store

Every brain episode (scripts/brain_episode.py, pusht_sim.brain.episode.run_brain_episode) ends by recording itself: a fixed set of typed datapoints, one row per plan, and a narrative in the voice of the video report. The store is runs/episodes.sqlite (module pusht_sim.brain.store), and beside it:

runs.py is the workbench's store for multi-scenario physics runs; this one is for brain episodes only and shares nothing with it but the runs/ root.

.venv/bin/python scripts/episodes.py list
.venv/bin/python scripts/episodes.py show 9a5daa8adf08                 # or a unique id prefix
.venv/bin/python scripts/episodes.py search --situation "near a wall" --solved no
.venv/bin/python scripts/episodes.py search --seed 438 --tag finish --min-coverage 0.9
.venv/bin/python scripts/episodes.py backfill runs/brain --tags brain_rule --git-commit 599bc8992d02
.venv/bin/python scripts/episodes.py narrate 9a5daa8adf08            # Claude narrative, needs ANTHROPIC_API_KEY
.venv/bin/python scripts/render_videos_page.py                       # docs/videos/index.html from the store

From Python: record_episode(log_path, video_path) -> episode_id, get(episode_id), search(seed=, backend=, planner=, model=, situation=, solved=, min_coverage=, max_coverage=, since=, until=, tag=, limit=), narrative(episode_id, kind), backfill(run_dirs), classify_situation(state), claude_narrative(episode_id). Every function takes root= (default runs/).

episodes - one row per episode

Every column exists because a later analysis will want to group or filter on it. The same one-line reasons are comments in the CREATE TABLE (sqlite3 runs/episodes.sqlite .schema).

column type why it is there
episode_id TEXT PK sha1[:12] of seed, backend, planner, model, env config and created_at: the same log records to the same row (backfill is idempotent), a re-run of the seed gets a new one
created_at TEXT ISO-8601 UTC; orders episodes, defines "earlier" for the comparison narrative, and is the date-range search key. Backfill uses the log's mtime
seed INTEGER the start pose; the key for before/after comparisons across code versions
backend TEXT physics backend; before/after comparisons stay within one physics, the engine comparison pairs the same seed across two
planner TEXT rule or claude: which brain made the decisions
model TEXT the Claude model id when planner is claude, else NULL
steps INTEGER horizon in control steps (10 Hz); every step count below is relative to it
start_position_error_px REAL CoG-to-goal distance at step 0: the translation the episode had to make
start_angle_error_deg REAL |goal - block angle| at step 0 (the sign is in plans.angle_before_deg): the rotation it had to make
start_wall_px REAL block CoG distance to the nearest wall line at step 0; below 115 px some edges cannot be reached
situation TEXT the classifier's label (below), e.g. mixed, moderate or flipped, near, near a wall: the axis for comparing episodes
situation_angle TEXT angle band: aligned <= 10 deg, mixed, flipped >= 90, inverted >= 160
situation_position TEXT position band: near <= 40 px, moderate, far > 100
near_wall INTEGER 1 when start_wall_px < 115: the wall-geometry cases the rule planner cannot see
nearly_solved INTEGER 1 when the start is within 30 px and 20 deg: the finish-mode and overshoot cases
final_position_error_px REAL CoG-to-goal distance at the last frame
final_angle_error_deg REAL |angle error| at the last frame; coverage 0.95 needs about 2 deg
final_coverage REAL block/goal overlap at the last frame: the task metric
max_coverage REAL best overlap seen; a gap to final_coverage means the block was pushed past or off the goal
first_success_step INTEGER first step with coverage >= 0.95, NULL if never; coverage alone, the pusher may still be on the block
solved_step INTEGER first step with coverage >= 0.95 AND the pusher >= 10 px clear, NULL if never: the success criterion
contact_bouts INTEGER maximal runs of contact frames; the human needs 3 [2-5]
plan_count INTEGER planning calls made (one per bout plus the first); more plans than bouts means plans that never touched
intents TEXT plan intent sequence rotate -> translate -> finish; NULL for logs made before plans carried an intent
peak_pusher_speed_px_s REAL fastest pusher step; the executor is tuned to the human's ~114 px/s orbit, the old scripted pusher hit 234
mean_pusher_speed_px_s REAL average pusher speed over the horizon: how much of the episode was motion versus waiting
contact_frames_share REAL contact frames / steps; the human is in contact 38.6% of frames
fallbacks INTEGER plans where the model's reply was unusable and the rule decided instead
video_path TEXT the store's own copy of episode.mp4 (runs/episodes/<id>/episode.mp4): stable across re-runs of the seed
log_path TEXT the episode.json the row was built from (per-step states, prompts, raw replies)
git_commit TEXT commit of the code that produced the episode (+dirty if src/ or scripts/ were modified); NULL when unknown. Backfill stores what --git-commit says, else NULL
render_style TEXT video colour scheme version (pygame-parity today), so a frame comparison across episodes knows what it is looking at. Backfill detects it from the first frame
tags TEXT free text (brain_finish, ablation lever=20); substring-searchable, and the videos page groups by it. Defaults to the run family directory (brain for runs/brain/428)

Every number is derived from the per-step records and the final state, not read from the summary, so logs written before the summary carried solved_step or the plans carried an intent record the same columns.

plans - one row per planning call

episode_id, plan_index, intent, edge, lever_px (signed, torque sign), orbit, release_alignment_deg, fallback, latency_s, outcome (why the bout ended: alignment / drift / bout_length / placed / horizon), bout_frames, touched, position_before_px, position_after_px, angle_before_deg, angle_after_deg (signed), coverage_before, coverage_after (NULL in pre-intent logs), reasoning. The narrative's "plan 3 overshot: 11 -> 36 px" comes from here.

narratives - (episode_id, kind) -> text

kind written by
auto always the rule-based writer (generated_by = rule-writer/1)
comparison when an earlier episode with the same seed and backend exists the rule-based writer
engine comparison when an earlier episode with the same seed, planner and horizon exists on another backend (pymunk-reference vs superdex-reference: same published start, same brain, different contact) the rule-based writer
claude only on request (scripts/episodes.py narrate, claude_narrative()) the model named in generated_by

The claude path uses the ClaudePlanner client pattern: client is anything with messages.create(**kwargs); without one it needs ANTHROPIC_API_KEY and raises the same BrainConfigError naming the key. record_episode never calls it.

The situation classifier

Deterministic, from the start state, with the thresholds the human analysis already uses (docs/human_pushing_analysis.md); the components are stored separately.

part rule source
aligned / mixed / flipped / inverted |angle| <= 10 / < 90 / < 160 / >= 160 deg 10 deg is where the human's lever goes off-centre (7.4); 160 is beyond the initial |angle error| p90 of 154 deg (2)
near / moderate / far position <= 40 / <= 100 / > 100 px 40 px is inside the initial position-error p10 (49.5); 100 is around its median (122)
near a wall CoG-to-wall < 115 px the T's 75 px circumradius plus the 52 px orbit ring (15 px pusher + 37 px standoff, 7.1) no longer fits between block and reach limit
already nearly solved position <= 30 px and |angle| <= 20 deg replaces the angle/position parts: one CoG push from done

Labels compose as <angle>, <position>[, near a wall] or already nearly solved[, near a wall].

The auto narrative

<situation>. plans: a -> b -> c. <one or two sentences>, where the plan sequence is the intents (edges for pre-intent logs) and every sentence is chosen by a measured fact and quotes it:

The second sentence, when there is room: the overshooting plan, a coverage peak >= 0.1 above the final value, fallbacks, or that no plan was ever made inside the finish box.

The comparison narrative is Before (<tags>, <date>): <bouts, errors, coverage, outcome>. After: <the same>. <verdict> with the verdict one of: identical to the pixel, now solved, solved both times, overshoot gone (ended farther than it started before, closer after), or the signed deltas. The engine comparison is the same sentence with On <other backend> (<tags>, <date>): ... On <this backend>: ... in place of before/after, the other engine being the baseline the verdict is read against.

The videos page

scripts/render_videos_page.py regenerates docs/videos/index.html from the store: one section per tag, the before/after table when every episode in the section has an earlier run on its seed, the situation table otherwise, and each caption is the stored narrative. It copies each episode's mp4 to docs/videos/<tag>_<seed>.mp4 and encodes the committed .webm beside it with ffmpeg when missing or stale.