Skip to content

Domino-fan environments: wind-triggered cascades, a declare variant, and a blow-to-goal task - #168

Open
naiimic wants to merge 48 commits into
masterfrom
mn-experiments
Open

Domino-fan environments: wind-triggered cascades, a declare variant, and a blow-to-goal task#168
naiimic wants to merge 48 commits into
masterfrom
mn-experiments

Conversation

@naiimic

@naiimic naiimic commented Sep 3, 2026

Copy link
Copy Markdown

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

env trigger task
pybullet_domino_fan press a switch bridge a green start block to a purple target with blue dominoes; the fan's wind topples the chain. The robot never pushes a domino - Push is withheld, so the only way to start a cascade is the fan.
pybullet_domino_declare DeclareFinished the same task with the button removed. The robot announces it has finished and the fan starts. Built for the real arm, where a physical button has to be reachable from every staging pose; for a learner it is also the cleaner causal question, since nothing is touched.
pybullet_domino_blow press a switch one block, one gust, one goal region. Place the block upwind so the wind knocks it over and it lands flat inside the region.

The old pybullet_domino_fan (with the ball) is renamed pybullet_domino_fan_ball.

Results

Reward on the domino tasks is 1 - 0.05 x blues consumed for a certified solve, so a run using fewer blocks scores higher.

rung handed domino_fan domino_declare domino_blow
1 GT simulator + predicates, process planner plans 1/1 · 0.900 1/1 · 0.900 1/1 · 1.000
2 GT simulator + predicates, the agent plans 1/1 · 0.950 1/1 · 0.950
4 base simulator only 1/1 · 0.950

Rung 4 on domino_fan is the headline. Given only the base simulator, a goal text that never mentions wind or a fan, and a predicate vocabulary stripped to Holding, the agent found the mechanism, modelled it as a force through the engine, reinvented six predicates including its own InFront, 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 in docs/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_fan on 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.py measures this in about a minute. That finding is what domino_blow exists 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 on domino_blow is 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

  • Cascade certificate generalised to bodyless triggers. A solve only counts if the sanctioned trigger (Push, TurnFanOn, or DeclareFinished) 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.
  • Goal text states rules, not mechanism. It used to say "the wind topples the green domino", which handed rung 4 the causal rule it exists to discover. Both fan envs now state what must end up toppled, what may be rearranged, and which step is sanctioned to start things, and stop there.
  • domino_blow's goal is flat-in-region, not in-region. Place sets 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).
  • One place decides the trigger. _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] N generates a config with every arm parked except the requested one; SEED=N repeats a rung. Rung 4 gets a capped fit budget (the uncapped fit spent five hours moving parameters by 0.0000 on domino_fan).
  • scripts/log_viewer.py rebuilt: 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 suppressed confirm() had made the buttons silently do nothing).
  • option_model_use_gui is forced off for scripted runs: agent_param_learning had it on and opened one PyBullet GUI window per option-model env, ten in one process.

Run it

scripts/domino_fan/run_rung.sh 1            # domino_fan oracle, ~2 min, no LLM
scripts/domino_fan/run_rung.sh --blow 1     # domino_blow oracle
scripts/domino_fan/dashboard.sh             # log viewer on :8765
PYTHONHASHSEED=0 python scripts/domino_debug/probe_wind_identifiability.py

naiimic added 30 commits August 25, 2026 14:07
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.
naiimic added 18 commits August 31, 2026 09:20
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant