Domino-fan environments: wind-triggered cascades, a declare variant, and a blow-to-goal task - #168
Open
naiimic wants to merge 48 commits into
Open
Domino-fan environments: wind-triggered cascades, a declare variant, and a blow-to-goal task#168naiimic wants to merge 48 commits into
naiimic wants to merge 48 commits into
Conversation
The viewer worked but read as a wall of same-weight text: outline-only
chips that all looked alike at a glance, a run table whose numbers did
not line up, and a private chip notation (round tags, kind marks,
rewards) documented only in tooltips.
* design tokens replace the flat GitHub palette — one HSL scale
defined on bare :root, with dark arriving either from the OS or
from a new topbar toggle (auto / light / dark) that stamps
data-theme on <html>. Applied in <head> so a stored choice never
flashes the other theme first;
* chips are filled (tinted background + saturated text) rather than
outline-only: an episode strip is scanned, not read, and fills
separate the states far faster than border colour alone. A run's
kind vocabulary now also reaches the run page sidebar, where a
learn query used to render as an unlabelled grey chip;
* the run table gets a sticky header, uppercase micro-labels, row
hover, and monospaced numerics so seeds, costs, timestamps and
durations line up column-wise;
* a "How to read a run row" legend spells out the notation on the
index, open on a first visit and remembered closed thereafter.
Column headers keep short labels and move provenance ("parsed from
info.log") into tooltips, read once rather than on every row;
* experiment groups open by default, so landing on the index shows
the runs instead of a stack of headers hiding them.
Presentation only: no change to what is parsed, served, or killed.
Palette and density follow the kalshi-burner / trading1 dashboards.
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
The run page pairs each video with its agent transcript, which is right for reading one episode and wrong for the question this viewer gets asked most: did the robot get better? Answering it meant finding the videos dir by hand and sorting filenames. /replays?d=<run> lays every recorded episode out oldest-first, each clip captioned in plain language — which round, whether it was a practice episode (data gathering) or a held-out test (the score), and the env verdict with its reward. Reachable from a ▶ on each index row and from the run page's banner. Deliberately not chips: this page exists for readers who do not know the pipeline's shorthand, so the same facts are spelled out in words. A replanned task's two transcripts share one video, so identical players are shown once rather than reading as two attempts. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
Two rounds of feedback on the same page said the same thing: the hierarchy was chrome, not information. The index is one flat table now. Approach and experiment were collapsible group headings nesting every row two deep; they are columns, visible on every row and typeable into the filter box, which is what the groups were actually used for. The episode strip is gone from it entirely - a dense private notation is the wrong thing to meet a reader with, and the run page and the reel both carry that detail already. What is left is what a run IS (approach, experiment, seed) and how it went (status, result, cost, when). The replay reel becomes the run's pipeline, not a list of clips: episodes grouped into the rounds of the online loop, clips small and side by side so a round is taken in at a glance, and between rounds the sysID verdict - the physical parameters the fit recovered from the practice episodes and handed to the planner. Watching top to bottom is watching belief and behaviour move together. A ▶ watch button opens it from every index row. Also: the reading key explains the columns on screen rather than a chip alphabet the index no longer uses, and says plainly that cost is the SDK's token valuation, not a subscription bill. Players seek to #t=0.1 so a wall of clips shows first frames rather than black rectangles. Removes the episode-grid layout machinery (grid_layout, grid_width, episode_grid, _split_episodes, the chip builders, and the geometry constants) with the grid that used them. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
…olumn
Three things the flat index still got wrong.
The table was sized to its content and left most of the window empty;
it is the page's content, so it takes the page's width.
Cost is gone from the index. It is the agent SDK's valuation of the
run's tokens at API rates, which on a subscription bills nothing — so
as a column it invited exactly the question it got ("what is the point
of showing cost?"). The run page still reports it per episode and in
total, where the surrounding detail makes it mean something.
Filtering by what an arm IS took knowing that oracle approaches are
named oracle_*, and typing it. Each row now carries data-kind, and an
all / learning / oracle toggle sits beside the filter box: learning
for the agent_* approaches that have to learn a model, oracle for the
ones handed the ground truth. It composes with the text filter (both
apply) and survives a reload.
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
"Run detail" told a reader nothing, so following it out of the replay reel landed them back on the densest page in the viewer with no idea why. There are only three pages here and each nav link now names its destination: all runs the index ▶ watch that run's episodes, as video logs that run's transcripts, logs and files The run name on the index points at the reel now, not at the log page: "show me this run" should show the robot, and the transcripts are a secondary "logs" link on the same row. Both run-scoped pages carry a link back to the index and across to each other, so neither is a dead end, and the log page's duplicate banner button is gone. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
pybullet_domino_fan put a ball between the fan and the dominoes: the wind pushed the ball and the ball knocked the chain over. The task we want is the fan acting on the dominoes themselves, so that arranging the layout is the whole problem and the robot never pushes anything. The ball variant is preserved under pybullet_domino_fan_ball, and the bare name now builds domino + fan only. Which body the wind pushes is the entire difference between the two tasks, so the name says it. Wind targeting had to move. With a ball there is one target body for the life of the env, wired once at startup. The start domino is not like that: roles are colours assigned per layout, so the green block is a different body id from one task to the next. _wire_wind_target re-resolves it on every reset, after the components have taken the new state, and leaves the previous target alone (with a warning) if a task has no start block. It also skips dominoes absent from the state - the component allocates num_dominos_max bodies once and a task instantiates a subset, so reading a colour off the tail of that list raises. envs/all.yaml gains a parked domino_fan entry. Its comment records what the config cannot: these tasks carry NO DominoEvaluator, because the generator attaches it only when dominoes are the sole dynamic component. Reward is plain goal-reached with no per-blue cost, so its numbers are not comparable with a domino_* arm's. Verified: the env builds with [DominoComponent, FanComponent] and no ball, generates a Toppled(target) task, and after reset the fan's wind target is the green start block's body id. pybullet_domino_fan_ball still resolves and keeps its ball. tests/envs/test_pybullet_domino_ composed.py passes. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
Probing the new ball-free fan env turned up three separate reasons a
wind-started chain could not work. None was the force constant, which
was the thing I expected to be tuning.
1. Layouts were not aligned to any fan. The generator picks the start
pose uniformly and the travel direction from {+y, +x, -x} at random,
which is right for a chain the ROBOT pushes and wrong for one the
WIND starts: a chain crossing the airstream cannot cascade however
hard the fan blows, so the task was unsolvable before a planner saw
it. domino_fan_aligned_tasks lays the chain along a chosen fan's
axis, start block in the upwind fifth, and records the side in
last_fan_side. The two frames meet in exactly one table:
_FAN_SIDE_TO_ROTATION reconciles the fan's yaw with the generator's
travel convention (sin/cos of rotation, measured from +y).
2. Wind pushed through the centre of mass. A force through the centre
is pure translation: the domino slid across the table indefinitely
and never tipped. Applied 0.4 of its height above the origin, the
same 2 N makes a moment about the bottom edge and it falls.
3. The wind never stopped. Even once down, the fan went on shoving the
fallen block, sliding it the full 0.29 m into the target - a bridge
task "solved" with none of the bridge built, at any force. The fan
now cuts out when its target passes the same 10-degree threshold the
Toppled predicate uses, so the physics stops where the symbol flips.
A fallen domino lies out of the airstream; this is what that means.
Both new behaviours are opt-in arguments to set_wind_target defaulting
off, so pybullet_domino_fan_ball keeps a centred, never-ending push on
its ball - verified: z_offset 0.0, stop_when_toppled False, target is
still the ball body.
Measured, 5 aligned test tasks at the default force: the wind now tips
the start block to +-90 in every one, and reaches the goal in none of
them. That is the correct answer, not a regression - start and target
sit 0.29 m apart against a ~0.15 m topple reach, with blue blocks
parked to the side, because these are bridge tasks. The wind starts the
chain; building the rest is the robot's job. Before fix 3 they scored
4/5 by bulldozing.
Still open: pybullet_domino_fan appears in no ground-truth registry
(options, processes, predicates, types, gt_simulator), so the oracle
arm raises NotImplementedError and cannot yet be run as a baseline.
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
The 2.0 N the fan blew with was the ball's number, inherited by the
domino env's copy of FanComponent and never checked against a domino.
A 100 g domino 15 mm thick and 150 mm tall, pushed at 0.4 of its
height, tips when the wind's moment beats gravity's about the bottom
edge: m*g*(depth/2)/(0.4*height) = 0.123 N. A sweep over real tasks
agrees, and shows what the extra order of magnitude was buying:
force tipped mean displacement
0.05 1/4 15 mm
0.10 4/4 82 mm
0.20 4/4 85 mm
0.30 4/4 83 mm
0.50 4/4 130 mm
1.00 4/4 833 mm
4.00 4/4 5462 mm
~85 mm is one topple of a 150 mm block, so 0.1-0.3 N is the band where
the domino falls over and nothing else happens. Past it the block stops
toppling and starts flying. domino_fan_wind_force = 0.2 sits ~60% over
the threshold, the margin pybullet_fan's 0.06 N keeps over its ball's
stiction - same method, different body.
The force is a per-target override on set_wind_target, not a change to
the class default, so pybullet_domino_fan_ball keeps blowing its ball
at 2.0 N. Verified: that env's override is None.
And the mechanism works end to end. The generated tasks are BRIDGE
tasks - start and target sit further apart than one topple reach on
purpose - so they cannot show whether wind can run a chain. Laying one
chain by hand at the env's own pos_gap and switching on the fan it
faces: 4/4 dominoes topple, purely from wind, no robot involved.
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
fan/gt_simulator.py is the right template: one rule, the wind, with the
force as a FITTED parameter, because the wind is precisely what the
agent is supposed to discover. This is that for the ball-free domino
env.
It is much smaller than the fan env's, and the reason is structural.
pybullet_fan needs a grid abstraction because a ball's whole trajectory
is wind-driven, cell by cell. Only a domino chain's FIRST block is; the
topple, the contact, and the entire cascade are the engine's, and the
existing domino processes already describe them. So the program is one
rule that pushes the start block, and nothing about chains at all.
Two things differ from the ball's wind, both forced by a domino
standing on a narrow base rather than rolling:
* the push has to act ABOVE the centre of mass, or it translates the
block instead of tipping it. ApplyForce is centre-of-mass only by
construction, so the offset goes in the way statics decomposes it -
the same force at the centre, plus the moment it would have made,
r x F with r = (0, 0, lever) giving (-lever*Fy, lever*Fx, 0). That
is a torque about the axis perpendicular to the wind: the tipping
moment. Both commands are in the vocabulary an agent-synthesized
simulator already has, so nothing here is privileged;
* the wind stops when its target falls, at the same 10-degree
threshold the Toppled predicate uses, so the dynamics stop where
the symbol flips.
wind_force (0.2 N) and wind_lever (0.06 m) are both fitted, not fixed:
neither is observable from a single state, and recovering them from
watching dominoes fall is the system-ID task. roll joins x/y in
RESIDUAL_FEATURES because the effect is a topple, not a shove - a
program with the force right and the lever wrong slides the block, and
only roll separates the two.
Verified: get_gt_simulator("pybullet_domino_fan") resolves; the rule
emits ApplyForce (0.2, 0, 0) and ApplyTorque (0, 0.012, 0) = lever x
force with the left fan on; four fans on emit nothing, matching the
physics, which needed a tolerance - they cancel to ~1e-16, not to zero,
and an exact test let that dust through as a command every step.
pybullet_domino and pybullet_fan still resolve their own.
Still missing for an oracle run: types, predicates, options and
processes for this env. This is the world model, not the planner.
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
The env block was written before domino_fan_aligned_tasks and domino_fan_wind_force existed, so it selected the env with neither: a chain pointed anywhere the generator liked, blown at the ball's 2.0 N. Both are the difference between a task and an impossible one, so they belong in the block rather than in a launcher someone has to remember. Turn ratios pinned to 0 as well - alignment only guarantees the FIRST leg runs downwind. Also records what the SKIP hides: this env has a GT residual simulator but no GT types, predicates, options or processes, so every arm raises NotImplementedError on the option lookup. Un-skipping it today buys a crash, and the comment says so rather than leaving the next person to find out. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
pybullet_domino_fan now solves under oracle_process_planning: 1/1,
reward 1.000, a 10-step plan. It was NotImplementedError before.
Registered the env in the domino types, predicates, options and
process factories, which needed almost nothing new - the composed env
already publishes the fan component's types and predicates, and the
grid helpers delegate to GridComponent either way. Two additions:
* TurnFanOn / TurnFanOff options. A switch is pressed by pushing at
its pose, so these are push skills, the same construction
fan/options.py uses including its yaw correction (a push faces
(sin yaw, cos yaw), a switch reports (cos rot, sin rot), so the
conventions differ by a quarter turn and on/off are that quarter
turn either side);
* TurnFanOn and WindTopplesStartBlock processes. The second is the
only bridging rule the wind needs: FanOn + InitialBlock + Upright
-> Tilting, and the existing cascade rules carry it from there.
Effects mirror PushStartBlock exactly, Tilting added and Upright
deleted, because they are the same event reached two ways.
Push is withheld in a fan env, option and process both. Left in, the
planner takes it every time and solves a wind task without ever
touching a switch - which is what the first run did, executing
Pick/Place/Pick/Place/Push and never turning the fan on.
Two things had to be written over FANS, not switches, and finding out
why cost most of the debugging: under fan_known_controls_relation the
FanComponent hides SwitchOn/SwitchOff and publishes FanOn/FanOff, so a
guard keyed on "SwitchOn" in predicates never fired, no wind process
was built, Push was already withheld, and the planner correctly
reported that nothing could topple anything. The option takes the fan
too, and resolves its switch by the side it controls.
Also: Upright is an at-start condition on the wind, not an overall one.
The process's own effect is the block ceasing to be upright, so
requiring it throughout makes the rule contradict itself.
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
Videos are only written under --make_test_videos / --make_interaction_videos, so plenty of runs have none - and every row got a watch button regardless, which spent the click to explain the emptiness afterwards. A run with no videos now shows "no video" in that column, and its name links to the logs instead of to a reel that could only say there is nothing here. The tooltip names the two flags that would have recorded them. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
Eight fixes, each measured. The env now does what it should everywhere I can test a piece in isolation; the oracle still cannot complete an episode, and the last section says exactly why. ONE FAN. The scene had four fan banks of five and four switches, the ball task's layout, where a ball must be blown four ways across a grid. A domino chain runs one way. The other three fans were distractors the planner had to ground, and being opposed they cancelled exactly - the reason an early probe measured zero wind with every switch on. num_sides / fans_per_side are constructor arguments now, and the env asks for one of each. A REACHABLE SWITCH. switch_y = (y_lb + y_ub)/2 - 0.25 is tuned to the fan env's workspace: 1.0 m deep, robot at the front edge, switch 0.73 m in front of the base. The domino workspace is half as deep with a centred robot, so the same line put the switch 0.41 m away, folded against the arm. IK solves there - which is why nothing errored - but the solution is clamped to joint limits and lands centimetres short, and the press waits forever for an arrival within 1e-4. A sweep found the boundary sharp: 0.41 m never terminates, 0.59 m and beyond press in ~30 steps. The switch sits at (1.10, 1.20) now, past the chain in x and nearer in y, so the reach comes in from outside the chain. WIND THAT ARRIVES IN TIME. 0.2 N clears the 0.123 N tipping threshold but the block does not visibly move for 28 steps - and Wait ends on quiescence, so it called the scene settled at step 11 and the plan finished before the wind did anything. Onset by force: 28 steps at 0.2, 17 at 0.4, 11 at 0.8, 7 at 1.5. 1.5 N moves the block well inside Wait's window and produces the same cascade (final rolls [81,66,45,11] against [80,66,45,11]). A WAIT THAT LASTS THE CASCADE. Ten quiet steps means "over" for a push-started cascade, which never pauses. A wind-started one has a lull built in: the fan cuts out the moment the start block is down, and the chain then coasts on contact. Wait ended mid-cascade at 35 steps against the ~70 needed. wait_quiescence_steps is 40 for fan envs, 10 elsewhere. FAN-LAST PLANS. The wind process carried a 2-tick delay, and the planner used the window: it ordered TurnFanOn FOURTH of six and kept placing dominoes, believing it could finish the bridge while the start block was mid-topple. A Place runs ~19 env steps; the topple is over in a fraction of that. Delay 0 forces InFront to hold when the fan comes on, and the plan is now Pick, Place, Pick, Place, TurnFanOn, Wait. A SCORE THAT CANNOT BE CHEATED. This env had no evaluator at all - reward was plain goal-reached - so three separate runs scored "1/1, reward 1.000" for episodes where the ARM knocked the target over and the fan was never touched. The cascade certificate now applies, with TurnFanOn as the sanctioned trigger instead of Push; the generalisation is small because the span finder already admitted a trigger naming no domino, which is what a switch press is. The same episode now scores 0/1, reward -0.050. Ball variants still get no evaluator: a ball is a second body the robot can throw at the chain. The counterfactual probe (rule d) is skipped for wind - it exists to prove the robot's BODY did not carry the cascade, and a switch press metres away has no contact to disprove - which leaves the arm unproven inert AFTER the trigger. Recorded in the code as a known gap. A THRESHOLD THAT IS NOT RESTATED. I hardcoded 10 degrees in the wind's stop-when-toppled check and in the GT simulator, and wrote in both comments that it matched the Toppled predicate. It does not: the predicate is 5 degrees. Both read DominoComponent.domino_roll_threshold now. STILL BLOCKED, and not on any of the above: Place cannot reach its drop pose. With motion planning off the arm drives straight through the scene (measured: start block dragged 0.19 m, target shoved 0.16 m, and the certificate correctly rejects the result). With it on, BiRRT refuses honestly - "[Place/MoveToDrop] BiRRT collision: target configuration in collision". Same fact either way: the gripper and its held domino do not fit the 9.8 cm gap in this scene, where the plain domino env manages the same spacing. The fan and switch bodies are the obvious new collision geometry to suspect. Verified: plain domino oracle still solves 1/1 at reward 0.900; 50 tests pass; pylint 10.00; mypy clean. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
…osed Two fixes, and one finding that undercuts the env's numbers. STAGING HAD NOWHERE TO GO. The staging grid steps y by 1.5 domino widths inside margins of 1.5 and 3, so the inherited y range (1.1-1.6) left a domino band 0.185 m deep: room for exactly ONE row, at y=1.275. A wind-aligned chain runs along x at y=1.283, so the only place the generator could park a blue was 8 mm from the line it had to be built into, and Place could not reach a bridge slot without the gripper fouling an unmoved neighbour - "BiRRT collision: target configuration in collision", overlapping domino_2 by 2 mm. y_ub=1.70 gives a 0.285 m band and rows at 1.275 and 1.38: one for the chain, one to park in. Blues now stage 5.1 cm clear. This bites only the fan env because the wind FORCES the chain along x. In a plain domino env the chain may run any of three ways and rarely lies parallel to the single staging row, which is why that env has never hit it. THE BODYLESS-TRIGGER SHORTCUT FAILED OPEN. Skipping the counterfactual probe for a wind trigger is sound only because rules (a) and (b) have already established that the trigger happened and that nothing toppled before it. Without option labels those rules are skipped, and returning True regardless turned the last gate into a rubber stamp - a place-knock episode with no trigger at all would collect the success bonus. It now fails closed when the trigger was not verified, matching what the probe path does for a goal-reaching episode it cannot check. AND THE NUMBERS ARE STILL NOT TRUSTWORTHY, for a reason neither fix touches. _topple_onset needs _TOPPLE_MIN_STEPS (3) consecutive post-topple states to register a fall. terminate_on_goal_reached_and_option_terminated cuts the episode at the instant the goal holds, leaving fewer than three - so no onset is found, the certificate concludes nothing toppled, and its whole rule set no-ops. An episode where placing a blue knocked the target over, with TurnFanOn never executed, still scores 1/1 at reward 0.900 under early termination. The certificate is blind to the event it exists to judge, precisely when the episode ends on it. Verified: plain domino oracle still solves 1/1 at reward 0.900; domino tests pass; pylint 10.00; mypy clean. Not fixed: with the full plan allowed to run, the fan is pressed but the chain is already down - placing the second blue beside the target topples it. That, and the onset blindness above, are what stand between this env and a trustworthy solve. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
Placement worked in the plain domino env and not here, and the reason
was luck rather than difficulty.
Both envs stage blues on the same grid and both need a 2-blue bridge
across the same 0.294 m. What differs is where the chain sits. The
plain generator picks the start pose uniformly, and here it landed at
x=0.697 with the chain running to 0.991 - so the staging cells at 0.470
and 0.575 were clear of it. _fan_aligned_start puts the start in the
upwind fifth, so the chain runs through the MIDDLE of the workspace and
the grid handed a blue the cell at x=0.680, inside the span. That blue
sat 66 mm from the bridge slot at 0.638, inside the gripper's 100 mm
finger sweep, and Place had no collision-free descent
("BiRRT collision: target configuration in collision", overlapping
domino_2 by 2 mm).
So staging now excludes the corridor between the start block and the
far target, rather than relying on the chain to have landed elsewhere.
Plain domino is unchanged - its blues were already clear - and the fan
env parks them at 0.470 and 0.995, both outside the span, 94 mm off the
line instead of 51 mm and inside it.
With that, the task runs end to end. Instrumented episode, every
option boundary:
before TurnFanOn 0:x=0.540 1:x=0.640 2:x=0.737 3:x=0.834 upright
after TurnFanOn 0:r=+83.7 1:r=+81.3 2:r=+82.8 3:r=+90.0
goal holds: True
The robot picks both blues from outside the corridor, builds the bridge
at pos_gap, presses the switch, and the chain falls. Every domino is
upright immediately before the trigger, which is the check that
separates this from the earlier episodes where a Place knocked the
target over.
Verified: plain domino oracle still solves 1/1 at reward 0.900; tests
pass; pylint 10.00; mypy clean.
Still not trustworthy: main.py under
terminate_on_goal_reached_and_option_terminated reports 1/1 at reward
0.900 for an episode that ends at the second Place with TurnFanOn never
started - the certificate passes it, and I cannot yet account for why
given the trigger is TurnFanOn and no trigger ran. Read that flag's
results with suspicion until it is explained; the instrumented trace
above is the evidence the task works, not the reward.
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
…re is sound
The previous two commit messages warned that this env's numbers could
not be trusted and named an unexplained gap where the certificate
passed an episode with no trigger. Both claims were wrong, and the
error was mine.
I concluded TurnFanOn never ran because it did not appear in the run
logs. Option-execution logging is conditional on --debug, which those
runs did not set, so the string's absence proved nothing. The
"[X] Terminated" lines only appear when an option SELF-terminates, and
TurnFanOn is cut short by the goal being reached mid-option - so it
logs nothing either. I treated two kinds of silence as evidence.
With --debug on, the same config shows TurnFanOn 8 times, including
TurnFanOn(robot:robot, fan_0:fan) in the plan and four [TurnFanOn]
execution entries.
The certificate is sound, and now tested rather than assumed. It
cannot return ok=True for a wind episode without having found the
TurnFanOn trigger in the recorded options. Synthetic place-knock
trajectories are rejected on both paths:
labelled "dominoes toppled but the green start block was never
pushed"
unlabelled "no option labels, so the TurnFanOn trigger cannot be
confirmed"
So reward 0.900 is not a number to be suspicious of - it is the
certificate asserting the fan did it: 1.0 bonus minus 2 blues at 0.05.
Standing result, reproduced three times: pybullet_domino_fan under
oracle_process_planning solves 1/1 at reward 0.900. The robot picks
both blues from outside the build corridor, bridges start to target at
pos_gap, presses the switch, and the wind topples the chain.
No code changes; this commit exists so the branch history does not end
on a false warning.
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
exp_domino_fan.yaml runs the ball-free domino-fan task: the robot bridges the start block to the target, presses the switch, and the WIND topples the chain. Verified through the launcher end to end - the oracle arm solves 1/1 at reward 0.900. The env block's comment said "NOT RUNNABLE YET ... un-skipping it now only buys a crash", which was true when written and false since the GT models landed. It now records what the env actually does, and that its tasks DO carry a DominoEvaluator - with TurnFanOn as the sanctioned trigger - so reward is 1[certified wind cascade] - 0.05 per blue and 0.900 is a two-blue solve, not a number to squint at. The learning arm is present but parked. What it has to discover is the wind: domino_fan/gt_simulator.py carries it as one residual rule whose wind_force and wind_lever are FITTED parameters, neither observable from a single state. agent_sim_provide_base_sim_source hands over the scene and the skills the way exp_fan.yaml does for the ball task - the wind dynamics are not in that source, so it is not handing over the answer. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
Yichao's three setups, plus the parameter-learning rung between them,
written into the launcher so moving up a rung is flipping one SKIP
rather than reconstructing flags:
1 oracle GT processes + predicates, process
planning. Measured 1/1 at 0.900.
2a agent_model_based_planning GT monolithic sim + predicates, the
AGENT plans. The gentler entry.
2b agent_oracle_hybrid_sim GT hybrid sim + params + predicates,
agent plans.
3 agent_param_learning GT sim STRUCTURE, params fitted.
Isolates system-ID from structure
discovery - for this env, "you are
told a wind exists, recover how
strong it is and where it acts".
4 agent_po_predicate_invention_al
Base sim only: discover the wind,
model it, invent predicates. Ours.
Records why NOT agent_oracle_mono_sim for rung 2, which is the trap in
this menu: its agent_bilevel_plan_sketch_file points at a BOIL sketch
naming objects this env does not have, so it needs a domino-fan sketch
written before it can be the "oracle mono sim + agent planning" rung.
Also records the reward scale, because it invites a wrong reading:
train tasks are 1-blue bridges (ceiling 0.95), test tasks 2-blue
(ceiling 0.900), since reward = 1[certified wind cascade] - 0.05 per
blue. A run at 0.95 train / 0.900 test is at ceiling on both, not
declining.
Each arm verified to resolve through launch_simp: 2a -> agent_planner,
2b and 3 -> agent_sim_learning.
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
np.linspace(a, b, 1) returns [a], not the midpoint, so the single-fan side introduced for domino_fan placed its body at the low end of the rail while the state kept reporting the centre: the reset check logged "fan_0.y: requested=1.708000 reconstructed=1.286000", a 0.42 m gap between where the fan was said to be and where it was. Invisible to the oracle, whose wind is computed from the fan's ORIENTATION and never its position. It matters for the learning rungs: the agent's own synthesized simulator reasons about "how far downstream of the fan anchor the beam still topples a domino", so a fan coordinate 0.42 m from the actual body corrupts precisely the spatial inference it is trying to make. Verified: the mismatch warning is gone (0 occurrences, was 2 per run), the state and body now agree at y=1.708, and the oracle still solves 1/1 at reward 0.900 through the launcher. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
The directory existed but was empty - the gifs lived on a Desktop and
in a session scratchpad, both since cleared, so nothing was actually in
the repo.
oracle_solve.gif rung 1 solving the task end to end, rendered from
the run this branch's launcher produces:
PickDomino, PlaceDomino, PickDomino, PlaceDomino,
TurnFanOn, Wait -> 1/1 at reward 0.900.
wind_cascade.gif the mechanism alone - chain laid by hand at
pos_gap, fan on, no robot. 4/4 topple, final rolls
[82, 81, 83, 90] degrees. Separates "the wind
works" from "the manipulation works", which is the
distinction that cost the most debugging time.
The README records the reward formula and the two ceilings, because
they invite a wrong reading: train tasks are 1-blue bridges (best
0.95), test tasks 2-blue (best 0.900), so a run at 0.95 train and 0.900
test is at ceiling on both rather than declining.
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
agent_model_based_planning solves the test task 1/1 at reward 0.950
against the oracle's 0.900, and the gap is real rather than a scoring
artifact. The oracle's grid planner bridges with TWO blues; the agent
found ONE suffices, because a domino topples further than one pos_gap.
The second blue is visible in the video, untouched at its staging spot.
So the README's ceiling table was misleading and is corrected: 0.900 is
the ORACLE's score on the test task, not the task's best possible.
The agent also probed its own limits and read them correctly - x=0.700
and x=0.570 fail on gripper clearance rather than physics ("the opening
fingers need ~0.044 m of side clearance"), symmetric about both
neighbours - and carried the findings across sessions in its own
notes_domino_fan.md.
Adds a results table for the ladder: rung 1 and 2 measured, rung 3 not
run, rung 4 partial.
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
THE FAN WAS NOT POINTING AT THE DOMINOES. Its rail centred at y=1.708
while the chain was laid at y=1.369 - 0.34 m to the side, on a rail
whose centre sits outside the domino workspace (y<=1.49) entirely, so
no chain could ever be on its axis.
It ran anyway because _apply_wind_force reads a fan's ORIENTATION and
ignores its POSITION: the wind topples the target from wherever the fan
happens to be parked. Right in direction, fake in geometry, and visible
the moment anyone looked at a frame.
Beyond looks, it is disqualifying for the learning rungs. An agent
reads fan_0.x/y and reasons about how far downstream the beam reaches -
its own synthesized simulator carried a wind_reach parameter measured
from the fan anchor. Fitting that against a fan 0.34 m off-axis is
fitting against a lie no amount of data resolves.
The fan is now aimed at the chain's lateral coordinate, and aimed at
GENERATION time rather than on reset: doing it at reset left the task
carrying the un-aimed coordinate and the reset check comparing two
different positions ("fan_0.y: requested=1.708000
reconstructed=1.386534"). Task state now reports fan y = start block
y = 1.3687, offset 0.0000.
This was the third symptom of one root cause. The other two - the body
at the rail's low end while the state reported its centre, and the
rail's centre being off-workspace - I fixed piecemeal without asking
why a fan's position was free to disagree with anything at all.
Adds scripts/domino_fan/run_rung.sh: run any rung of the ladder without
editing YAML. It generates a config with every arm parked except the
one asked for, so the rungs cannot be run half-switched (which is how a
10-cycle Opus run started unnoticed). Rung 2 gets
num_online_learning_cycles: 0 - with a GT model there is nothing to
learn and the cycles only re-derive a solved plan at ~$2 each.
scripts/domino_fan/run_rung.sh 1 # oracle, ~2 min, no LLM
scripts/domino_fan/run_rung.sh 2 # agent plans
scripts/domino_fan/run_rung.sh 3 # learns the wind's parameters
scripts/domino_fan/run_rung.sh 4 # learns its code and predicates
scripts/domino_fan/run_rung.sh all
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
Three scripts and a dashboard that knows what a rung is, so moving up
the ladder is not a matter of remembering which SKIP to flip in which
YAML - which is how a 10-cycle Opus run started unnoticed yesterday and
sat there costing money.
run_rung.sh 1|2|3|4|all generates a config with every arm parked
except the one asked for, then launches it
dashboard.sh the viewer, with buttons that start a rung
reset_runs.sh [--yes] clears domino-fan runs; dry by default
The dashboard gains:
* a RUNG n badge on every domino-fan row, keyed off the arm in its
experiment_id, with the rung's line on hover - a row now reads as a
rung of the experiment rather than an approach name;
* "run rung 1 2 3 4" buttons that POST /launch and start the script
detached, so the run outlives the request (rungs 3 and 4 are
hour-scale) and reports progress through the rows the dashboard
already draws;
* a refusal to start a second run while one is live. Two PyBullet
runs at once is how a laptop ends up with a dozen physics servers
open, and the dashboard could not say which row belonged to which.
Also corrects the reading key, which still quoted the friction env's
numbers ("0.90 beats 0.80"). It now says what the fan env's reward
means and that FEWER blocks scores higher - which is why rung 2's 0.950
beats the oracle's 0.900 rather than contradicting it.
Verified end to end: POST /launch?rung=9 is refused, rung=1 starts and
its row appears with a RUNG 1 badge and 1/1 at reward 0.900, and a
second launch while it runs is refused.
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
An oracle run writes no agent transcripts - there is no LLM in the loop to write any - and the replay reel was built entirely from them, so it announced 'this run recorded no videos' with the mp4 sitting on disk beside it. The index row disagreed, offering a watch button that led to that page. Videos are now matched to the reel's rounds directly: whatever a transcript claims keeps its paired card, and the rest are shown with the env verdict read from info.log, which oracle runs do write. The reel's intro no longer promises 'watch it learn' on a run that has nothing to learn. reset_runs.sh takes a pattern, so the plain-domino scratch could be cleared without touching the friction system-ID result.
…ewer The generated goal text told every agent 'when the green domino is pushed ... only the green domino may ever be pushed' regardless of env. In a fan env that is an instruction the agent cannot follow and must not follow: it has no Push skill, and the certificate rejects any episode with no TurnFanOn on the record. The text now branches on the same trigger the evaluator is given, so the two cannot disagree, and the verification clause states what is actually checked for wind (a TurnFanOn step, nothing toppling before it) rather than a counterfactual replay that does not run for a switch press. The viewer's buttons used confirm()/alert(). One 'prevent this page from creating additional dialogs' checkbox -- Chrome offers it on any alert -- makes confirm() return false with no UI at all, so run rung, kill and delete silently did nothing with nowhere to read why. They go through an in-page ask bar and toast now, which cannot be suppressed. The index also opens with what is running: rung, elapsed, how far it has got, and a stop button, with the ladder buttons beside it. The first question this page gets asked was previously answerable only by finding the right row in a table sorted by something else.
…ct it The push/wind choice now comes from _wind_triggered(), used by the goal text, the verification clause, and the evaluator's sanctioned trigger alike. The min-block builder also picks between them: nothing pairs domino_min_block_tasks with a fan today, but the flags are independent and the push wording is unfollowable in a fan env, so the trap is disarmed rather than left set. Checked: pybullet_domino still reads push + replay-probe, pybullet_domino_fan reads fan + wind verification.
Three unlabeled things on this page each made a reader guess. The checkbox column had no header, so the only way to learn what ticking a row does was to tick one and hunt for the button. It is called cmp now, with the tooltip on the header, and the button says 'Compare 2 runs' and stays dead until it can do anything. 'This run recorded no videos ... pass --make_test_videos' was told to every empty reel, including runs that were still going and runs that had been killed - sending a reader to fix a config that was already right. The three causes now read differently. 'This approach does not learn' was decided by counting fits, so a learner killed in its first round got the oracle's line: the interruption described as if it were the method. It reads the approach family instead, and an agent arm with no online learning cycles gets its own line rather than either. Rung 2 on the corrected goal text: 1/1, reward 0.950 - a one-block solve, where the oracle spends two.
I added ask() and never styled it. Appended to <body>, which sits behind a content div of height 100vh - topbar, the bar rendered below the fold: created correctly, invisible, and clicking a rung button looked exactly like the confirm() bug it replaced. It is fixed-positioned now, above everything else on the page. The click also writes into the panel's own status line, which is inside the scrolling content and cannot go off-screen the same way - a control I cannot click-test here should leave more than one trace.
approaches/all.yaml sets option_model_use_gui: True on agent_param_learning, so rung 3 opened a real p.GUI client per option-model env - and the run rebuilds that env repeatedly. Ten 1024x796 windows were open in the one process before it was half done, which is what 'so many pybullet open' was: not many processes, one process with many windows. Rungs 1, 2 and 4 have the flag off, which is why it only showed up on rung 3. The generated rung config forces it off. Watching a rollout is worth a window when a person is watching; a run started from a script or a dashboard button has nobody there. I had twice reported this as 'one process, headless' from the process list alone. The process list cannot see windows - Quartz's CGWindowList can, and did.
PARAM_SPECS bounded wind_force to [0, 1.0] while the env blows at CFG.domino_fan_wind_force = 1.5. The true value sat outside the search box, so the rung was unwinnable by construction: run_20260829_110429 spent three online cycles creeping 0.2251 -> 0.2229 (-1% per cycle) and scored 0/1 with reward -0.05 every time. Mine to own: I wrote the spec with the env at 0.2, then calibrated the wind to 1.5 for the topple onset and never revisited the bound. The comment still claimed the constant was 'equal to the env's domino_fan_wind_force', which it had not been for days. The bound now derives from the env's own setting with room either side, so recalibrating the wind cannot strand the fit again. The init stays a deliberately wrong 0.2 - recovering the value from rollouts is the point of the rung.
Group decision: the just-fit-the-parameters rung is not the interesting setting for this env, so rung 4 - where the agent has to discover and write the wind rule itself - is the milestone. Rung 3 still runs, but says why it is not meaningful here first: the wind acts for ~2 steps before the start block tips, so the whole observation is 'tipped or did not', and 1.5 N and 2.0 N produce identical trajectories. That is a property of a domino, not of the optimizer - pybullet_fan can fit the same parameter because a ball's entire trajectory is wind. Also drops agent_sim_provide_base_sim_source from exp_domino_fan.yaml. It was a no-op that read like a feature: the ball env can turn it on because it split its sim core into pybullet_fan_base.py with no wind law in it, and this env has no such split - the wind sits in fan_component.py beside the fan bodies - so the flag only produced 'declares no base-sim source files; providing none'. The successful friction run had it off too.
probe_wind_identifiability.py measures, in about a minute and with no LLM, whether a change in the wind force produces a change a fitter could see. It does not: below ~0.8 N nothing moves at all (static friction), above it the block tips in ~2 steps, and 1.5 N and 2.0 N give identical trajectories. Post-topple drift is non-monotonic, so it is tumbling rather than signal. That was the one thing worth keeping from the interrupted rung 3 runs, and it is better as a script than as a log directory: it re-answers the question after any change to the wind, the block, or the friction, instead of standing as a fact that was true once. The four interrupted runs are deleted. What is left is a clean ladder: rung 1 at 0.900, rung 2 at 0.950, rung 4 running.
A new env, pybullet_domino_declare: same scene, same wind, same certificate as pybullet_domino_fan, with the switch removed. The robot runs DeclareFinished and the fan starts. Two reasons it earns its own env. A physical button is awkward on a real arm - it must be reachable from every staging pose, the approach must not sweep through the chain just built, and a missed press looks exactly like a press that did not take. And for a learning arm the declaration is the cleaner question: nothing is touched, so there is no contact to credit the wind to, and an agent that finds the relation has found a causal one rather than a mechanical one. How it works. Action.extra_info carries a DECLARE_FINISHED_KEY flag (the field existed and had no users); _step_once stashes the action so _domain_specific_step, which takes no arguments, can read it. The switch body still stores the on/off bit - a fan's is_on is read off its joint - but it is parked two metres outside the workspace, so a press is not merely unnecessary here, it is impossible. The process model says it plainly: DeclareFinished(?robot) has effect FanOn(?fan). An option that does not take the fan as an argument, and an effect on it anyway. Verified: the env builds, the latch turns the fan on and off, the robot is offered DeclareFinished and not TurnFanOn, the goal text and the certificate both name DeclareFinished as the sanctioned trigger, and the wind's residual dynamics are shared with the button env. run_rung.sh --declare N runs the same ladder on it.
'when the fan is switched on, THE WIND TOPPLES THE GREEN DOMINO and the purple domino is toppled' told every agent the causal rule that rung 4 exists to discover. The predicate-invention run then reported 'the wind mechanism is absent from the belief model' as a finding, having been told in its own goal statement what to look for. Both fan envs now state the goal and the rules and stop there: what must end up toppled, what may be rearranged, what may not move before the trigger, and which step is sanctioned to start things. How the trigger reaches the dominoes is the thing being learned, so it is no longer written down. The verification clauses drop the word too - they name the trigger, not the mechanism. Rungs 1 and 2 are unaffected in substance: they are handed the ground-truth model anyway, so the sentence told them nothing they did not already have. The dashboard now badges each row with its env's trigger, BUTTON or DECLARE, so the two ladders read as two series rather than one. Declare oracle: 1/1 at reward 0.900, same as the button oracle - the robot picks, places one blue, declares finished, and the cascade runs.
wait_quiescence_steps was chosen by CFG.env.endswith('_fan'), which
gave pybullet_domino_declare the push threshold of 10. A wind-started
cascade has a lull built into it - the fan cuts out the moment the
start block is down, and the chain then coasts on contact alone - so
10 quiet steps reads as settled and ends the Wait mid-cascade. The
envs are named explicitly now.
Found by the agent in run_20260831_122006, which noticed the config
value, worked out what it implied, and padded its plan with extra
Waits rather than failing. Its rungs 1 and 2 had passed anyway, so
nothing had flagged it.
run_20260831_122006 worked out, from two recorded trajectories and nothing else, that declaring finished switches on a fan whose wind topples the start block. Its own decision record states the inference: the same action sequence flips fan_0.is_on 0->1 one step after the first DeclareFinished, 'and then the green start block tips over ... with NO contact and NO robot nearby'. The harder half is that the trigger leaves no trace. Residual rules never see actions, and a declaration is - to the base sim - indistinguishable from a two-step Wait, so 'has the agent declared?' is hidden state. It inferred the event from a stillness signature it fitted itself: two clean clusters of arm movement separated by a factor of ~34, cut at 0.002, giving a latch that fires exactly one step before the recorded flip. The run is stopped rather than finished: its recorded cascades were truncated by the wait_quiescence_steps bug it had itself pointed out, and the fit dropped one of two trajectories as unexplainable. The result worth keeping is the reasoning, not a reward number.
run_20260831_152630, given the base simulator and a goal text that never mentions wind or fans, wrote the residual dynamics itself. It found the mechanism: 'Exactly one step after fan_0.is_on flips to 1 (t=98->99 in traj 0, t=112->113 in traj 1) the GREEN domino - the one nearest the fan along the fan's facing axis - begins to translate in +x and to roll ... Nothing else moves.' It got the modelling decision right for the right reason: a force through cmds, 'NOT a feature overwrite - the engine must resolve the resulting contacts (that is what produces the cascade)'. And it found something my hand-written GT simulator does not model at all. The wind is OCCLUDED: the staged blue sits on the fan's axis and shows roll exactly 0.000 until the toppling green reaches it, and 'a sub-threshold force would have produced a visible lean', so an upright domino blocks the beam. My version cheats past this by applying wind only to whichever block is painted green. The agent derived the general rule from the data. Two explore episodes reached the goal at reward 0.95 - the same one-block solve rung 2 gets while holding the ground-truth model.
It spent five hours inside cycle 0 and never scored a test. The cost is the rollout fit: the agent declared five physical params (the hand-written GT simulator has two), and grid seeding is a coordinate sweep - params x seed_points x passes evaluations, each rebuilding a PyBullet env and replaying every recorded trajectory, run once per refit. That fit was buying nothing. It reported a delta of exactly 0.0000 on all five params, five times over, which is the identifiability wall probe_wind_identifiability.py measures: above the topple threshold the wind's effect saturates and SSE is flat. Meanwhile the agent's OWN initial values had already produced two real-env episodes at reward 0.95 - the same one-block solve rung 2 gets while holding the ground-truth model. So the fit budget is cut and the cycle count with it. What this rung is for - discovering the mechanism and inventing the predicates to express it - happens in cycle 0 either way.
Rung 4 scored 1/1 at reward 0.950 from the base simulator alone - matching rung 2, which holds the ground-truth model, and beating the oracle's 0.900 by spending one blue where the process planner spends two. Its simulator and predicates are kept here beside the GT ones, because on one point the agent's is better: it models the wind as occluded, an upright domino blocking the beam, proven by noting that a staged blue on the fan axis holds roll exactly 0.000 until the green reaches it and that a sub-threshold force would have shown a lean. The hand-written version applies wind only to whichever block is painted green, which is a role lookup rather than physics. The README now says why rung 3 is skipped rather than leaving a gap in the table that reads as unfinished work.
A rung's result on one seed is an anecdote. The agent's exploration is not deterministic - two runs of the same config took 21 and 83 minutes to reach the same phase - so a repeat on another seed is the cheapest evidence that a number is the method rather than the draw. Runs land in seed<N>/ and appear as separate dashboard rows.
A new env, pybullet_domino_blow: fan, goal patch, staged block, left to right. The robot picks the block, puts it down between the fan and the patch, and declares finished; the fan blows for a bounded number of steps and the block slides. It scores if the block comes to rest inside the patch. This is the task pybullet_domino_fan could not be. There the wind tips a standing domino in about two steps, so every force above threshold produces the same observation and wind_force is unfittable - 1.5 N and 2.0 N give identical trajectories. Here the wind pushes through the block's CENTRE OF MASS, so it slides and the distance travelled is a continuous, monotone function of the force. Same reason pybullet_fan can fit this parameter and the cascade env cannot: what the wind is pushing decides whether its strength leaves a trace. Two details carry the design. The gust is FINITE, because a wind that blows until the horizon puts every sufficient force against the same far wall - the saturation this env exists to avoid. And the goal is a bounded REGION, which answers the question raised in the design meeting: placing the block as close to the fan as possible, the obvious way to avoid learning anything, overshoots the far edge. In place so far: the goal-region component and its InGoal predicate, the env composing it, and the wind wired through the centre of mass. Still to come: the task generator for this layout, the GT models, and the residual simulator whose wind_force is the thing to be fitted. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
Layout, verified: fan x=0.320, goal patch x=0.684 +/-0.04, block staged at x=0.864. Fan left, patch middle, block right - and the block starts DOWNWIND of the patch, so leaving it where it is can never work. The process model is four processes and no grid, because this is a different task rather than the cascade with pieces switched off: PickBlock -> Holding(block) PlaceUpwind -> HandEmpty, ReadyToBlow(block, region) DeclareFinished -> FanOn(fan) WindCarriesToGoal -> InGoal(block, region) [exogenous] The last one is the shape of the whole task: the robot never carries the block into the goal, it arranges the world so the wind will and then says it is done. ReadyToBlow is an ORACLE HELPER predicate, never one of the env's, so an agent approach cannot read the slide distance off the state. Knowing how far this gust carries this block is the content of the task, and the oracle's advantage is that it is told. Calibrated by measurement: at 2.5 N over 60 steps the block slides 11.5 cm, and the response is steep and monotone - 1.8 N -> 5.8 cm, 2.2 -> 8.8, 2.8 -> 14.6, 3.2 -> 19.3. A quadratic fit reproduces those to under a millimetre. Against a 4 cm goal half-width the robot has to know the force to about a sixth, which is the identifiability domino_fan never had. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
…k a task
Michi's design call, and it resolves the tension I was stuck on. The
goal is now the block lying FLAT inside the region, not merely present
there. A robot cannot place a domino on its side - Place sets blocks
upright - so a block lying in the patch can only have been put there
by the wind. Without that word the task had a trivial answer: pick the
block up, put it down in the region, never touch the fan.
Making it flat AND learnable took the right pair of settings, both
measured:
* the gust acts 0.4 of the way up the block, so it tips
* it does NOT stop at the topple, so it keeps pushing the fallen
block and the landing point still encodes the force
Cut the wind at the topple and every force lands in the same place
(8.78 cm at 1.3 N, 8.93 at 2.0) - the saturation that made
domino_fan's wind unfittable. Let it keep pushing and the spread comes
back: over a 30-step gust, 12.0 cm at 1.5 N, 14.4 at 2.0, 16.3 at 2.5,
20.3 at 3.0, 26.4 at 3.5, always flat.
Two orientation bugs found by tracing the block through the plan
rather than reading the code. The generator staged the block at yaw 0,
presenting its narrow edge to a +x gust: it slid and never tipped at
any force or lever I tried. And the place sampler then dropped it back
to yaw 0 even after the generator was fixed - 5.0 cm and roll 0.000,
where a turned block gives 14.4 cm and flat.
Verified in-process end to end: Pick, Place at 0.5491 against a target
of 0.5468, TurnFanOn, and the block ends flat at 0.6925 against a goal
of 0.6841 - 0.83 cm, inside a 4 cm tolerance.
The launcher's oracle run still scores 0/1, so something in the
refinement path differs from the traced plan. Committing the mechanism
while that is chased.
Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
Two bugs, and the second is the one that mattered. The gust budget was refilled by _set_domain_specific_state, which the executor calls far more often than once per episode - it reconstructs state during execution. The counter never reached zero, the fan never switched off, and the block was pushed past the goal. It now refills only when the incoming state has the fan off, which is to say at the start of an episode. And envs/all.yaml pinned domino_blow_wind_steps: 60 while settings.py had been tuned to 30. Force and gust length are calibrated together against the measured slide curve, so pinning one of them decoupled the pair: the launcher blew for twice as long as the oracle's slide model assumed and the block sailed past every time. The override is gone; settings.py governs both. Worth recording how this was found, because reading the code did not find it. Driving the plan by hand succeeded (0.06 cm error) while the launcher scored zero on the same plan, and the difference only became visible after logging where the gust actually left the block. The log said budget=60 where the model assumed 30, and that was the whole mystery. Result: block lands 1.3 cm from the goal centre, flat, inside the region. Tasks solved 1/1, reward 1.000. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
…e that is correct The dashboard had no trigger badge for this env, so its rows read as unclassified beside the two fan ladders. BLOW now sits beside BUTTON and DECLARE, and its tooltip says the thing that distinguishes it: the only one of the three where the wind's strength can be fitted. The parent fan env warned 'no start (green) domino in this task' on every reset of a blow task. There is no green start block here by design - one plain block, no chain - and PyBulletDominoBlowEnv re-aims the wind immediately afterwards, so the line was noise that read like a broken scene. Rung 2 was stopped at 1h15m rather than finished. It had converged: 26 of 41 gust attempts landed the block flat inside the goal, the last several clustered at dx = -0.02 m against a 0.04 m tolerance, so it had found the placement band and was validating rather than searching. No score was captured. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
The rung 4 budget caps were set for domino_fan, where the parameter fit provably could not move (delta 0.0000 on every param, five times) and was costing five hours per cycle. On domino_blow the same fit is load-bearing: the wind's magnitude decides how far the block travels, and a block that travels wrong misses the goal region. A fit starved of sweep passes would fail the task for a reason that is not the agent's. Two passes instead of one, on this env only. A wide posterior is fine here - the rung has to do structural learning AND parameter learning, and it is the pair that matters rather than the precision. Claude-Session: https://claude.ai/code/session_01XWzrba9c7sQ45YV5KCtvMm
The gif is the oracle's 1/1 solve: pick, place upwind, press the switch, and the wind knocks the block flat into the goal region. The simulator is from the first rung-4 attempt on this env. It decomposed the wind into force and torque with the correct cross-product form, and added a creep term for the fallen block sliding on - the behaviour that makes landing distance encode the wind's strength. Four self-named parameters, and a fit that moved wind_force by 20-70% between refits.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three wind-driven domino environments, the ground-truth models that let the process planner solve them, the scripts that run the approach ladder on each, and a rebuilt log viewer.
Environments
pybullet_domino_fanPushis withheld, so the only way to start a cascade is the fan.pybullet_domino_declareDeclareFinishedpybullet_domino_blowThe old
pybullet_domino_fan(with the ball) is renamedpybullet_domino_fan_ball.Results
Reward on the domino tasks is
1 - 0.05 x blues consumedfor a certified solve, so a run using fewer blocks scores higher.Rung 4 on
domino_fanis the headline. Given only the base simulator, a goal text that never mentions wind or a fan, and a predicate vocabulary stripped toHolding, the agent found the mechanism, modelled it as a force through the engine, reinvented six predicates including its ownInFront, and solved a held-out layout with a wider gap than the one it practised on - matching the arm that holds the ground-truth model and beating the oracle by spending one blue where the process planner spends two. Its simulator and predicates are kept indocs/envs/domino_fan/; on one point (wind occlusion by an upright domino) the agent's model is better than the hand-written one.Rung 3 (structure given, parameters fitted) is skipped on
domino_fanon purpose. The wind acts for about two steps before the start block tips, so the whole observation is "tipped or did not" and 1.5 N and 2.0 N produce identical trajectories.scripts/domino_debug/probe_wind_identifiability.pymeasures this in about a minute. That finding is whatdomino_blowexists to fix: there the gust keeps pushing the fallen block, so landing distance is a smooth, monotone function of the force (12 cm at 1.5 N, 26 cm at 3.5 N) and the wind's strength is fittable. Rung 4 ondomino_blowis the next experiment, and it is the first env in the ladder where the parameter fit is load-bearing rather than decoration.Design notes worth knowing
Push,TurnFanOn, orDeclareFinished) is on the record and nothing topples before it; for a switch press or a declaration there is no arm contact to replay, so the counterfactual probe is skipped and the check fails closed if the trigger cannot be confirmed.domino_blow's goal is flat-in-region, not in-region.Placesets dominoes upright, so a block lying in the goal can only have been put there by the wind. Without that word the task has a trivial answer (put the block in the region, never touch the fan)._wind_triggered()drives the goal text, the verification clause, and the evaluator's sanctioned trigger, so they cannot disagree.Tooling
scripts/domino_fan/run_rung.sh [--declare|--blow] Ngenerates a config with every arm parked except the requested one;SEED=Nrepeats a rung. Rung 4 gets a capped fit budget (the uncapped fit spent five hours moving parameters by 0.0000 ondomino_fan).scripts/log_viewer.pyrebuilt: flat run table with rung and trigger badges, a live panel for what is running with a stop button, launch-a-rung buttons, a replay reel grouped by round with the sysID fit shown between rounds, and no native dialogs (a suppressedconfirm()had made the buttons silently do nothing).option_model_use_guiis forced off for scripted runs:agent_param_learninghad it on and opened one PyBullet GUI window per option-model env, ten in one process.Run it