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/<family>/<seed>/metadata.json - the record, plans and narratives, next to the
episode.mp4 it describes, so the video never travels without its record;runs/episodes/<episode_id>/episode.mp4 + metadata.json - the store's own copy.
episodes.video_path points here, so a row survives a re-run of the same seed
overwriting runs/brain/<seed>/episode.mp4.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 episodeEvery 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 callepisode_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.
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].
<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:
finish;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.
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.