diff --git a/.gitignore b/.gitignore index 291b12e16..93266140e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,8 @@ predicators/datasets/vlm_input_data_prompts/vision_api/response.txt .idea/ paper/ +.venv/ + +# Generated by scripts/domino_fan/run_rung.sh, one per env+rung. +scripts/configs/predicatorv3/_*_rung*.yaml +scripts/configs/predicatorv3/_rung*.yaml diff --git a/docs/envs/domino_blow/discovered_simulator.py b/docs/envs/domino_blow/discovered_simulator.py new file mode 100644 index 000000000..20c67410e --- /dev/null +++ b/docs/envs/domino_blow/discovered_simulator.py @@ -0,0 +1,179 @@ +# ============================================================================= +# DECISION RECORD - pybullet_domino_blow residual simulator +# ============================================================================= +# WHAT THE BASE SIM ALREADY CARRIES (do NOT re-model): +# * Robot motion / IK / grasping; Pick and Place reproduce the recorded +# action counts and the placed pose exactly (probe: Pick=51 actions, +# Place=27 actions, placed x within 0.0003 of the recording). +# * The switch -> fan.is_on toggle (TurnFanOn flips both switch_0.is_on and +# fan_0.is_on, 24 actions into its 41-action rollout). +# * All rigid-body physics of the domino: the gravity topple in traj 1 +# (the Pick at grasp_z_offset=0.08 knocked the block over) is reproduced +# by the base sim on replay. +# +# WHAT IS MISSING FROM THE BASE SIM = WIND (the whole residual): +# With fan_0.is_on == 1 the base sim leaves the domino perfectly static +# (journal iter0: 8 consecutive Waits, zero motion at 4 different x). +# In the recorded data the fan visibly drives the block. This is an +# exogenous influence the engine knows nothing about => diagnostic-ladder +# case 2 => modelled with `cmds` (physics commands), NOT feature writes. +# +# PHYSICAL_PARAMS: DELIBERATELY NOT DECLARED. +# Open-loop rollout sweep of all five registry parameters (lateral_friction, +# mass, restitution, rolling_friction, spinning_friction), each alone across +# its full box, was FLAT: SSE 62.2-65.3 against a 62.62 baseline (<5% spread, +# far under the 3x consistency bar). The open-loop divergence is entirely +# the missing wind, not mis-set rigid-body physics. Declaring any of them +# would fit noise. +# +# TWO WIND REGIMES (both required; a single channel cannot produce both): +# (A) STANDING / TIPPING block -> force + toppling moment. +# Evidence: traj 0, fan on at step 102, the block goes +# roll 0 -> pi/2 in 6 actions with dx per action rising then falling +# (0.006, 0.013, 0.028, 0.041, 0.019, 0.008) - the signature of a +# rotation about the leading bottom edge, i.e. engine physics driven +# by an external push. A pure COM force reproduces the topple only in +# a knife-edge band (F=0.14 N: no topple at all; F=0.15 N: topple), so +# the wind is modelled as force + an explicit moment (equivalent to a +# force applied above the COM, which is what wind on a tall face is). +# Torque axis = z_hat x wind_dir, so it tips the block DOWNWIND. +# (B) FLAT (already fallen) block -> constant-velocity creep. +# Evidence: in BOTH trajectories a fallen block drifts downwind at a +# PERFECTLY constant 0.001215 m/action (5 significant figures, 22 +# consecutive steps in traj 1, 14 in traj 0, at two different x). +# Constant velocity is incompatible with a Coulomb-friction + constant +# force model (measured in-probe: F=0.2 N -> the flat block does not +# move at all; F=0.45 N -> it runs away accelerating at 0.108 m/action; +# there is no stable creep regime in between). So regime B is a +# kinematic velocity override. It does NOT fight the engine: with the +# small wind force the base sim leaves a flat block motionless. +# +# LATENT (partial observability): +# `blow_steps` per fan - actions elapsed since is_on went high. The task +# statement says the fan blows for a LIMITED time, but no expiry is +# observable in the data (wind still driving the block 22 actions after +# switch-on in traj 1, 20 in traj 0). The counter is therefore carried but +# its cutoff is set beyond anything the data can see; it is NOT a ParamSpec +# because there is no signal to fit it with. Later cycles that observe an +# expiry should turn _BLOW_DURATION_STEPS into a fitted parameter. +# +# RESIDUAL_FEATURES = domino x, z, roll: the pose dimensions the wind moves. +# They are scored against observations but NOT overwritten at test time - +# the engine moves them, which is exactly what the command channel wants. +# +# VALIDATION ANCHOR (real data, used to size placements): +# recorded Place(x=0.570) -> block lands at 0.57275 -> after the full +# TurnFanOn rollout (17 wind actions) it is FLAT at x = 0.7012. +# this model, same plan: x = 0.7042. Topple+creep offset ~= +0.1285. +# +# FIT / VALIDATION (this cycle): +# sim.fit -> joint rollout system-ID, 3 of 4 motion segments explainable +# (the dropped one is traj 1's Pick knocking the block over - +# prolonged robot-object contact, not repeatable under replay). +# rollout SSE 21.14 -> 0.0304 (99.9% reduction). +# sim.residuals(rollout=True) at these inits: wind segments RMS 0.0138 and +# 0.00064, against a no-rule baseline of 0.0883 and 0.0223. +# sim.refine(require_goal=True) on +# Pick[0.06]; Place[0.5287,1.3728,0.55,1.5708]; TurnFanOn[0.1,0.11] +# -> SUCCESS, goal atoms held. +# continuous sim.run of the same plan -> Goal reached: True, block flat at +# x = 0.6575 (region centre 0.6572); 5/5 trials reached the goal. +# Placement margin: every x in [0.511, 0.547] (the whole AtLaunchPose band) +# lands the block inside the region. The one real-data +# observation of a FAILING placement sits at |along| = 0.044, +# 2.4x the learned launch_tol of 0.018 - a comfortable margin, +# not a knife edge. +# +# NOTE for later cycles: no Wait steps in the plan. In this session's probe a +# Wait runs to quiescence (>= 40 actions, and NEVER quiesces while the wind +# creeps the block, hitting the 1000-action option cap), whereas every Wait +# in the recorded real trajectories consumed exactly 1 action. Because that +# discrepancy is unresolved, the plan is built so no Wait is needed: the +# topple and the whole useful slide happen inside the TurnFanOn rollout +# itself (the switch flips 24 actions into its 41-action rollout, leaving 17 +# actions of wind). +# ============================================================================= +import numpy as np + +# Half-width of the "lying flat" band around |roll| = pi/2. Regime switch, +# not a fitted quantity: the recorded roll settles to 1.5708 +- 0.0001 within +# one action of touching down, so anything in [0.002, 0.02] selects the same +# steps. Kept a constant to keep the fitted parameter set minimal. +_FLAT_TOL = 0.01 + +# Actions of wind after switch-on before the fan cuts out. NOT identified - +# see DECISION RECORD. Set beyond the episode horizon so it is a no-op here. +_BLOW_DURATION_STEPS = 400 + + +def _wind_dir(observation, fan): + """Unit wind direction in the world plane, from the fan's own frame.""" + rot = observation.get(fan, "rot") + ux, uy = float(np.cos(rot)), float(np.sin(rot)) + # `facing_side` selects which side of its housing the fan blows from. + if observation.get(fan, "facing_side") > 0.5: + ux, uy = -ux, -uy + return ux, uy + + +def wind_rule(observation, latent, history, updates, params, cmds): + """Wind from every running fan, on every free domino.""" + fans = [o for o in observation.data if o.type.name == "fan"] + dominoes = [o for o in observation.data if o.type.name == "domino"] + for fan in fans: + fl = latent.setdefault(fan.name, {}) + if observation.get(fan, "is_on") <= 0.5: + fl["blow_steps"] = 0 + continue + elapsed = fl.get("blow_steps", 0) + fl["blow_steps"] = elapsed + 1 + if elapsed >= _BLOW_DURATION_STEPS: + continue + ux, uy = _wind_dir(observation, fan) + force = params["wind_force"] + torque = params["topple_torque"] + creep = params["creep_speed"] + for dom in dominoes: + if observation.get(dom, "is_held") > 0.5: + continue + roll = observation.get(dom, "roll") + if abs(abs(roll) - 0.5 * np.pi) < _FLAT_TOL: + # Regime B: already down - steady downwind creep. + cmds.set_velocity(dom, linear=(creep * ux, creep * uy, 0.0)) + else: + # Regime A: standing (or mid-fall) - push + topple moment. + cmds.apply_force(dom, (force * ux, force * uy, 0.0)) + cmds.apply_torque(dom, (-torque * uy, torque * ux, 0.0)) + return updates + + +LATENT_INIT = {} + +RESIDUAL_RULES = [wind_rule] + +PARAM_SPECS = [ + # Net downwind force on a standing/tipping domino (N). + ParamSpec("wind_force", 0.1504, lo=0.03, hi=0.35), + # Toppling moment about (z_hat x wind_dir) (N*m); the part of the wind + # load that acts above the COM on the tall exposed face. + ParamSpec("topple_torque", 0.0235, lo=0.004, hi=0.08), + # Steady creep speed of a fallen domino (m/s in engine units). + ParamSpec("creep_speed", 0.0260, lo=0.004, hi=0.08), + # --- predicate-only geometry (no SSE signal; see DECISION RECORD) --- + # Downwind displacement from the standing launch pose to where the block + # ends up at the end of a TurnFanOn rollout (topple arc + creep). + # Measured: 0.7012 - 0.57275 = 0.1285 in the real data. + ParamSpec("launch_offset", 0.1285, lo=0.05, hi=0.25), + # Half-width of the acceptable launch band along the wind axis. + ParamSpec("launch_tol", 0.018, lo=0.002, hi=0.05), + # Half-width of the acceptable lateral (cross-wind) launch band. + ParamSpec("launch_lat_tol", 0.06, lo=0.01, hi=0.1), + # Max |roll| still counted as "standing". + ParamSpec("upright_roll_max", 0.09, lo=0.02, hi=0.3), + # Min |roll| counted as "knocked flat". + ParamSpec("flat_roll_min", 1.40, lo=0.5, hi=1.55), + # Max deviation of the broad face from square-on to the wind (rad). + ParamSpec("face_to_wind_tol", 0.25, lo=0.05, hi=0.7), +] + +RESIDUAL_FEATURES = {"domino": ["x", "z", "roll"]} diff --git a/docs/envs/domino_blow/oracle_solve.gif b/docs/envs/domino_blow/oracle_solve.gif new file mode 100644 index 000000000..8ae2008fd Binary files /dev/null and b/docs/envs/domino_blow/oracle_solve.gif differ diff --git a/docs/envs/domino_declare/discovered_simulator.py b/docs/envs/domino_declare/discovered_simulator.py new file mode 100644 index 000000000..3dced0296 --- /dev/null +++ b/docs/envs/domino_declare/discovered_simulator.py @@ -0,0 +1,238 @@ +# ===================================================================== +# DECISION RECORD - pybullet_domino_declare (cycle 3, learn) +# ===================================================================== +# WHAT THE BASE SIM ALREADY DOES (verified, do not re-implement): +# * All rigid-body motion: arm IK, grasping, transport, release, and +# the *entire* domino-on-domino cascade. Cycle-1/2 probes showed +# that seeding `mods={'domino_0': {'roll': 0.3}}` makes the base sim +# topple green -> blue -> purple all by itself. Contact physics is +# present and good. +# +# WHAT IS MISSING (measured this cycle, probe with an empty rule set): +# Running `DeclareFinished` + `Wait` from task-0 init in the base sim +# leaves fan_0.is_on = 0.0 and every domino untouched. In the RECORDED +# env trajectories the exact same action sequence flips +# fan_0.is_on 0->1 and switch_0.is_on 0->1 one step after the first +# DeclareFinished action (traj0 t=74, traj1 t=80) and then the green +# start block tips over: roll 0 -> .047 -> .128 -> .193 -> .294 (and +# x drifts +x, toward the target) with NO contact and NO robot nearby. +# => An exogenous influence the engine knows nothing about: the +# declaration switches a FAN on and its wind topples the start +# block. Diagnostic ladder case 2 -> model with force `cmds`, +# gated on an observable condition; plus the device-state flip on +# the feature-update channel. +# +# THE TRIGGER IS NOT DIRECTLY OBSERVABLE (partial observability): +# Rules never see actions, and `DeclareFinished` is - to the base sim +# - indistinguishable from a 2-step Wait (probe diff: only sub-mm +# arm/finger drift). So "has the agent declared?" is a HIDDEN state. +# It is inferred from an observable signature and carried in `latent` +# (Pattern A: counter + threshold). +# Signature = "robot holds nothing AND the arm has stopped moving". +# Threshold-fitting protocol, traj0, max |delta| over robot +# (x,y,z,roll,tilt,wrist), buckets over the is_held==0 steps: +# arm-moving bucket (t=1..20, 60..72): min 0.0086, typ 0.02-1.0 +# arm-still bucket (t=73..78, declare/wait): max 0.000256 +# -> two clean clusters separated by a factor of ~34. Cut at 0.002. +# Held steps (t=21..59) are excluded by the is_held gate, which is +# what makes the slow 0.0007/step transport phase harmless. +# With `declare_delay`=1 the latch fires on state[73] and the fan +# reads on at state[74] - exactly the recorded flip step. +# +# WHAT THE RULES OWN (RESIDUAL_FEATURES): +# fan.is_on, switch.is_on - written directly (base sim never sets +# them; they are the device readout of +# the latent `declared` flag). +# domino.x/z/roll - moved by the wind FORCE through the +# engine, so they are SCORED but not +# overwritten. Listing them is what gives +# the wind magnitude an SSE signal. +# (domino.y / yaw deliberately omitted: the wind is axial, those +# carry only base-sim replay noise from the Pick/Place phase.) +# +# WIND MODEL - simplest hypothesis that fits: +# Constant world-frame force along the fan axis (cos rot, sin rot), +# applied every step the fan is on, to ONE domino: the "start block", +# latched at declare time as the nearest not-yet-toppled domino +# inside a narrow beam in front of the fan (that is domino_0, the +# green one, at lateral offset 0.000; the blue sits 0.017-0.025 off +# axis and never moves under wind in either recorded trajectory). +# The force stops once the start block is down - matching the env +# note in reference/options.py that "the fan cuts out the moment the +# start block is down (a fallen domino is out of the airstream)". +# Everything after that - green hitting blue hitting purple - is left +# entirely to the base sim's contact physics. +# +# PHYSICAL_PARAMS: not declared (see the sweep in the session log). +# The base sim reproduces the recorded rigid-body motion; the defect +# was a missing mechanism, not mis-set physics. +# ===================================================================== + +# --- object/feature helpers ------------------------------------------ + +_ARM_FEATS = ("x", "y", "z", "roll", "tilt", "wrist") + + +def _by_type(state, tname): + return [o for o in state.data if o.type.name == tname] + + +def _prev_obs(observation, history): + """Most recent *earlier* observation, or None at the first step.""" + if not history: + return None + for entry in reversed(history): + st = entry[0] if isinstance(entry, (tuple, list)) else entry + if st is not observation: + return st + return None + + +def _is_toppled(state, dom, params): + return abs(state.get(dom, "roll")) > params["topple_roll"] + + +def _fan_axis(state, fan): + rot = state.get(fan, "rot") + return float(np.cos(rot)), float(np.sin(rot)) + + +def _fan_anchor(state, fan, params): + """Outlet point of the fan: recorded origin + a LOCAL-frame offset + rotated by the fan's `rot`. Shared with predicates.InAirstream.""" + ux, uy = _fan_axis(state, fan) + ox = params["fan_local_dx"] * ux - params["fan_local_dy"] * uy + oy = params["fan_local_dx"] * uy + params["fan_local_dy"] * ux + return state.get(fan, "x") + ox, state.get(fan, "y") + oy + + +def _beam_pick(state, fan, dominoes, params): + """Nearest upright, un-held domino inside the fan's beam.""" + ux, uy = _fan_axis(state, fan) + fx, fy = _fan_anchor(state, fan, params) + best, best_along = None, None + for d in dominoes: + if state.get(d, "is_held") > 0.5: + continue + if _is_toppled(state, d, params): + continue + dx = state.get(d, "x") - fx + dy = state.get(d, "y") - fy + along = dx * ux + dy * uy + lateral = -dx * uy + dy * ux + if along <= 0.0: + continue + if abs(lateral) > params["beam_halfwidth"]: + continue + if best_along is None or along < best_along: + best, best_along = d, along + return best + + +# --- rule 1: infer the hidden "declared" flag, drive the devices ----- + + +def declare_rule(observation, latent, history, updates, params): + """Latent Pattern A: count steps of 'nothing held + arm stopped'; + latch `declared` once the count passes the learned delay, and read + the latch out onto every fan / switch `is_on`.""" + robots = _by_type(observation, "robot") + dominoes = _by_type(observation, "domino") + + held = any(observation.get(d, "is_held") > 0.5 for d in dominoes) + prev = _prev_obs(observation, history) + still = False + if prev is not None and not held: + try: + drift = max( + abs(observation.get(r, f) - prev.get(r, f)) + for r in robots for f in _ARM_FEATS) + still = drift < params["still_eps"] + except Exception: # pylint: disable=broad-except + still = False + + latent["still"] = (latent.get("still", 0.0) + 1.0) if still else 0.0 + if latent.get("declared", 0.0) < 0.5: + if latent["still"] >= params["declare_delay"]: + latent["declared"] = 1.0 + + # Also honour a directly observed device flip (recorded env data + # sets is_on itself); never un-latch. + for fan in _by_type(observation, "fan"): + if observation.get(fan, "is_on") > 0.5: + latent["declared"] = 1.0 + + on = 1.0 if latent.get("declared", 0.0) > 0.5 else 0.0 + for fan in _by_type(observation, "fan"): + updates.setdefault(fan, {})["is_on"] = on + for sw in _by_type(observation, "switch"): + updates.setdefault(sw, {})["is_on"] = on + return updates + + +# --- rule 2: the wind itself (physics-command channel) --------------- + + +def wind_rule(observation, latent, history, updates, params, cmds): + """Constant axial force on the latched start block while the fan is + on and that block is still standing.""" + del history + if latent.get("declared", 0.0) < 0.5: + return updates + dominoes = _by_type(observation, "domino") + if not dominoes: + return updates + by_name = {d.name: d for d in dominoes} + + for fan in _by_type(observation, "fan"): + key = "start_" + fan.name + target = by_name.get(latent.get(key)) + if target is None: + target = _beam_pick(observation, fan, dominoes, params) + if target is None: + continue + latent[key] = target.name + # The fan cuts out once the start block is down / out of the + # airstream; the chain then coasts on contact alone. + if _is_toppled(observation, target, params): + continue + if observation.get(target, "is_held") > 0.5: + continue + ux, uy = _fan_axis(observation, fan) + f = params["wind_force"] + cmds.apply_force(target, (ux * f, uy * f, 0.0)) + return updates + + +RESIDUAL_RULES = [declare_rule, wind_rule] + +PARAM_SPECS = [ + # "arm has stopped" cut: still bucket <= 2.6e-4, moving bucket + # >= 8.6e-3 (traj0, is_held==0 steps). + ParamSpec("still_eps", 0.002, lo=0.0005, hi=0.006), + # Steps of stillness before the declaration is inferred. + ParamSpec("declare_delay", 1.0, lo=1.0, hi=6.0), + # Newtons of wind on the start block. + ParamSpec("wind_force", 0.20, lo=0.0, hi=1.5), + # Half-width of the airstream, metres, about the fan axis. + ParamSpec("beam_halfwidth", 0.04, lo=0.005, hi=0.15), + # |roll| beyond which a domino counts as down / out of the stream. + ParamSpec("topple_roll", 0.60, lo=0.2, hi=1.4), + # Fan outlet offset in the fan's LOCAL frame (shared with the + # InAirstream predicate). Init 0: the render shows the housing + # centred on its recorded origin, so the fit is free to confirm it. + ParamSpec("fan_local_dx", 0.0, lo=-0.2, hi=0.2), + ParamSpec("fan_local_dy", 0.0, lo=-0.2, hi=0.2), + # Predicate-only (no SSE signal -> stays at init_value): the + # centre-to-centre gap a toppling domino can still bridge. + # Cycle-1/2 reach sweeps: 0.130 m links propagate, 0.140 m do not. + ParamSpec("chain_gap_max", 0.125, lo=0.05, hi=0.20), +] + +LATENT_INIT = {"declared": 0.0, "still": 0.0} + +RESIDUAL_FEATURES = { + "fan": ["is_on"], + "switch": ["is_on"], + "domino": ["x", "z", "roll"], +} diff --git a/docs/envs/domino_fan/README.md b/docs/envs/domino_fan/README.md new file mode 100644 index 000000000..8164aa241 --- /dev/null +++ b/docs/envs/domino_fan/README.md @@ -0,0 +1,135 @@ +# domino-fan + +The robot bridges a start block to a target with blue dominoes, presses +a switch, and the **wind** topples the chain. It never pushes a domino +itself — `Push` is withheld in this env, option and process both, so the +only way to start a cascade is the fan. + +Run it: + +```bash +scripts/domino_fan/run_rung.sh 1 # or 2, 4 +scripts/domino_fan/run_rung.sh --declare 1 # the button-free variant +``` + +## Results + +| rung | what it is handed | result | +|-----:|-------------------|--------| +| 1 | ground-truth simulator and predicates; the process planner plans | 1/1 · **0.900** | +| 2 | ground-truth simulator and predicates; the AGENT plans | 1/1 · **0.950** | +| 3 | structure only, parameters fitted from data | *skipped, see below* | +| 4 | **the base simulator alone** | 1/1 · **0.950** | + +Reward is `1 - 0.05 x blues consumed`, so 0.950 is a one-block solve +and 0.900 a two-block one. Rung 4 matches rung 2 and beats the oracle +while being handed none of the model. + +**Rung 3 is skipped on purpose.** Fitting the wind's magnitude is not +a learnable problem here: 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. Measure it yourself +with `scripts/domino_debug/probe_wind_identifiability.py`. Contrast +`pybullet_fan`, which fits the same parameter happily because a ball's +entire trajectory is wind. What a wind parameter is pushing decides +whether it can be fitted. + +## What rung 4 discovered + +`discovered_simulator.py` and `discovered_predicates.py` in this +directory are the agent's own, copied from run_20260831_202652. Its +goal text never contained the words "wind" or "fan", and its predicate +vocabulary was stripped to `Holding` alone. + +It found the mechanism by comparing its model against reality: "exactly +one step after `fan_0.is_on` flips to 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 modelled it as a force through +the engine rather than a feature overwrite, because "the engine must +resolve the resulting contacts (that is what produces the cascade)". + +It also found something the hand-written ground-truth simulator does +not model. The wind is **occluded**: a staged blue on the fan's axis +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. `domino_fan/gt_simulator.py` sidesteps this by +applying wind only to whichever block is painted green - a role lookup, +not physics. + +And it rebuilt the vocabulary it had been denied: `_toppled`, +`_upright`, `_fan_on`, `_fan_off`, `_switch_on`, and `_bridges_gap` - +its own `InFront`, named for what it does. + +The test layout has a 0.294 m gap against the 0.196 m it practised on, +so the model generalized rather than memorizing one scene. + +## oracle_solve.gif + +![oracle](oracle_solve.gif) + +Rung 1 — `oracle_process_planning`, ground-truth simulator and +predicates. **1/1 at reward 0.900**, ~2 minutes, no LLM. Plan: + +``` +PickDomino → PlaceDomino → PickDomino → PlaceDomino +→ TurnFanOn → Wait +``` + +This is the ceiling the learning rungs are measured against, not a +result in itself. + +## rung2_agent_planner.gif + +![rung2](rung2_agent_planner.gif) + +Rung 2 — `agent_model_based_planning`, same ground-truth simulator and +predicates, but the **agent** writes the plan instead of the process +planner. **1/1 at reward 0.950**, early-stopped at cycle 1. + +Higher than the oracle, and not by luck: the oracle's grid-based +planner bridges with TWO blues, while the agent found that ONE +suffices, since a domino topples further than one `pos_gap`. In the +video the second blue is still sitting untouched at its staging spot. + +The agent also probed the limits and diagnosed them correctly - x=0.700 +and x=0.570 both fail on **gripper clearance**, not physics ("the +opening fingers need ~0.044 m of side clearance"), symmetric about both +neighbours. Its notes are in the run's sandbox as `notes_domino_fan.md`. + +## wind_cascade.gif + +![wind](wind_cascade.gif) + +The mechanism in isolation: a chain laid by hand at `pos_gap`, fan +switched on, **no robot**. 4/4 dominoes topple (final rolls +`[82, 81, 83, 90]` degrees). Useful for showing that the wind physics +work independently of whether the manipulation does. + +## Reading the reward + +`reward = 1[goal reached via a certified wind cascade] − 0.05 × blues used` + +The certificate ([`cascade_certificate.py`](../../../predicators/envs/pybullet_domino/cascade_certificate.py)) +rejects an episode where the arm knocks the target over rather than the +wind — `TurnFanOn` is the sanctioned trigger here, in place of `Push`. + +Ceilings differ between task sets, so do not read a drop as a +regression: + +| task set | bridge | blues (grid plan) | reward | +|----------|---------|-------------------|--------| +| train | 0.196 m | 1 | 0.95 | +| test | 0.294 m | 2 | 0.900 | + +"Blues needed" is what the ORACLE's grid planner uses, not a floor: rung +2 solved the test task with one blue for 0.950. Treat 0.900 as the +oracle's score, not the task's ceiling. + +## Results so far + +| rung | arm | what it must supply | result | +|------|-----|---------------------|--------| +| 1 | `oracle` | nothing (GT everything) | **1/1, 0.900** | +| 2 | `agent_model_based_planning` | the plan + its continuous params | **1/1, 0.950** | +| 3 | `agent_param_learning` | the wind's parameters | not run | +| 4 | `agent_po_predicate_invention_al` | the wind's code, its params, and predicates | partial | diff --git a/docs/envs/domino_fan/discovered_predicates.py b/docs/envs/domino_fan/discovered_predicates.py new file mode 100644 index 000000000..625c6c359 --- /dev/null +++ b/docs/envs/domino_fan/discovered_predicates.py @@ -0,0 +1,119 @@ +"""Learned predicates for pybullet_domino_fan. + +All numeric cutoffs are shared with simulator.py's PARAM_SPECS via the +pre-injected `params` view, so a refit moves rule and predicate together. + +Evidence for each cutoff (belief-sim sweeps at the demo layout, which +reproduces both recorded cascades to <= 1 cm on every body): + * bridge_max_gap 0.13 : centre-to-centre spacings 0.06 / 0.08 / 0.10 / + 0.12 / 0.14 all relay the cascade to the purple target; 0.16 stalls + (the struck domino only reaches roll 0.12). 0.13 sits inside the + working bucket with a clear margin to the 0.16 failure. + * bridge_max_lateral 0.035 : lateral offsets of 0.00 and 0.06 still + relay, 0.12 does not. 0.035 is deliberately tighter than the + empirical boundary (tighten, never widen). + * bridge_yaw_align 0.5 : |a.u| of 0.45 and 0.70 relay, 0.92 and 1.00 + (edge-on bridge) stall the chain and even jam the green start block + at roll ~0.5. + * toppled_roll 0.7 : recorded rolls are ~0 while standing and settle at + 1.44-1.57 once down; nothing ever rests between 0.2 and 1.4. +Recorded domino poses are BODY CENTRES (z = 0.475 = table 0.40 + half of +the 0.15 m height), so domino-domino spacing needs no anchor offset - the +recorded origin is the functional point. The fan, by contrast, is only +ever used through its `rot`-derived facing direction, never its origin +distance, so no fan anchor offset is needed either. +""" + + +_DEFAULTS = { + "bridge_max_gap": 0.13, + "bridge_max_lateral": 0.035, + "bridge_yaw_align": 0.5, + "toppled_roll": 0.7, + "upright_roll": 0.2, +} + + +def _p(name): + """Read a shared simulator ParamSpec, falling back to its declared + init_value when no fit has populated the params view yet (the + predicate-quality loader can run before the first fit).""" + try: + return float(params[name]) + except Exception: + return _DEFAULTS[name] + + +def _roll(s, d): + return abs(float(s.get(d, "roll"))) + + +def _toppled(s, objs, latent=None): + return _roll(s, objs[0]) >= _p("toppled_roll") + + +def _upright(s, objs, latent=None): + return _roll(s, objs[0]) < _p("upright_roll") + + +def _fan_on(s, objs, latent=None): + return float(s.get(objs[0], "is_on")) > 0.5 + + +def _fan_off(s, objs, latent=None): + return float(s.get(objs[0], "is_on")) <= 0.5 + + +def _switch_on(s, objs, latent=None): + return float(s.get(objs[0], "is_on")) > 0.5 + + +def _xy(s, o): + return np.array([float(s.get(o, "x")), float(s.get(o, "y"))]) + + +def _bridges_gap(s, objs, latent=None): + """`bridge` stands between `src` and `tgt` close enough, and squarely + enough, that a topple travelling src -> tgt relays through it.""" + bridge, src, tgt = objs + if bridge is src or bridge is tgt or src is tgt: + return False + if float(s.get(bridge, "is_held")) > 0.5: + return False + p_b, p_s, p_t = _xy(s, bridge), _xy(s, src), _xy(s, tgt) + span = p_t - p_s + span_len = float(np.linalg.norm(span)) + if span_len < 1e-6: + return False + u = span / span_len + perp = np.array([-u[1], u[0]]) + along = float((p_b - p_s) @ u) + # must sit strictly between the two, and split the span into two + # hops each short enough to carry the topple + if along <= 0.0 or along >= span_len: + return False + if float(np.linalg.norm(p_b - p_s)) > _p("bridge_max_gap"): + return False + if float(np.linalg.norm(p_t - p_b)) > _p("bridge_max_gap"): + return False + if abs(float((p_b - p_s) @ perp)) > _p("bridge_max_lateral"): + return False + # face the oncoming domino squarely: the width axis (cos yaw, sin yaw) + # must be near-perpendicular to the cascade direction + yaw = float(s.get(bridge, "yaw")) + axis = np.array([np.cos(yaw), np.sin(yaw)]) + if abs(float(axis @ u)) > _p("bridge_yaw_align"): + return False + return True + + +LEARNED_PREDICATES = [ + Predicate("DomToppled", [domino_type], _toppled), + Predicate("DomUpright", [domino_type], _upright), + Predicate("FanRunning", [fan_type], _fan_on), + Predicate("FanIdle", [fan_type], _fan_off), + Predicate("SwitchPressed", [switch_type], _switch_on), + Predicate("DomBridges", [domino_type, domino_type, domino_type], + _bridges_gap), +] + diff --git a/docs/envs/domino_fan/discovered_simulator.py b/docs/envs/domino_fan/discovered_simulator.py new file mode 100644 index 000000000..665e10b4a --- /dev/null +++ b/docs/envs/domino_fan/discovered_simulator.py @@ -0,0 +1,180 @@ +"""Residual dynamics for pybullet_domino_fan. + +DECISION RECORD (cycle 3) +================================= +Observations (trajectories 0 and 1, both solved, reward 0.95): + * Both episodes: Pick blue domino_1 -> Place it between green (domino_0, + x=0.540) and purple target (domino_2, x=0.736) -> TurnFanOn -> cascade. + * The instant `fan_0.is_on` flips 0 -> 1 (traj0 step 99, traj1 step 88) the + GREEN start block begins to tip on the very next recorded state + (roll 0 -> 0.05 -> 0.13 -> 0.19 -> 0.30 -> 0.51 -> 0.89 -> 1.26 -> 1.57) + while sliding +x by ~6 cm. No robot contact is involved. + * The blue (x=0.638 / 0.662) and the purple (x=0.736) do NOT move while the + green is still upright; they only start rolling one step AFTER the body in + front of them has tipped into them (blue at green.roll~0.9, purple at + blue.roll~0.4). So the chain itself is ordinary contact physics. + * Previous-cycle journal: with fan_0.is_on = 1 and several Wait options, + the base sim moves NOTHING (green roll stays 1e-4). So the wind is an + exogenous influence the engine knows nothing about => diagnostic-ladder + case 2 => model it with physics COMMANDS (constant force gated on is_on), + not with feature overwrites and not with PHYSICAL_PARAMS. + +Modeling choices: + * ONE rule, `fan_wind`. While a fan is on it emits a constant world-frame + force along the fan's facing direction (cos(rot), sin(rot)) on the closest + UPRIGHT, un-held domino that lies inside a learned downwind corridor + (half-width + range). Only the closest one: in the data the second and + third dominoes are shielded by the first and never move under wind alone, + and options.py documents that "the fan cuts out the moment the start block + falls". Modelling only the nearest exposed body is both consistent with + the data and conservative for planning - the planner must bridge the gap + with blue dominoes rather than hoping the wind reaches across it. + * Everything downstream of the first tip (domino-domino collisions, sliding, + settling) is left to the base sim's rigid-body engine. No rule touches it. + * No latent state is needed: the wind's driver (`fan.is_on`) is observable + and the response is instantaneous (<1 step), so LATENT_INIT is empty. + * PHYSICAL_PARAMS: NOT declared. Decided from open-loop evidence, not from + small per-step residuals. With the wind rule riding, + `sim.residuals(rollout=True, sweep_params='all')` gives baseline SSE 2.96 + and every swept alternative is worse or flat: + lateral_friction 0.01/0.038/0.14/0.53/2.0 -> 73.9/69.6/68.3/1.81/69.3 + mass 0.005..1 -> 68.3/18.1/7.2/68.7/68.7 + restitution 0..0.9 -> 2.95/2.95/2.95/2.98/3.58 + rolling_friction 0/0.025/0.05/0.075/0.1 -> 3.00/70.6/70.6/70.6/65.8 + spinning_friction 0.01..2 -> 1.79/2.97/2.97/1.78/1.86 + Nothing clears the 3x consistency bar over the registry baseline (the best, + lateral_friction 0.53, is 1.6x and is essentially the baseline value), so + the shipped rigid-body physics is already calibrated for this data and + declaring a parameter would fit noise. + +Calibration + validation (belief probe, task 0): + * wind_force calibrated by replaying each recorded pre-fan scene + (`sim.reset(mods=...)` staging the blue at its recorded pre-fan pose) and + matching the settled poses. At 0.15-0.18 N every body lands within 1 cm + of the recording: traj0 (blue @0.6375) predicted g/b/p x = 0.598/0.722/ + 0.836 vs recorded 0.586/0.722/0.835, rolls 1.45/1.44/1.57 vs 1.46/1.44/ + 1.57; traj1 (blue @0.662) predicted 0.571/0.769/0.827 vs recorded + 0.571/0.767/0.827. 1.2 N (the first guess) shot the green 30 cm downrange + and produced a fantasy solve with no bridge at all - the calibration is + what makes the model refuse that. + * Sanity check in the other direction: with NO bridge the wind topples only + the green (it ends at x=0.636) and the purple stays upright -> goal not + reached. The model therefore reproduces the env's actual requirement that + a blue must bridge the 0.196 m green->purple gap, and 0.95 (one blue + consumed) is the best score physically available on this task. + * Cascade-geometry sweeps used to fit the predicate cutoffs in + predicates.py: centre spacings 0.06-0.14 relay, 0.16 stalls; lateral + offsets 0.00-0.06 relay, 0.12 does not; bridge yaw within ~+-0.77 rad of + square relays, edge-on (yaw 0.0/0.4) stalls and even jams the green. All + eight corners of the region DomBridges accepts were re-simulated and every + one topples the purple target. + * Known harness caveat this cycle: `evaluate_predicate_quality` loads and + scores predicates.py fine, but the run_python BeliefProbe still parses + sketches against the Holding-only allowlist, so learned-predicate subgoal + annotations are silently dropped there and `sim.refine` cannot be used as + the goal gate (it then places the blue anywhere and misses the goal). + Validation was therefore done with fully-parameterised `sim.run` plans + plus the corner sweeps above; the reference plan below runs end-to-end + with `Goal reached: True`. + +Reference plan (validated in the calibrated model, matches both recordings): + Pick(robot, domino_1)[0.05] + Place(robot)[0.638, 1.38653, 0.57, 1.5708] + TurnFanOn(robot, fan_0)[0.1, 0.11] + Wait(robot) x2-3 +""" + +from typing import Any, Dict, List + +UPRIGHT_ROLL = 0.25 # |roll| below this counts as still standing + +# Structural (NOT fitted) wind-corridor constants. The data contains exactly +# one body in the wind (the green start block, 0.22 m downwind, dead on the +# fan axis), so neither the corridor's length nor its width is identifiable - +# sim.fit reported contraction ~1 for both when they were ParamSpecs. Rather +# than let MCMC wander them to an arbitrary value, they are pinned to +# deliberately CONSERVATIVE values: just past the green block and about one +# domino width. Erring short is safe (the planner then has to bridge the gap +# with blue dominoes, which is what the data shows works); erring long would +# invent a wind that topples the target directly and produce plans that fail +# for real. +WIND_RANGE = 0.26 # m downwind reach of the jet +WIND_HALF_WIDTH = 0.09 # m half-width of the jet corridor + + +def _fan_dir(state, fan): + rot = float(state.get(fan, "rot")) + return np.array([np.cos(rot), np.sin(rot)]) + + +def fan_wind(observation, latent, history, updates, params, cmds): + """Constant wind force from every switched-on fan on the first + exposed upright domino downwind of it.""" + fans = [o for o in observation.data if o.type.name == "fan"] + dominoes = [o for o in observation.data if o.type.name == "domino"] + if not fans or not dominoes: + return updates + for fan in fans: + if float(observation.get(fan, "is_on")) <= 0.5: + continue + d = _fan_dir(observation, fan) + perp = np.array([-d[1], d[0]]) + origin = np.array([float(observation.get(fan, "x")), + float(observation.get(fan, "y"))]) + best, best_along = None, None + for dom in dominoes: + if float(observation.get(dom, "is_held")) > 0.5: + continue + if abs(float(observation.get(dom, "roll"))) > UPRIGHT_ROLL: + continue # already toppling: no longer a sail + rel = np.array([float(observation.get(dom, "x")), + float(observation.get(dom, "y"))]) - origin + along = float(rel @ d) + lateral = abs(float(rel @ perp)) + if along <= 0.0 or along > WIND_RANGE: + continue + if lateral > WIND_HALF_WIDTH: + continue + if best_along is None or along < best_along: + best, best_along = dom, along + if best is not None: + f = params["wind_force"] + cmds.apply_force(best, (float(f * d[0]), float(f * d[1]), 0.0)) + return updates + + +RESIDUAL_RULES = [fan_wind] + +PARAM_SPECS = [ + # Newtons, applied at the body COM (0.075 m above the table) every physics + # substep while the fan is on. Static topple threshold for a 0.1 kg, + # 0.15 x 0.014 m domino is ~0.09 N, so the box brackets "just tips it" to + # "shoves it hard"; 0.15 already reproduced both recorded cascades to + # ~1 cm on every body (see decision record). + ParamSpec("wind_force", 0.175, lo=0.05, hi=0.45), + # Predicate-only geometry (no SSE signal - it never enters a rule, so it + # stays at init_value): the largest centre-to-centre spacing at which a + # toppling domino still reliably knocks over the next one. Bodies are + # 0.15 m tall and ~0.014 m deep, so contact is geometrically possible out + # to ~0.16 m; 0.13 keeps the strike high on the neighbour's face. + ParamSpec("bridge_max_gap", 0.13, lo=0.05, hi=0.17), + # Half-width of the corridor a bridging domino must sit in, measured + # perpendicular to the source->target line. + ParamSpec("bridge_max_lateral", 0.035, lo=0.005, hi=0.08), + # |roll| above which a domino counts as toppled / below which it counts + # as still standing. + # |a . u| cap, where a = (cos yaw, sin yaw) is the domino's width axis + # and u the cascade direction: how squarely the bridge domino must face + # the oncoming domino. Probe sweep at the demo layout: yaw giving + # |a.u| = 0.45 / 0.70 relay fine, 0.92 / 1.00 (edge-on) stall the chain, + # so 0.5 sits well inside the working bucket. + ParamSpec("bridge_yaw_align", 0.5, lo=0.1, hi=0.85), + ParamSpec("toppled_roll", 0.7, lo=0.3, hi=1.4), + ParamSpec("upright_roll", 0.2, lo=0.05, hi=0.4), +] + +LATENT_INIT: Dict[str, Any] = {} + +# The wind moves the first domino's pose; the engine carries it and the rest +# of the chain. These are scored against observations, never overwritten. +RESIDUAL_FEATURES = {"domino": ["x", "y", "z", "roll"]} diff --git a/docs/envs/domino_fan/oracle_solve.gif b/docs/envs/domino_fan/oracle_solve.gif new file mode 100644 index 000000000..d8676ede6 Binary files /dev/null and b/docs/envs/domino_fan/oracle_solve.gif differ diff --git a/docs/envs/domino_fan/rung2_agent_planner.gif b/docs/envs/domino_fan/rung2_agent_planner.gif new file mode 100644 index 000000000..a47cf3302 Binary files /dev/null and b/docs/envs/domino_fan/rung2_agent_planner.gif differ diff --git a/docs/envs/domino_fan/wind_cascade.gif b/docs/envs/domino_fan/wind_cascade.gif new file mode 100644 index 000000000..90ac08241 Binary files /dev/null and b/docs/envs/domino_fan/wind_cascade.gif differ diff --git a/predicators/envs/pybullet_domino/cascade_certificate.py b/predicators/envs/pybullet_domino/cascade_certificate.py index a070ea5c5..22e4c8c52 100644 --- a/predicators/envs/pybullet_domino/cascade_certificate.py +++ b/predicators/envs/pybullet_domino/cascade_certificate.py @@ -65,9 +65,29 @@ DominoComponent from predicators.structs import GroundAtom, Object, State, StepOption -# The name of the option through which the robot is allowed to topple -# the green start block. +# The name of the option through which the robot is allowed to set the +# cascade going. "Push" is the domino env's: the robot shoves the green +# start block itself. A fan env's is "TurnFanOn" - the robot presses a +# switch and the WIND does the toppling - and the machinery below needs +# nothing else to accommodate it, because the span finder already +# admits a trigger that names no domino at all. _PUSH_OPTION_NAME = "Push" +_WIND_TRIGGER_OPTION_NAME = "TurnFanOn" + +# Triggers that never touch a domino. The counterfactual probe (rule d) +# is skipped for these: it exists to prove the robot's BODY did not +# carry the cascade, and a trigger whose whole action is pressing a +# switch metres away has no body contact to disprove. Rules (a)-(c) +# still apply and are what reject the arm-knocked-the-target episodes +# this env was scoring as wins. +_DECLARE_TRIGGER_OPTION_NAME = "DeclareFinished" + +# Triggers that start a cascade without the arm touching anything, and +# so have no contact for the counterfactual probe to disprove. The +# switch press is one because the switch is metres from the chain; a +# declaration is one because it moves nothing at all. +_BODYLESS_TRIGGERS = frozenset( + {_WIND_TRIGGER_OPTION_NAME, _DECLARE_TRIGGER_OPTION_NAME}) # Consecutive non-held states a domino must spend at or past # ``fallen_threshold`` before that counts as a topple rather than as @@ -243,8 +263,11 @@ def _topple_onset(states: Sequence[State], domino: Object) -> Optional[int]: def _push_on_green_spans( - step_options: Sequence[StepOption], greens: Sequence[Object], - domino_names: Set[str]) -> Tuple[List[Tuple[int, int]], bool]: + step_options: Sequence[StepOption], + greens: Sequence[Object], + domino_names: Set[str], + trigger_option_name: str = _PUSH_OPTION_NAME +) -> Tuple[List[Tuple[int, int]], bool]: """Maximal runs of consecutive action indices whose option is a Push on a green start block, plus whether any option label was missing. @@ -261,7 +284,7 @@ def _push_on_green_spans( any_unknown = True continue name, object_names = step_option[0], step_option[1] - if name == _PUSH_OPTION_NAME and ( + if name == trigger_option_name and ( green_names & set(object_names) or not domino_names & set(object_names)): push_idxs.append(i) @@ -314,7 +337,8 @@ def check_cascade_legitimacy( states: Sequence[State], goal: Set[GroundAtom], step_options: Optional[Sequence[StepOption]] = None, - probe: Optional[CascadeProbe] = None) -> Tuple[bool, str]: + probe: Optional[CascadeProbe] = None, + trigger_option_name: str = _PUSH_OPTION_NAME) -> Tuple[bool, str]: """Certify that the episode's topples are a genuine push-seeded cascade. Rules (any violation fails the whole episode): @@ -380,6 +404,9 @@ def check_cascade_legitimacy( "start block in the scene to seed a cascade") # Action rules (a)/(b). + # Whether the action rules actually ran and found the trigger. The + # bodyless-trigger shortcut below is only sound once they have. + trigger_verified = False pre_push_idx: Optional[int] = None pushed_greens: List[Object] = list(greens) push_params: Optional[Tuple[float, ...]] = None @@ -388,7 +415,7 @@ def check_cascade_legitimacy( if step_option is None: continue name, object_names = step_option[0], step_option[1] - if name == _PUSH_OPTION_NAME: + if name == trigger_option_name: foreign = sorted((set(object_names) & domino_names) - {g.name for g in greens}) @@ -398,7 +425,8 @@ def check_cascade_legitimacy( f"step {i}) - only the green start block may be " "pushed") spans, any_unknown = _push_on_green_spans(step_options, greens, - domino_names) + domino_names, + trigger_option_name) if any_unknown: logging.warning( "[cascade certificate] some actions lack option labels; " @@ -407,6 +435,7 @@ def check_cascade_legitimacy( return False, ("dominoes toppled but the green start block was " "never pushed (no Push on it in the episode)") first_push = spans[0][0] + trigger_verified = True pre_push_idx = first_push pushed_greens = _pushed_greens_in_order(step_options, greens, spans) push_params = _push_params_of_span(step_options, first_push) @@ -458,6 +487,26 @@ def check_cascade_legitimacy( # Rule (d): the counterfactual push probe, on goal-reaching episodes. if not all(atom.holds(states[-1]) for atom in goal): return True, "" + if trigger_option_name in _BODYLESS_TRIGGERS: + # No probe for a wind trigger: the probe re-runs the robot's own + # skill with every link but the fingertips masked, to prove the + # cascade was not carried by the arm, and pressing a switch + # metres from the chain has no such contact to disprove - there + # is no push to replay. + # + # 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 passing + # here regardless would turn the last gate into a rubber stamp: + # a place-knock episode with no trigger anywhere would collect + # the success bonus. Fail closed instead, as the probe path + # does for a goal-reaching episode it cannot check. + if trigger_verified: + return True, "" + return False, ( + "the episode reached the goal but carries no option labels, " + f"so the {trigger_option_name} trigger cannot be confirmed " + "and a place-knock cannot be ruled out") if probe is None: return False, ( "the goal atoms hold, but no counterfactual push probe is " diff --git a/predicators/envs/pybullet_domino/components/fan_component.py b/predicators/envs/pybullet_domino/components/fan_component.py index 7cb7f0e66..22f09640b 100644 --- a/predicators/envs/pybullet_domino/components/fan_component.py +++ b/predicators/envs/pybullet_domino/components/fan_component.py @@ -8,7 +8,7 @@ - Related predicates (FanOn, FanOff, Controls, FanFacingSide) """ -from typing import Any, ClassVar, Dict, List, Optional, Sequence, Set +from typing import Any, ClassVar, Dict, List, Optional, Sequence, Set, Tuple import numpy as np import pybullet as p @@ -34,10 +34,13 @@ class FanComponent(DominoEnvComponent): # ========================================================================= # Fan counts per side - num_left_fans: ClassVar[int] = 5 - num_right_fans: ClassVar[int] = 5 - num_back_fans: ClassVar[int] = 5 - num_front_fans: ClassVar[int] = 5 + # Fans per side. Banks of five look like a wall of fans and blow as + # one: only fan_ids[0] is ever read for direction or wind. A single- + # fan env overrides these to 1 (see __init__'s fans_per_side). + num_left_fans: int = 5 + num_right_fans: int = 5 + num_back_fans: int = 5 + num_front_fans: int = 5 # Fan physical properties fan_scale: ClassVar[float] = 0.08 @@ -62,15 +65,53 @@ class FanComponent(DominoEnvComponent): def __init__(self, workspace_bounds: Optional[Dict[str, float]] = None, table_height: float = 0.4, - table_width: float = 1.0) -> None: + table_width: float = 1.0, + num_sides: int = 4, + fans_per_side: Optional[int] = None, + switch_xy: Optional[Tuple[float, float]] = None, + switch_reachable: bool = True) -> None: """Initialize the fan component. Args: workspace_bounds: Dictionary with x_lb, x_ub, y_lb, y_ub. table_height: Height of the table surface. table_width: Width of the table. + num_sides: How many sides carry a fan and its switch, taken + in order left, right, down, up. Four is the ball task's + layout, where the ball must be blown any of four ways + across a grid. A domino chain runs ONE way, so the other + three fans are distractors the planner still has to + ground - and, blowing inward from opposite sides, they + cancel each other exactly. + fans_per_side: Fan bodies in each side's bank (default keeps + the class values). They blow as one; only fan_ids[0] is + read for direction or wind, so a bank is decoration. + switch_reachable: When False, the switch bodies are parked + far outside the workspace. The switch stays the thing + that STORES whether the fan is on -- extract_feature + reads the fan's is_on off its joint -- but nothing can + reach it, so the only way the fan comes on is whatever + the env decides to latch it with (see + PyBulletDominoDeclareEnv, where a declaration does). + switch_xy: Where the first switch sits, overriding the + workspace-centre formula below. That formula assumes the + fan env's workspace, which is twice as deep with the + robot at its front edge; dropped into a shallower one + with a centred robot it puts the switch BEHIND the arm, + 0.38 m from the base against the 0.73 m the fan env + gives it, and the press never completes (IK solves, the + joint-limited arm lands centimetres short, and the skill + waits forever for an exact arrival). """ super().__init__() + assert 1 <= num_sides <= 4, num_sides + self.num_sides = num_sides + self.switch_reachable = switch_reachable + if fans_per_side is not None: + self.num_left_fans = fans_per_side + self.num_right_fans = fans_per_side + self.num_back_fans = fans_per_side + self.num_front_fans = fans_per_side # Store table parameters self.table_height = table_height @@ -106,12 +147,25 @@ def __init__(self, self.fan_y_len / 2 - 0.01) # Switch positioning - self.switch_y = (self.y_lb + self.y_ub) * 0.5 - 0.25 - self.switch_base_x = 0.60 + if switch_xy is None: + self.switch_base_x = 0.60 + self.switch_y = (self.y_lb + self.y_ub) * 0.5 - 0.25 + else: + self.switch_base_x, self.switch_y = switch_xy self.switch_x_spacing = 0.08 - - # Side names - self._switch_sides = ["left", "right", "down", "up"] + if not switch_reachable: + # Parked well outside the arm's reach and off the table. + # The body still exists and still stores the on/off bit, + # but no skill and no stray sweep of the arm can touch it, + # so the fan's state has exactly one cause: whatever the + # env latches it with. + self.switch_base_x = self.x_lb - 2.0 + self.switch_y = self.y_lb - 2.0 + + # Side names. A component built with fewer sides keeps the + # first N of these: side 0 (left) blows +x, and the domino-fan + # env's chains are laid along it. + self._switch_sides = ["left", "right", "down", "up"][:self.num_sides] # Create types self._fan_type = Type( @@ -125,12 +179,12 @@ def __init__(self, # Create objects self._fans: List[Object] = [] - for i in range(4): # 4 sides + for i in range(self.num_sides): fan_obj = Object(f"fan_{i}", self._fan_type) self._fans.append(fan_obj) self._switches: List[Object] = [] - for i in range(4): + for i in range(self.num_sides): switch_obj = Object(f"switch_{i}", self._switch_type) self._switches.append(switch_obj) @@ -156,6 +210,22 @@ def __init__(self, # Object to apply wind force to (set by composed environment) self._wind_target_id: Optional[int] = None + # Height above the target's origin at which the wind pushes. Zero + # for a ball, where a force through the centre is what rolls it. + # A domino needs a positive value or it never tips: see + # _apply_wind_force. + self._wind_target_z_offset: float = 0.0 + # Stop pushing the target once it has fallen over (see + # _target_still_standing). Only meaningful for a body that can + # fall out of the airstream, so it travels with the z offset. + self._wind_stops_when_toppled: bool = False + # Per-target force override (N). None keeps the class default, + # which is calibrated for the ball. + self._wind_force_override: Optional[float] = None + # Lateral position the single fan is aimed at, along the axis it + # does NOT blow down: y for a left/right fan, x for front/back. + # None keeps the rail's centre. See set_lateral_alignment. + self._lateral_alignment: Optional[float] = None # ------------------------------------------------------------------------- # DominoEnvComponent interface implementation @@ -314,7 +384,9 @@ def step(self) -> None: physicsClientId=self._physics_client_id, ) # Apply force to wind target (e.g., ball) - if self._wind_target_id is not None: + if self._wind_target_id is not None and ( + not self._wind_stops_when_toppled + or self._target_still_standing(self._wind_target_id)): self._apply_wind_force(fan_obj.fan_ids[0], self._wind_target_id) else: @@ -335,9 +407,97 @@ def step(self) -> None: # Fan-specific methods # ------------------------------------------------------------------------- - def set_wind_target(self, target_id: int) -> None: - """Set the object that wind forces should be applied to.""" + def set_fans_on(self, on: bool = True) -> None: + """Turn every fan on or off without anything pressing anything. + + The switch joint IS the stored bit -- ``extract_feature`` reads + a fan's ``is_on`` off the switch that controls its side -- so + setting the fan means setting that joint. Written for envs + where the fan has no reachable button and something else + decides (see PyBulletDominoDeclareEnv). In the button envs + nothing calls this and the press remains the only cause. + """ + for switch in self._switches: + self._set_switch_on(switch.id, on) + + def any_fan_on(self) -> bool: + """True when at least one fan is currently blowing.""" + return any(self._is_switch_on(sw.id) for sw in self._switches) + + def set_wind_target(self, + target_id: int, + z_offset: float = 0.0, + stop_when_toppled: bool = False, + force: Optional[float] = None) -> None: + """Set the object that wind forces should be applied to. + + ``z_offset`` raises the point of application above the target's + origin, which is what turns a shove into a topple for a body + that stands on a narrow base. ``stop_when_toppled`` cuts the + force once the target is down. ``force`` overrides the class + magnitude, which is calibrated for the ball and is 16x what a + domino needs. + """ self._wind_target_id = target_id + self._wind_target_z_offset = z_offset + self._wind_stops_when_toppled = stop_when_toppled + self._wind_force_override = force + + def set_lateral_alignment(self, lateral: Optional[float]) -> None: + """Aim the fan across its blowing axis, at ``lateral``. + + The wind force is computed from a fan's ORIENTATION alone, so + the sim happily blows a domino over from a fan parked anywhere - + which is how this env ended up with its fan 0.34 m to the side + of the chain, on a rail whose centre (y=1.708) is outside the + domino workspace (y<=1.49) altogether. It looked wrong because + it WAS wrong: no real fan there blows down that line. + + It also matters beyond looks. A learning agent reads the fan's + coordinates and reasons about how far downstream its beam + reaches; a fan that is not where the physics pretends it is + makes that inference unlearnable. + + Called per reset by the composed env, which knows where the + task's chain was laid. None restores the rail's centre. + """ + self._lateral_alignment = lateral + if self._physics_client_id is not None: + self._position_fans_on_sides() + + def _aligned_lateral(self, side_idx: int, default: float) -> float: + """``default``, unless this side has been aimed at a chain.""" + if self._lateral_alignment is None: + return default + # Sides 0/1 (left/right) blow along x, so their free axis is y; + # sides 2/3 (back/front) blow along y and are free in x. + del side_idx # both cases read the same stored scalar + return self._lateral_alignment + + def _target_still_standing(self, target_id: int) -> bool: + """Is the wind target still upright enough to be pushed? + + A standing domino presents its face to the airstream; a fallen + one lies flat on the table, out of it. Without this the fan goes + on shoving a body that is already down and slides it across the + table indefinitely - which lets the start block bulldoze all the + way into the target and "solve" a bridge task with none of the + bridge built. + + Reads DominoComponent's own threshold rather than restating a + number, so the physics stops exactly where the symbol flips + even if that constant moves. + """ + # pylint: disable-next=import-outside-toplevel + from predicators.envs.pybullet_domino.components.domino_component \ + import DominoComponent + _, orn = p.getBasePositionAndOrientation( + target_id, physicsClientId=self._physics_client_id) + roll = p.getEulerFromQuaternion(orn)[0] + # Fold to [-pi/2, pi/2): a box turned 180 degrees about its own + # width axis is the same box, so roll only means anything mod pi. + roll = (roll + np.pi / 2) % np.pi - np.pi / 2 + return abs(roll) < DominoComponent.domino_roll_threshold def _apply_wind_force(self, fan_id: int, target_id: int) -> None: """Apply wind force from fan to target object.""" @@ -353,25 +513,47 @@ def _apply_wind_force(self, fan_id: int, target_id: int) -> None: world_dir = rmat.dot(local_dir) pos_target, _ = p.getBasePositionAndOrientation( target_id, physicsClientId=self._physics_client_id) - force_vec = self.wind_force_magnitude * world_dir + # Apply the force ABOVE the centre of mass, not through it. A + # force through the centre is pure translation: a domino under + # it slides across the table indefinitely and never tips, which + # is how a wind-driven task ends up "solved" by the start block + # bulldozing into the target with the chain untouched. Offset + # upward, the same force makes a moment about the domino's + # bottom edge and it falls. Zero for a ball, whose existing + # behaviour (roll from a central push) is what that task wants. + pos_apply = (pos_target[0], pos_target[1], + pos_target[2] + self._wind_target_z_offset) + magnitude = (self.wind_force_magnitude + if self._wind_force_override is None else + self._wind_force_override) + force_vec = magnitude * world_dir p.applyExternalForce(objectUniqueId=target_id, linkIndex=-1, forceObj=force_vec.tolist(), - posObj=pos_target, + posObj=pos_apply, flags=p.WORLD_FRAME, physicsClientId=self._physics_client_id) def _position_fans_on_sides(self) -> None: """Position all PyBullet fan bodies on their respective sides.""" assert self._physics_client_id is not None - left_coords = np.linspace(self.fan_y_lb, self.fan_y_ub, - self.num_left_fans) - right_coords = np.linspace(self.fan_y_lb, self.fan_y_ub, - self.num_right_fans) - front_coords = np.linspace(self.fan_x_lb, self.fan_x_ub, - self.num_front_fans) - back_coords = np.linspace(self.fan_x_lb, self.fan_x_ub, - self.num_back_fans) + + # np.linspace(a, b, 1) returns [a], not the midpoint - so a + # single-fan side lands at the LOW end of its rail while the + # state still reports the centre. Harmless to the oracle, whose + # wind is computed from the fan's orientation and never its + # position, but a learning agent reasons about "how far + # downstream of the fan the beam still reaches", and a body 0.42 + # m from its reported coordinate corrupts exactly that. + def _rail(lo: float, hi: float, n: int) -> Any: + if n == 1: + return np.array([(lo + hi) / 2.0]) + return np.linspace(lo, hi, n) + + left_coords = _rail(self.fan_y_lb, self.fan_y_ub, self.num_left_fans) + right_coords = _rail(self.fan_y_lb, self.fan_y_ub, self.num_right_fans) + front_coords = _rail(self.fan_x_lb, self.fan_x_ub, self.num_front_fans) + back_coords = _rail(self.fan_x_lb, self.fan_x_ub, self.num_back_fans) for fan_obj in self._fans: side_idx = fan_obj.side_idx @@ -380,7 +562,7 @@ def _position_fans_on_sides(self) -> None: if side_idx == 0: # left for i, fan_id in enumerate(fan_ids): px = self.left_fan_x - py = left_coords[i] + py = self._aligned_lateral(0, left_coords[i]) pz = self.table_height + self.fan_z_len / 2 rot = [0.0, 0.0, 0.0] update_object(fan_id, @@ -391,7 +573,7 @@ def _position_fans_on_sides(self) -> None: elif side_idx == 1: # right for i, fan_id in enumerate(fan_ids): px = self.right_fan_x - py = right_coords[i] + py = self._aligned_lateral(1, right_coords[i]) pz = self.table_height + self.fan_z_len / 2 rot = [0.0, 0.0, np.pi] update_object(fan_id, @@ -401,7 +583,7 @@ def _position_fans_on_sides(self) -> None: elif side_idx == 2: # back for i, fan_id in enumerate(fan_ids): - px = back_coords[i] + px = self._aligned_lateral(2, back_coords[i]) py = self.down_fan_y pz = self.table_height + self.fan_z_len / 2 rot = [0.0, 0.0, np.pi / 2] @@ -412,7 +594,7 @@ def _position_fans_on_sides(self) -> None: elif side_idx == 3: # front for i, fan_id in enumerate(fan_ids): - px = front_coords[i] + px = self._aligned_lateral(3, front_coords[i]) py = self.up_fan_y pz = self.table_height + self.fan_z_len / 2 rot = [0.0, 0.0, -np.pi / 2] @@ -500,16 +682,24 @@ def get_init_dict_entries( for fan_obj in self._fans: side_idx = fan_obj.side_idx if side_idx == 0: # left - px, py = self.left_fan_x, (self.fan_y_lb + self.fan_y_ub) / 2 + px = self.left_fan_x + py = self._aligned_lateral(0, + (self.fan_y_lb + self.fan_y_ub) / 2) rot = 0.0 elif side_idx == 1: # right - px, py = self.right_fan_x, (self.fan_y_lb + self.fan_y_ub) / 2 + px = self.right_fan_x + py = self._aligned_lateral(1, + (self.fan_y_lb + self.fan_y_ub) / 2) rot = np.pi elif side_idx == 2: # back - px, py = (self.fan_x_lb + self.fan_x_ub) / 2, self.down_fan_y + px = self._aligned_lateral(2, + (self.fan_x_lb + self.fan_x_ub) / 2) + py = self.down_fan_y rot = np.pi / 2 else: # front - px, py = (self.fan_x_lb + self.fan_x_ub) / 2, self.up_fan_y + px = self._aligned_lateral(3, + (self.fan_x_lb + self.fan_x_ub) / 2) + py = self.up_fan_y rot = -np.pi / 2 init_dict[fan_obj] = { diff --git a/predicators/envs/pybullet_domino/components/goal_region_component.py b/predicators/envs/pybullet_domino/components/goal_region_component.py new file mode 100644 index 000000000..c7014f311 --- /dev/null +++ b/predicators/envs/pybullet_domino/components/goal_region_component.py @@ -0,0 +1,217 @@ +"""Goal region for the wind-placement task. + +A flat patch on the table the wind has to deliver a domino INTO. It is +scenery, not an obstacle: zero mass, collisions disabled, drawn only so +a person watching the video can see what the robot is aiming at. + +The region exists so the task cannot be solved by pushing as hard as +possible. Emily's question in the design meeting was whether a robot +could simply place the block as close to the fan as it can every time; +with a target POINT it could, and with a bounded region it cannot - +place too near the fan and the wind carries the block past the far +edge, too far and it never arrives. Only a band of placements works, +and finding that band means knowing how far this wind pushes this +block, which is the parameter the task exists to make learnable. +""" + +from typing import Any, ClassVar, Dict, List, Optional, Sequence, Set, Tuple + +import numpy as np +import pybullet as p + +from predicators.envs.pybullet_domino.components.base_component import \ + DominoEnvComponent +from predicators.pybullet_helpers.objects import create_pybullet_block +from predicators.structs import Object, Predicate, State, Type + + +class GoalRegionComponent(DominoEnvComponent): + """One rectangular goal patch, and the predicate for being in it.""" + + # A patch big enough to be hittable and small enough that "as hard + # as possible" overshoots it. 6 cm of slack along the wind axis + # against a slide of tens of centimetres. + # Half-width along the wind axis. 4 cm against an 11.6 cm slide + # means the robot has to know how far this gust carries the block to + # within about a quarter - loose enough to be learnable from a + # handful of episodes, tight enough that a coarse guess misses. + region_half_x: ClassVar[float] = 0.04 + region_half_y: ClassVar[float] = 0.09 + region_thickness: ClassVar[float] = 0.001 + region_color: ClassVar[Tuple[float, float, float, + float]] = (0.2, 0.85, 0.35, 0.55) + + def __init__(self, + workspace_bounds: Optional[Dict[str, float]] = None, + table_height: float = 0.4, + domino_type: Optional[Type] = None) -> None: + super().__init__() + self.table_height = table_height + self._domino_type = domino_type + if workspace_bounds is None: + workspace_bounds = { + "x_lb": 0.4, + "x_ub": 1.1, + "y_lb": 1.1, + "y_ub": 1.6 + } + self.x_lb = workspace_bounds["x_lb"] + self.x_ub = workspace_bounds["x_ub"] + self.y_lb = workspace_bounds["y_lb"] + self.y_ub = workspace_bounds["y_ub"] + + # half_x / half_y are features rather than constants so an agent + # reading the state can see how much slack it has, and so a task + # generator could vary the difficulty without a code change. + self._region_type = Type( + "region", ["x", "y", "z", "half_x", "half_y"], + sim_features=["id"]) + self._region = Object("goal_region", self._region_type) + self._region_id: Optional[int] = None + self._region_xy: Tuple[float, float] = (0.0, 0.0) + + self._InGoal = Predicate("InGoal", + [self._domino_type, self._region_type] + if self._domino_type is not None else + [self._region_type], self._InGoal_holds) + + # -- component interface ------------------------------------------ + + def get_types(self) -> Set[Type]: + return {self._region_type} + + def get_predicates(self) -> Set[Predicate]: + return {self._InGoal} + + def get_goal_predicates(self) -> Set[Predicate]: + return {self._InGoal} + + def get_objects(self) -> List[Object]: + return [self._region] + + def initialize_pybullet(self, physics_client_id: int) -> Dict[str, Any]: + """A thin coloured plate lying on the table. + + Mass 0 and collisions off: the region must not deflect the very + block it is measuring, and a lip of even a millimetre would. + """ + self._physics_client_id = physics_client_id + region_id = create_pybullet_block( + color=self.region_color, + half_extents=(self.region_half_x, self.region_half_y, + self.region_thickness), + mass=0.0, + friction=0.5, + position=(0.0, 0.0, self.table_height + self.region_thickness), + orientation=(0.0, 0.0, 0.0, 1.0), + physics_client_id=physics_client_id) + p.setCollisionFilterGroupMask(region_id, + -1, + 0, + 0, + physicsClientId=physics_client_id) + return {"region_id": region_id} + + def store_pybullet_bodies(self, pybullet_bodies: Dict[str, Any]) -> None: + self._region_id = pybullet_bodies["region_id"] + self._region.id = self._region_id + + def reset_state(self, state: State) -> None: + x = float(state.get(self._region, "x")) + y = float(state.get(self._region, "y")) + self._region_xy = (x, y) + if self._region_id is not None: + p.resetBasePositionAndOrientation( + self._region_id, + (x, y, self.table_height + self.region_thickness), + (0.0, 0.0, 0.0, 1.0), + physicsClientId=self._physics_client_id) + + def extract_feature(self, obj: Object, feature: str) -> Optional[float]: + if obj.type != self._region_type: + return None + if feature == "x": + return self._region_xy[0] + if feature == "y": + return self._region_xy[1] + if feature == "z": + return self.table_height + self.region_thickness + if feature == "half_x": + return self.region_half_x + if feature == "half_y": + return self.region_half_y + return None + + def get_init_dict_entries( + self, rng: np.random.Generator) -> Dict[Object, Dict[str, Any]]: + """Placed by the task generator, which knows where the fan is.""" + del rng + x, y = self._region_xy + return { + self._region: { + "x": x, + "y": y, + "z": self.table_height + self.region_thickness, + "half_x": self.region_half_x, + "half_y": self.region_half_y, + } + } + + def get_object_ids_for_held_check(self) -> List[int]: + return [] + + # -- placement, used by the task generator ------------------------ + + def set_region_xy(self, x: float, y: float) -> None: + """Put the region where the generator decided it goes.""" + self._region_xy = (x, y) + + # -- predicate ---------------------------------------------------- + + def _InGoal_holds(self, state: State, objects: Sequence[Object]) -> bool: + """The domino lies FLAT inside the patch. + + Flat, not merely present, and that word is what makes the task + a task. 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 it the goal has a trivial + answer: pick the block up, put it down in the region, done, and + the fan is never needed at all. + + Centre rather than footprint overlap: a block half in and half + out is a coin toss on the exact contact solve, and the reward + should not turn on which millimetre the solver settled at. + """ + if len(objects) != 2: + return False + domino, region = objects + if state.get(domino, "is_held") > 0.5: + return False + # Roll is meaningful modulo pi: a box turned 180 degrees about + # its own width axis is the same box. + # pylint: disable-next=import-outside-toplevel + from predicators.envs.pybullet_domino.components.domino_component \ + import DominoComponent + roll = float(state.get(domino, "roll")) + roll = (roll + np.pi / 2) % np.pi - np.pi / 2 + if abs(roll) < DominoComponent.domino_roll_threshold: + return False + dx = abs(float(state.get(domino, "x")) - float(state.get(region, "x"))) + dy = abs(float(state.get(domino, "y")) - float(state.get(region, "y"))) + return (dx <= float(state.get(region, "half_x")) + and dy <= float(state.get(region, "half_y"))) + + @property + def region(self) -> Object: + """The goal patch object.""" + return self._region + + @property + def region_type(self) -> Type: + """Type of the goal patch.""" + return self._region_type + + @property + def InGoal(self) -> Predicate: + """True when a domino's centre is inside the patch.""" + return self._InGoal diff --git a/predicators/envs/pybullet_domino/env.py b/predicators/envs/pybullet_domino/env.py index d3e401fcc..7d82ad1fe 100644 --- a/predicators/envs/pybullet_domino/env.py +++ b/predicators/envs/pybullet_domino/env.py @@ -18,6 +18,8 @@ DominoEnvComponent from predicators.envs.pybullet_domino.components.domino_component import \ DominoComponent +from predicators.envs.pybullet_domino.components.goal_region_component \ + import GoalRegionComponent from predicators.envs.pybullet_domino.components.fan_component import \ FanComponent from predicators.envs.pybullet_domino.components.grid_component import \ @@ -34,8 +36,9 @@ from predicators.pybullet_helpers.objects import create_object from predicators.pybullet_helpers.robots import SingleArmPyBulletRobot from predicators.settings import CFG -from predicators.structs import Action, EnvironmentTask, GroundAtom, Object, \ - ParameterizedOption, Predicate, State, TaskEvaluator, Type +from predicators.structs import DECLARE_FINISHED_KEY, Action, \ + EnvironmentTask, GroundAtom, Object, ParameterizedOption, Predicate, \ + State, TaskEvaluator, Type class DominoEvaluator(TaskEvaluator): @@ -64,12 +67,20 @@ class DominoEvaluator(TaskEvaluator): def __init__(self, goal: Set[GroundAtom], - num_movables: Optional[int] = None) -> None: + num_movables: Optional[int] = None, + trigger_option_name: str = "Push") -> None: """``num_movables`` is the number of movable (blue) dominoes staged in the task's scene, bounding the worst-case toppled-blue cost; it defaults to the min-block budget flag for the min-block / heavy task - families, and the plain chain generator passes its actual count.""" + families, and the plain chain generator passes its actual count. + + ``trigger_option_name`` is the one option through which the + robot may legitimately set the cascade going: "Push" where it + shoves the green itself, "TurnFanOn" in a fan env, where it + presses a switch and the wind does the toppling. + """ super().__init__(goal) + self._trigger_option_name = trigger_option_name if num_movables is None: num_movables = CFG.domino_min_block_num_blues assert CFG.domino_block_cost * num_movables < 1.0, \ @@ -111,10 +122,12 @@ def _certify(self, if self._certify_memo is not None and self._certify_memo[0] == key: return self._certify_memo[1] probe = getattr(sim_env, "run_counterfactual_cascade_probe", None) - verdict = check_cascade_legitimacy(states, - self.goal, - step_options, - probe=probe) + verdict = check_cascade_legitimacy( + states, + self.goal, + step_options, + probe=probe, + trigger_option_name=self._trigger_option_name) self._certify_memo = (key, verdict) return verdict @@ -405,7 +418,9 @@ def _store_pybullet_bodies(self, pybullet_bodies: Dict[str, Any]) -> None: comp_bodies = comp.initialize_pybullet(self._physics_client_id) comp.store_pybullet_bodies(comp_bodies) - # Wire up fan -> ball connection if both present + # Wire up fan -> ball connection if both present. Without a + # ball the target is a domino, which changes per task, so it is + # wired on every reset by _wire_wind_target instead. if self._fan_component is not None and self._ball_component is not None: self._fan_component.set_wind_target(self._ball_component.ball_id) @@ -447,6 +462,66 @@ def _set_domain_specific_state(self, state: State) -> None: if self._ball_component is not None: self._ball_component.set_current_state(state) + self._wire_wind_target(state) + + def _wire_wind_target(self, state: State) -> None: + """Point the fan at the body it is supposed to blow, for this task. + + With a ball in the scene the fan blows the ball, which is one + body for the life of the env and is wired at startup. Without + one it blows the START domino, and which body holds that role is + a property of the task, not of the env: the roles are colours + assigned per layout, so the green block is a different body id + from one reset to the next. Re-resolving here - after the + components have taken the new state - is what keeps the wind on + the domino the task actually starts from. + + A task with no start block (none generated, or a scene where the + chain begins elsewhere) leaves the previous target in place + rather than silently blowing an arbitrary body. + """ + if self._fan_component is None or self._ball_component is not None: + return + if self._domino_component is None: + return + for domino in self._domino_component.dominos: + # The component allocates num_dominos_max bodies once and a + # task instantiates a subset of them, so the tail of this + # list is absent from the state; reading a colour off one + # raises rather than returning False. + if domino.id is None or domino not in state: + continue + # pylint: disable-next=protected-access + if DominoComponent._StartBlock_holds(state, [domino]): + # Push near the top of the domino so the wind tips it + # rather than sliding it: 0.4 of its height above the + # origin is comfortably above the centre and still on + # the body. + # Aim the fan down the chain, not merely in its + # direction. Wind force is computed from orientation + # alone, so a misplaced fan still topples the block - + # which let this env ship with its fan 0.34 m to the + # side of the chain, on a rail whose centre sits outside + # the domino workspace. Correct in the picture and in + # the state an agent reads. + lateral = float(state.get(domino, "y")) + self._fan_component.set_lateral_alignment(lateral) + self._fan_component.set_wind_target( + domino.id, + z_offset=0.4 * self._domino_component.domino_height, + stop_when_toppled=True, + force=CFG.domino_fan_wind_force) + return + if CFG.env == "pybullet_domino_blow": + # Expected, not a fault: the blow task has one plain block + # and no chain, so there is no green start block to aim at + # and PyBulletDominoBlowEnv re-aims the wind itself right + # after this. Warning about it filled the logs with a line + # that reads like a broken scene. + return + logging.warning( + "Fan env has no start (green) domino in this task; leaving the " + "wind target unchanged.") def _domain_specific_step(self) -> None: """Run component physics updates (e.g., fan wind simulation).""" @@ -911,8 +986,29 @@ def get_name(cls) -> str: return "pybullet_domino" -class PyBulletDominoFanEnv(PyBulletDominoComposedEnv): - """Backward-compatible domino + fan + ball environment class.""" +# Where the domino-fan env's switch sits: in front of the robot, past +# the far end of the chain in x and a little NEARER in y, so the arm +# approaches it from outside the chain instead of over it. At +# (1.05, 1.30) the press reached across a finished bridge - the switch +# was 0.22 m from the target at the same y - and disturbed it. +# +# The inherited formula put it 0.41 m from the robot's base, folded in +# against the arm, and the press never completed - IK solves there, but +# the joint-limited arm lands centimetres short and the skill waits +# forever for an exact arrival. A sweep over candidate positions found +# the boundary sharp: 0.41 m never terminates, while every position +# from 0.59 m out presses in ~30 steps. The fan env, where this has +# always worked, presses at 0.73 m. +DOMINO_FAN_SWITCH_XY = (1.10, 1.20) + + +class PyBulletDominoFanBallEnv(PyBulletDominoComposedEnv): + """Domino + fan + ball: the fan blows the BALL, which knocks dominoes. + + Formerly ``pybullet_domino_fan``. Renamed when the ball-free variant + below took that name, because which body the wind pushes is the + whole difference between the two tasks and the old name did not say. + """ def __init__(self, use_gui: bool = False, **kwargs: Any) -> None: bounds = self._default_workspace_bounds() @@ -926,11 +1022,289 @@ def __init__(self, use_gui: bool = False, **kwargs: Any) -> None: use_gui=use_gui, **kwargs) + @classmethod + def get_name(cls) -> str: + return "pybullet_domino_fan_ball" + + +class PyBulletDominoFanEnv(PyBulletDominoComposedEnv): + """Domino + fan, no ball: the fan blows the START DOMINO directly. + + The task is to arrange the dominoes so that switching the fan on + topples the chain all the way to the target - the robot never + pushes anything itself, so the whole plan is in the layout. + + Wind targets the green start block rather than the ball, resolved on + every reset (see ``_wire_wind_target``): which body carries the + start role changes from task to task, so it cannot be wired once at + startup the way the ball's single body can. + """ + + # A deeper workspace than the other domino envs, and the chain + # layout is why. The staging grid steps y by 1.5 * domino_width + # inside margins of 1.5 and 3 widths, so the inherited y range + # (1.1-1.6) leaves a domino band 0.185 m deep - room for exactly ONE + # staging row, at y = 1.275. A wind-aligned chain runs along x at + # y = 1.283, so that single row lands ON the chain: every blue is + # parked 8 mm from the line it has to be built into, and Place + # cannot reach a bridge slot without the gripper fouling a + # neighbour ("BiRRT collision: target configuration in collision"). + # 1.70 gives a 0.285 m band, hence rows at 1.275 and 1.38 - one for + # the chain, one to park in. The far row sits 0.66 m from the + # robot's base, well inside the reach the switch sweep measured. + y_ub: ClassVar[float] = 1.70 + + def __init__(self, use_gui: bool = False, **kwargs: Any) -> None: + bounds = self._default_workspace_bounds() + domino_comp = self._make_domino_component(bounds) + # One fan, one switch. The four-sided layout is the ball task's, + # where the ball must be blown any of four ways across a grid; a + # domino chain runs one way, and the aligned generator lays it + # along side 0. The other three fans would be distractors the + # planner still has to ground, and being opposed they cancel. + fan_comp = self._make_fan_component(bounds) + components = [domino_comp, fan_comp] + components += self._extra_components(bounds, domino_comp) + super().__init__(components=components, use_gui=use_gui, **kwargs) + + def _extra_components(self, bounds: Dict[str, float], + domino_comp: DominoComponent) -> List[Any]: + """Components beyond the dominoes and the fan. None by default.""" + del bounds, domino_comp + return [] + + def _make_fan_component(self, bounds: Dict[str, float]) -> FanComponent: + """The fan bank for this env. Overridden where the switch is + not something the robot can reach.""" + return FanComponent(workspace_bounds=bounds, + table_height=self.table_height, + table_width=self.table_width, + num_sides=CFG.domino_fan_num_sides, + fans_per_side=1, + switch_xy=DOMINO_FAN_SWITCH_XY) + @classmethod def get_name(cls) -> str: return "pybullet_domino_fan" +class PyBulletDominoDeclareEnv(PyBulletDominoFanEnv): + """The fan env with the button removed: the robot DECLARES finished. + + Same scene and same physics as ``pybullet_domino_fan`` -- arrange + the blues so the wind carries a cascade from the green start block + to the purple target -- with one thing changed: there is no switch + to press. The robot runs a ``DeclareFinished`` skill, and the fan + comes on. + + Two reasons the change is worth its own env rather than a flag. + + **It is the version that survives contact with real hardware.** A + physical button has to be reachable from every staging pose, the + arm has to approach it without sweeping through the chain it just + built, and a missed press looks exactly like a press that did not + take. A declaration has none of that. + + **It makes the causal question clean.** With a button, "what starts + the wind" has a mechanical answer an agent can stumble into: the + gripper touched a thing. Here nothing is touched. An agent that + works out that the wind follows its declaration has found a + relation that is causal and nothing else -- which is precisely what + the predicate-invention rung is meant to be testing. + + The switch body still exists and still stores the 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. + """ + + def __init__(self, use_gui: bool = False, **kwargs: Any) -> None: + super().__init__(use_gui=use_gui, **kwargs) + # Latched by the declaration, cleared on reset. Kept on the env + # rather than read from the switch each step because the + # question it answers is "has the robot declared yet", which is + # about the episode, not about the bodies. + self._declared: bool = False + + @classmethod + def get_name(cls) -> str: + return "pybullet_domino_declare" + + def _make_fan_component(self, bounds: Dict[str, float]) -> FanComponent: + """A fan whose switch nothing can reach.""" + return FanComponent(workspace_bounds=bounds, + table_height=self.table_height, + table_width=self.table_width, + num_sides=CFG.domino_fan_num_sides, + fans_per_side=1, + switch_xy=DOMINO_FAN_SWITCH_XY, + switch_reachable=False) + + def _set_domain_specific_state(self, state: State) -> None: + super()._set_domain_specific_state(state) + # A reset is a new episode: the declaration does not carry over, + # or the second task of a run would begin with the wind already + # blowing on a chain nobody has built yet. + self._declared = bool(self._fan_component is not None + and self._fan_component.any_fan_on()) + + def _domain_specific_step(self) -> None: + # The declaration, before the wind: the flag it sets is what + # makes any wind happen at all this step. + action = self._last_action + info = getattr(action, "extra_info", None) + if isinstance(info, dict) and info.get(DECLARE_FINISHED_KEY): + self._declared = True + if self._fan_component is not None: + self._fan_component.set_fans_on(True) + super()._domain_specific_step() + + +class PyBulletDominoBlowEnv(PyBulletDominoFanEnv): + """Place a block so the wind carries it INTO a goal region. + + The scene reads left to right: fan, goal patch, staged block. The + robot picks the block up, puts it down somewhere between the fan + and the patch, and presses the fan's switch; the fan then blows for + a bounded number of steps and the block slides. It scores if the + block comes to rest inside the patch. + + This task exists because of what pybullet_domino_fan could NOT + teach. There the wind tips a standing domino in about two steps, + so every force above the tipping threshold produces the same + observation and ``wind_force`` is unfittable - 1.5 N and 2.0 N give + identical trajectories (measured by + scripts/domino_debug/probe_wind_identifiability.py). Here the wind + pushes through the block's CENTRE OF MASS, so it slides rather than + tips and the distance it travels is a continuous, monotone function + of the force. That is the same reason pybullet_fan can fit this + parameter and the domino env cannot: what the wind is pushing + decides whether its strength leaves a trace. + + And the goal is a bounded REGION, not a point, which is what stops + the degenerate policy. Placing the block as close to the fan as + possible - the obvious way to avoid learning anything - overshoots + the far edge. Placing it safely far never arrives. Only a band of + placements works, and its position depends on how hard this fan + blows, so the robot cannot reach the goal without having learned + that. + """ + + def __init__(self, use_gui: bool = False, **kwargs: Any) -> None: + self._goal_region_component: Optional[GoalRegionComponent] = None + super().__init__(use_gui=use_gui, **kwargs) + self._wind_steps_left: int = 0 + + def _extra_components(self, bounds: Dict[str, float], + domino_comp: DominoComponent) -> List[Any]: + """The patch the block has to end up in.""" + self._goal_region_component = GoalRegionComponent( + workspace_bounds=bounds, + table_height=self.table_height, + domino_type=domino_comp.domino_type) + return [self._goal_region_component] + + @classmethod + def get_name(cls) -> str: + return "pybullet_domino_blow" + + def _set_domain_specific_state(self, state: State) -> None: + super()._set_domain_specific_state(state) + # A fresh gust budget, but ONLY when the incoming state has the + # fan off - which is to say, at the start of an episode. + # + # _set_state is called far more often than once per episode: the + # executor reconstructs state during execution, and refilling + # the budget on every one of those meant the counter never + # reached zero, the fan never switched off, and the block was + # pushed straight past the goal. The trace that drove the plan + # by hand never hit it, which is exactly why it passed while the + # launcher scored zero. + if self._fan_component is None or not self._fan_component.any_fan_on(): + self._wind_steps_left = CFG.domino_blow_wind_steps + self._wire_blow_target(state) + + def _wire_blow_target(self, state: State) -> None: + """Aim the fan at the block it is supposed to move. + + Two settings, and the task depends on both. + + The lever (0.4 of the block's height, as in the cascade env) is + what makes the block END UP FLAT, and flat is what stops the + task having a trivial answer: the robot cannot place a domino + on its side, so a block lying in the goal can only have been + put there by the wind. + + stop_when_toppled=False is what keeps the distance LEARNABLE. + Cut the wind at the topple and the block always lands about the + same place - 8.78 cm at 1.3 N and 8.93 at 2.0 - which is the + saturation that made pybullet_domino_fan's wind unfittable. + Let the gust keep pushing the fallen block and the landing point + spreads out again: 11.8 cm at 1.2 N, 23.4 at 2.6, smooth and + monotone in between. + """ + if self._fan_component is None or self._domino_component is None: + return + movable = [ + obj for obj in state + if obj.type == self._domino_component.domino_type + # pylint: disable-next=protected-access + and self._domino_component._MovableBlock_holds(state, [obj]) + ] + if not movable: + return + block = movable[0] + self._fan_component.set_lateral_alignment(float( + state.get(block, "y"))) + self._fan_component.set_wind_target( + block.id, + z_offset=0.4 * self._domino_component.domino_height, + stop_when_toppled=False, + force=CFG.domino_blow_wind_force) + + def _domain_specific_step(self) -> None: + super()._domain_specific_step() + # The gust is finite. Without a budget the block is pushed until + # the episode horizon and its resting place says nothing about + # the force - every force large enough to move it at all ends up + # against the far wall, which is the saturation this env was + # built to avoid. + if self._fan_component is None: + return + if self._fan_component.any_fan_on() and self._wind_steps_left > 0: + self._wind_steps_left -= 1 + if self._wind_steps_left == 0: + self._fan_component.set_fans_on(False) + self._log_gust_outcome() + + + def _log_gust_outcome(self) -> None: + """Where the gust left the block, once per episode. + + The task turns entirely on this one number, and reading it off + a video is not reading it. Logged at the moment the gust ends, + which is the moment the answer is decided. + """ + try: + state = self._get_state() + except Exception: # pylint: disable=broad-except + return + blocks = [o for o in state if o.type.name == "domino"] + regions = [o for o in state if o.type.name == "region"] + if not blocks or not regions: + return + block, region = blocks[0], regions[0] + roll = float(state.get(block, "roll")) + roll = (roll + np.pi / 2) % np.pi - np.pi / 2 + gx = float(state.get(region, "x")) + half = float(state.get(region, "half_x")) + bx = float(state.get(block, "x")) + logging.info( + "[blow] gust over: block x=%.4f roll=%.3f | goal x=%.4f " + "+/- %.3f | dx=%.4f | flat=%s in_x=%s", bx, roll, gx, half, + bx - gx, abs(roll) >= 0.087, abs(bx - gx) <= half) + + class PyBulletDominoFanRampEnv(PyBulletDominoComposedEnv): """Domino + fan + ball + ramp environment class.""" @@ -995,7 +1369,8 @@ def get_name(cls) -> str: from predicators import utils # Choose which environment to test - # Options: "domino", "domino_fan", "domino_fan_ramp", + # Options: "domino", "domino_fan", "domino_fan_ball", + # "domino_fan_ramp", # "domino_fan_ramp_stairs" # Change this to test different environments test_env = "domino_fan_ramp_stairs" @@ -1037,6 +1412,10 @@ def get_name(cls) -> str: print("Creating PyBulletDominoFanEnv...") CFG.env = "pybullet_domino_fan" demo_env = PyBulletDominoFanEnv(use_gui=True) + elif test_env == "domino_fan_ball": + print("Creating PyBulletDominoFanBallEnv...") + CFG.env = "pybullet_domino_fan_ball" + demo_env = PyBulletDominoFanBallEnv(use_gui=True) elif test_env == "domino_fan_ramp": print("Creating PyBulletDominoFanRampEnv...") CFG.env = "pybullet_domino_fan_ramp" diff --git a/predicators/envs/pybullet_domino/task_generators/domino_task_generator.py b/predicators/envs/pybullet_domino/task_generators/domino_task_generator.py index a280a4a72..4687366dc 100644 --- a/predicators/envs/pybullet_domino/task_generators/domino_task_generator.py +++ b/predicators/envs/pybullet_domino/task_generators/domino_task_generator.py @@ -1,6 +1,6 @@ """Task generator for domino-based tasks.""" -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple import numpy as np @@ -15,6 +15,20 @@ from predicators.structs import EnvironmentTask, GroundAtom, Object +def _dist_to_segment(pt: Tuple[float, float], a: Tuple[float, float], + b: Tuple[float, float]) -> float: + """Perpendicular distance from ``pt`` to segment ``a``-``b``.""" + px, py = pt + ax, ay = a + bx, by = b + dx, dy = bx - ax, by - ay + denom = dx * dx + dy * dy + if denom <= 0.0: + return float(np.hypot(px - ax, py - ay)) + t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / denom)) + return float(np.hypot(px - (ax + t * dx), py - (ay + t * dy))) + + class DominoTaskGenerator(TaskGenerator): """Generates tasks involving domino sequences. @@ -39,6 +53,9 @@ def __init__(self, self.robot = robot self.robot_init_state = robot_init_state self.additional_components = additional_components or [] + # Fan side the last generated chain was aligned to (None when + # domino_fan_aligned_tasks is off, or before the first chain). + self.last_fan_side: Optional[int] = None def generate_tasks( self, @@ -88,6 +105,21 @@ def generate_tasks( return tasks + def _wind_triggered(self) -> bool: + """True when the cascade is started by wind, not by a push. + + A fan is the only additional component that can start a cascade + legitimately without the arm touching a domino, so it decides + both the trigger the certificate sanctions and what the goal + text may ask for. Those two must never disagree: an agent told + to push the green in a fan env has no Push skill to push with, + and every episode it runs is rejected for having no TurnFanOn + on the record. A ball is a second body the robot can throw at + the chain, so ball variants are not wind-triggered. + """ + comp_names = {type(c).__name__ for c in self.additional_components} + return bool(comp_names) and comp_names <= {"FanComponent"} + def _generate_single_task( self, task_idx: int, @@ -107,6 +139,8 @@ def _generate_single_task( straight-only. Ignored on the min-block path, which fills its own quota from the same ratio. """ + if CFG.env == "pybullet_domino_blow": + return self._generate_blow_task(task_idx, rng) if CFG.domino_min_block_tasks: return self._generate_min_block_task(task_idx, rng) @@ -162,6 +196,18 @@ def _generate_single_task( init_dict.update(obj_dict) + # Aim the fan down the chain BEFORE it reports its own init + # state. Doing it at reset instead leaves the task carrying the + # fan's un-aimed coordinate and the reset check comparing two + # different positions ("fan_0.y: requested=1.708000 + # reconstructed=1.386534"). The chain's lateral coordinate is + # known here and nowhere earlier. + chain_lateral = self._chain_lateral(obj_dict) + for component in self.additional_components: + if chain_lateral is not None and hasattr(component, + "set_lateral_alignment"): + component.set_lateral_alignment(chain_lateral) + # Add entries from additional components for component in self.additional_components: if hasattr(component, 'get_init_dict_entries'): @@ -186,14 +232,48 @@ def _generate_single_task( target_word, target_verb = "the purple domino", "is" else: target_word, target_verb = "the purple dominoes", "are" - goal_nl = (f"Arrange the blue dominoes as needed (possibly none) such " - f"that when the green domino is pushed, {target_word} " - f"{target_verb} toppled. Only the blue dominoes may be " - f"rearranged: the green and purple dominoes must stay " - f"untouched at their staged poses, upright and never " - f"held, until the green is pushed, and nothing may " - f"topple before that push. Only the green domino may " - f"ever be pushed.") + fan_only = self._wind_triggered() + # Which skill is sanctioned to start the cascade. The two fan + # envs differ only here: one presses a button, one declares. + if not fan_only: + trigger = "Push" + elif CFG.env == "pybullet_domino_declare": + trigger = "DeclareFinished" + else: + trigger = "TurnFanOn" + if fan_only and trigger == "DeclareFinished": + goal_nl = ( + f"Arrange the blue dominoes as needed (possibly none) such " + f"that once you declare you have finished building, " + f"{target_word} {target_verb} toppled. Only the blue " + f"dominoes may be " + f"rearranged: the green and purple dominoes must stay " + f"untouched at their staged poses, upright and never held, " + f"until you declare finished, and nothing may topple " + f"before that. There is no switch to press and the robot " + f"must never push a domino - declaring finished is the " + f"only thing that starts the cascade.") + elif fan_only: + goal_nl = ( + f"Arrange the blue dominoes as needed (possibly none) such " + f"that when the fan is switched on, {target_word} " + f"{target_verb} toppled. " + f"Only the blue dominoes may be rearranged: the green and " + f"purple dominoes must stay untouched at their staged " + f"poses, upright and never held, until the fan is switched " + f"on, and nothing may topple before that. The robot must " + f"never push a domino - the only way to start the cascade " + f"is to press the fan's switch.") + else: + goal_nl = ( + f"Arrange the blue dominoes as needed (possibly none) such " + f"that when the green domino is pushed, {target_word} " + f"{target_verb} toppled. Only the blue dominoes may be " + f"rearranged: the green and purple dominoes must stay " + f"untouched at their staged poses, upright and never " + f"held, until the green is pushed, and nothing may " + f"topple before that push. Only the green domino may " + f"ever be pushed.") # Cascade-legitimacy evaluator (reward = certified success minus a # per-toppled-blue cost), same as the min-block tasks. Attached only @@ -203,9 +283,15 @@ def _generate_single_task( # would certify it at zero blue cost), and dominoes must be the only # dynamic component (ball/fan variants topple dominoes legitimately # without a robot Push, which the certificate would reject). + # A fan is a dynamic component, but a legitimate one: it + # topples dominoes only through the wind, and the certificate + # can score that as long as it is told the trigger is TurnFanOn + # rather than Push. A BALL is not - it is a second body the + # robot can throw at the chain - so ball variants still get no + # evaluator. evaluator = None if CFG.domino_use_domino_blocks_as_target and \ - not self.additional_components: + (not self.additional_components or fan_only): # Imported lazily: env.py imports this module at load time. # pylint: disable-next=import-outside-toplevel from predicators.envs.pybullet_domino.env import DominoEvaluator @@ -213,7 +299,9 @@ def _generate_single_task( 1 for obj in init_state.get_objects(self.domino.domino_type) # pylint: disable-next=protected-access if DominoComponent._MovableBlock_holds(init_state, [obj])) - evaluator = DominoEvaluator(goal_atoms, num_movables) + evaluator = DominoEvaluator(goal_atoms, + num_movables, + trigger_option_name=trigger) # State the reward structure so a rejected goal-reaching # attempt reads as "no solve bonus", not as a fatal # per-blue penalty: run_20260716_215533 burned its budget @@ -227,13 +315,117 @@ def _generate_single_task( # Same reasoning for the legitimacy rule (see # goal_text.CASCADE_VERIFICATION_NL): an arm-assisted layout # otherwise fails with verdicts the agent cannot explain. - goal_nl += goal_text.CASCADE_VERIFICATION_NL + if not fan_only: + goal_nl += goal_text.CASCADE_VERIFICATION_NL + elif trigger == "DeclareFinished": + goal_nl += goal_text.DECLARE_VERIFICATION_NL + else: + goal_nl += goal_text.WIND_VERIFICATION_NL return EnvironmentTask(init_state, goal_atoms, goal_nl=goal_nl, evaluator=evaluator) + def _generate_blow_task( + self, task_idx: int, + rng: np.random.Generator) -> Optional[EnvironmentTask]: + """Place a block so the wind carries it into the goal patch. + + The scene is one line along the fan's axis: fan (off-table, at + low x, blowing +x), then the goal patch, then the block on its + staging spot downwind of the patch. The robot has to pick the + block up and put it down UPWIND of the patch, far enough back + that the gust delivers it into the patch rather than past it. + + The patch is placed first and the staging spot derived from it, + so the block never starts inside its own goal - which would make + the task solvable by doing nothing at all. + """ + dominos = self.domino.dominos + if not dominos or self._goal_region is None: + return None + x_lb, x_ub = self.domino.domino_x_lb, self.domino.domino_x_ub + y_lb, y_ub = self.domino.domino_y_lb, self.domino.domino_y_ub + + # One lane, chosen away from the workspace edges so both the + # placement band and the staging spot stay reachable. + lane_y = float(rng.uniform(y_lb + 0.05, y_ub - 0.05)) + # The patch sits in the middle third of the run, leaving room + # upwind for the placement band and downwind for staging. + goal_x = float( + rng.uniform(x_lb + 0.30 * (x_ub - x_lb), + x_lb + 0.55 * (x_ub - x_lb))) + self._goal_region.set_region_xy(goal_x, lane_y) + + # The block starts downwind of the patch: the wind blows +x, so + # from here the gust alone can never deliver it. Only a pick and + # a place upwind can. + stage_x = min(x_ub - 0.02, goal_x + 0.18) + if stage_x <= goal_x + 0.10: + return None + + obj_dict: Dict[Object, Dict[str, Any]] = {} + # pi/2 turns the block's WIDE face into the wind. With rotation + # 0 it presents its narrow edge, which a +x gust shoves happily + # and can barely tip - measured: nothing toppled at any force or + # lever until the block was turned to face the wind. + obj_dict[dominos[0]] = self.domino.place_domino(0, + stage_x, + lane_y, + np.pi / 2, + rng=rng, + task_idx=task_idx) + # Every other domino body this env owns is parked out of view: + # the task is about ONE block, and spare bodies on the table + # would be distractors the planner still has to ground. + ox, oy = self.domino.out_of_view_xy + for i in range(1, len(dominos)): + obj_dict[dominos[i]] = self.domino.place_domino(i, + ox + 0.05 * i, + oy, + 0.0, + rng=rng, + task_idx=task_idx) + + init_dict: Dict[Object, Dict[str, Any]] = { + self.robot: self.robot_init_state.copy() + } + init_dict.update(obj_dict) + # Aim the fan down the lane BEFORE its init entries are read. + # Reading them first records the fan at its rail default while + # the body ends up on the lane, and the state then disagrees + # with the world by a third of a metre - the same misalignment + # the cascade generator fixes for the same reason. + for component in self.additional_components: + if hasattr(component, "set_lateral_alignment"): + component.set_lateral_alignment(lane_y) + for component in self.additional_components: + if hasattr(component, "get_init_dict_entries"): + init_dict.update(component.get_init_dict_entries(rng)) + init_state = utils.create_state_from_dict(init_dict) + + goal_atoms = { + GroundAtom(self._goal_region.InGoal, + [dominos[0], self._goal_region.region]) + } + goal_nl = ( + "Pick up the block and put it down so that when the fan is " + "switched on, the wind knocks it over and it ends up lying " + "FLAT inside the green goal region. Putting the block down " + "in the region is not enough - you cannot place it on its " + "side, so only the wind can leave it flat. The fan blows for " + "a limited time once it is switched on.") + return EnvironmentTask(init_state, goal_atoms, goal_nl=goal_nl) + + @property + def _goal_region(self) -> Any: + """The GoalRegionComponent, if this env has one.""" + for component in self.additional_components: + if type(component).__name__ == "GoalRegionComponent": + return component + return None + def _generate_min_block_task( self, task_idx: int, rng: np.random.Generator) -> Optional[EnvironmentTask]: @@ -317,7 +509,51 @@ def _generate_min_block_task( goal_atoms.add(GroundAtom(self.domino.Toppled, [domino_obj])) return EnvironmentTask(init_state, goal_atoms, - goal_nl=goal_text.MIN_BLOCK_GOAL_NL) + goal_nl=(goal_text.MIN_BLOCK_WIND_GOAL_NL + if self._wind_triggered() else + goal_text.MIN_BLOCK_GOAL_NL)) + + # A chain's travel direction is (sin rotation, cos rotation) -- see + # _place_straight_domino -- so rotation is measured from +y, turning + # toward +x. A fan on side_idx blows along its own yaw, world + # (cos yaw, sin yaw): left(0) +x, right(1) -x, back(2) +y, + # front(3) -y. These are those two conventions reconciled, which is + # the only place the fan's frame and the chain's frame meet. + _FAN_SIDE_TO_ROTATION: ClassVar[Dict[int, float]] = { + 0: np.pi / 2, # left fan blows +x + 1: -np.pi / 2, # right fan blows -x + 2: 0.0, # back fan blows +y + 3: np.pi, # front fan blows -y + } + + def _fan_aligned_start(self, rng: np.random.Generator, x_lb: float, + x_ub: float, y_lb: float, + y_ub: float) -> Tuple[float, float, float, int]: + """Start pose and travel direction for a wind-started chain. + + Picks a fan side, points the chain downwind, and puts the start + block in the upwind fifth of that axis so the rest of the chain + has the workspace to run into. Free across the crosswind axis: + the wind is uniform, so where the chain sits sideways does not + change whether it cascades, and varying it keeps the task set + from collapsing onto one line. + """ + # Only sides that actually carry a fan (see + # domino_fan_num_sides); aligning a chain to a fan that is not + # there makes the task unsolvable. + side = int(rng.integers(0, max(1, CFG.domino_fan_num_sides))) + rotation = self._FAN_SIDE_TO_ROTATION[side] + dx, dy = np.sin(rotation), np.cos(rotation) + lead = 0.2 # fraction of the axis reserved upwind of the start + if abs(dx) > abs(dy): # travelling along x + x = (x_lb + lead * (x_ub - x_lb) if dx > 0 else x_ub - lead * + (x_ub - x_lb)) + y = rng.uniform(y_lb, y_ub) + else: # travelling along y + x = rng.uniform(x_lb, x_ub) + y = (y_lb + lead * (y_ub - y_lb) if dy > 0 else y_ub - lead * + (y_ub - y_lb)) + return x, y, rotation, side def _generate_domino_sequence(self, rng: np.random.Generator, @@ -351,10 +587,18 @@ def _generate_domino_sequence(self, def _in_bounds(nx: float, ny: float) -> bool: return x_lb < nx < x_ub and y_lb < ny < y_ub - # Initial position and orientation - x = rng.uniform(x_lb, x_ub) - y = rng.uniform(y_lb, y_ub) - rotation = rng.choice([0, np.pi / 2, -np.pi / 2]) + # Initial position and orientation. A wind-started chain has to + # run downwind from the upwind edge; a robot-pushed one can start + # anywhere and face any of three ways (the fourth, -y, has never + # been in this list). + self.last_fan_side = None + if CFG.domino_fan_aligned_tasks: + x, y, rotation, self.last_fan_side = self._fan_aligned_start( + rng, x_lb, x_ub, y_lb, y_ub) + else: + x = rng.uniform(x_lb, x_ub) + y = rng.uniform(y_lb, y_ub) + rotation = rng.choice([0, np.pi / 2, -np.pi / 2]) gap = self.domino.pos_gap # Place first domino (start block) @@ -924,6 +1168,21 @@ def stage_movable_blocks(self, obj_dict: Dict) -> Optional[Dict]: candidate_xy = [(float(x), float(y)) for y in y_values for x in x_values] + # The corridor the robot has to build through: the segment from + # the start block to the far target. A blue parked inside it is + # not merely untidy - it sits within the gripper's finger sweep + # of a bridge slot, and Place then has no collision-free + # descent. Whether that happens is pure luck about where the + # chain landed: a uniformly-placed chain usually sits to one + # side and leaves whole staging cells free, while a + # wind-ALIGNED chain starts in the upwind fifth and runs through + # the middle of the workspace, straight across the staging row. + # Measured: plain domino parks its blues at x = 0.470 / 0.575 + # against a chain spanning 0.697-0.991 (clear), the fan env at + # 0.470 / 0.680 against 0.540-0.834 - the second one 66 mm from + # a slot, inside a 100 mm finger sweep. + corridor = self._chain_corridor(occupied) + for obj, obj_type in intermediate_objects: placed = False for new_x, new_y in candidate_xy: @@ -949,6 +1208,9 @@ def stage_movable_blocks(self, obj_dict: Dict) -> Optional[Dict]: } if self._placement_collides(obj, candidate, occupied): continue + if corridor is not None and _dist_to_segment( + (new_x, new_y), *corridor) < grasp_clear_finger: + continue if obj_type == "domino" and self._grasp_clearance_blocked( candidate, occupied, grasp_clear_hand, grasp_clear_finger): @@ -962,6 +1224,44 @@ def stage_movable_blocks(self, obj_dict: Dict) -> Optional[Dict]: return obj_dict + def _chain_lateral(self, obj_dict: Dict) -> Optional[float]: + """The y the chain was laid at, or None if there is no chain. + + Only meaningful for a wind-aligned layout, where every block + shares one lateral coordinate; the staged blues sit on their own + row and are excluded by taking the START block's. + """ + for obj, data in obj_dict.items(): + if obj.type != self.domino.domino_type or "y" not in data: + continue + eps = 1e-3 + if all( + abs(data.get(c, 0.0) - + self.domino.start_domino_color[i]) < eps + for i, c in enumerate(("r", "g", "b"))): + return float(data["y"]) + return None + + def _chain_corridor( + self, occupied: Dict[Object, Dict[str, float]] + ) -> Optional[Tuple[Tuple[float, float], Tuple[float, float]]]: + """Endpoints of the line the chain will be built along, or None. + + The fixed blocks at staging time are the start block and the + target(s); the bridge runs between them, so the two extreme + fixed positions bound the corridor. None when fewer than two + are present and there is nothing to keep clear of. + """ + pts = [(float(d["x"]), float(d["y"])) for o, d in occupied.items() + if o.type == self.domino.domino_type and "x" in d] + if len(pts) < 2: + return None + far = max( + ((a, b) for i, a in enumerate(pts) for b in pts[i + 1:]), + key=lambda ab: np.hypot(ab[0][0] - ab[1][0], ab[0][1] - ab[1][1]), + default=None) + return far + def _placement_collides(self, obj: Object, candidate: Dict[str, float], occupied: Dict[Object, Dict[str, float]]) -> bool: """Check whether ``candidate`` overlaps any occupied object.""" diff --git a/predicators/envs/pybullet_domino/task_generators/goal_text.py b/predicators/envs/pybullet_domino/task_generators/goal_text.py index 84c143747..97a979cd0 100644 --- a/predicators/envs/pybullet_domino/task_generators/goal_text.py +++ b/predicators/envs/pybullet_domino/task_generators/goal_text.py @@ -19,6 +19,20 @@ "fingertips made intangible, and the built layout must still cascade " "to the goal - topples that needed the arm's body earn nothing.") +# The wind variant of the same rule. There is no counterfactual replay +# to describe: the probe exists to prove the arm's body did not carry a +# cascade its push started, and a switch pressed metres from the chain +# has no such contact to disprove. What is enforced instead is that a +# TurnFanOn step is actually on the record -- otherwise an episode that +# reached the goal by knocking the chain over while placing a block +# would certify. +WIND_VERIFICATION_NL = ( + " A solve only counts if switching the fan on is what starts the " + "cascade: the episode " + "must carry a TurnFanOn step, nothing may topple before it, and an " + "episode that reaches the goal with no TurnFanOn on the record is " + "rejected.") + MIN_BLOCK_GOAL_NL = ( "Arrange the blue dominoes so that when the green domino is pushed, " "the purple domino is toppled -- using AS FEW blue dominoes as " @@ -28,6 +42,31 @@ "may topple before that push. Only the green domino may ever be " "pushed." + CASCADE_VERIFICATION_NL) +# The declaration variant. Same rule, different named step: there is +# no counterfactual replay for a skill that moves nothing, so what is +# checked is that the declaration is on the record and that nothing +# fell before it. +DECLARE_VERIFICATION_NL = ( + " A solve only counts if the declaration is what starts the " + "cascade: the episode " + "must carry a DeclareFinished step, nothing may topple before it, " + "and an episode that reaches the goal with no DeclareFinished on " + "the record is rejected.") + +# The min-block instruction for a wind-triggered env. Nothing pairs +# domino_min_block_tasks with a fan today, but the two flags are +# independent and the push wording is unfollowable in a fan env, so the +# builder picks between them rather than leaving a trap set. +MIN_BLOCK_WIND_GOAL_NL = ( + "Arrange the blue dominoes so that when the fan is switched on, the " + "wind topples the green domino and the purple domino is toppled -- " + "using AS FEW blue dominoes as possible (possibly none). Only the " + "blue dominoes may be rearranged: the green and purple dominoes must " + "stay untouched at their staged poses, upright and never held, until " + "the fan is switched on, and nothing may topple before that. The " + "robot must never push a domino - the only way to start the cascade " + "is to press the fan's switch." + WIND_VERIFICATION_NL) + HEAVY_GOAL_NL = ( "Arrange the blue dominoes so that when the green domino is pushed, " "the purple domino is toppled -- using AS FEW blue dominoes as " diff --git a/predicators/envs/pybullet_env.py b/predicators/envs/pybullet_env.py index 24d83a624..6e51fe66b 100644 --- a/predicators/envs/pybullet_env.py +++ b/predicators/envs/pybullet_env.py @@ -302,6 +302,11 @@ def __init__(self, # Used by sim-learning to create base-sim-only envs. self._skip_domain_specific_dynamics: bool = skip_residual_dynamics + # The action currently being stepped, for domain dynamics that + # depend on what the skill announced rather than on where the + # arm went (see Action.extra_info). + self._last_action: Optional[Action] = None + # Drives real hardware from this env's rollouts; None means pure sim, # which is what every env built by the planner stays. self._executor: Optional[ActionExecutor] = None @@ -727,6 +732,11 @@ def _step_once(self, action: Action, render_obs: bool = False) -> Observation: """Advance the simulation one action, with no executor involved.""" + # Stashed so _domain_specific_step, which takes no arguments, + # can still read what the action carried. Only envs with a + # signalling skill look at it (see Action.extra_info); the + # rest never touch it. + self._last_action = action self._step_base(action) if not self._skip_domain_specific_dynamics: self._domain_specific_step() diff --git a/predicators/ground_truth_models/domino/options.py b/predicators/ground_truth_models/domino/options.py index c1cb18d88..21deacfed 100644 --- a/predicators/ground_truth_models/domino/options.py +++ b/predicators/ground_truth_models/domino/options.py @@ -4,14 +4,18 @@ from typing import ClassVar, Dict, Optional, Sequence, Set, Tuple from typing import Type as TypingType +import numpy as np from gym.spaces import Box +from predicators import utils from predicators.envs.pybullet_domino import PyBulletDominoEnv from predicators.envs.pybullet_env import PyBulletEnv from predicators.ground_truth_models import GroundTruthOptionFactory from predicators.ground_truth_models.skill_factories import SkillConfig, \ create_pick_skill, create_place_skill, create_push_skill, \ create_wait_option, shared_skill_robot, shared_skill_simulator +from predicators.ground_truth_models.skill_factories.declare import \ + create_declare_option from predicators.ground_truth_models.skill_factories.pick import _PICK_PARAMS from predicators.pybullet_helpers.robots import SingleArmPyBulletRobot from predicators.settings import CFG @@ -36,6 +40,27 @@ def _skill_robot_env_cls(env_name: str) -> TypingType[PyBulletEnv]: return PyBulletDominoEnv +# Envs where the cascade is started by WIND rather than by a push, and +# so where a Wait has to outlast the lull described below. Named +# explicitly because the obvious test - a "_fan" suffix - silently gave +# pybullet_domino_declare the push threshold of 10: it is wind-started +# too, it just does not say so in its name. The agent in +# run_20260831_122006 noticed before I did, and padded its plan with +# extra Waits to compensate. +# Envs where the robot starts the fan by DECLARING it has finished +# rather than by pressing a switch. Named rather than tested by suffix, +# for the same reason as _WIND_STARTED_ENVS below. +_DECLARE_TRIGGER_ENVS = frozenset({ + "pybullet_domino_declare", +}) + +_WIND_STARTED_ENVS = frozenset({ + "pybullet_domino_fan", + "pybullet_domino_declare", + "pybullet_domino_blow", +}) + + class PyBulletDominoGroundTruthOptionFactory(_DominoLegacyOptionsMixin, GroundTruthOptionFactory): """Ground-truth options for the domino environment.""" @@ -56,7 +81,9 @@ class PyBulletDominoGroundTruthOptionFactory(_DominoLegacyOptionsMixin, def get_env_names(cls) -> Set[str]: return { "pybullet_domino_grid", "pybullet_domino", "pybullet_domino_real", - "pybullet_domino_real_geometry" + "pybullet_domino_real_geometry", "pybullet_domino_fan", + "pybullet_domino_declare", + "pybullet_domino_blow" } @classmethod @@ -95,18 +122,113 @@ def _get_options_skill_factories( options: Set[ParameterizedOption] = set() - if CFG.domino_restricted_push: - options.add( - cls._create_sf_push_restricted(cfg, robot_type, domino_type)) - else: - options.add(cls._create_sf_push(cfg, robot_type, domino_type)) + # A fan env withholds Push on purpose. The wind is what starts + # the chain there, so leaving the robot a shove makes the fan + # decorative: the planner takes the cheaper Push every time and + # solves a wind task without ever touching a switch. Detected by + # the switch type, which only a FanComponent contributes. + if "switch" not in types: + if CFG.domino_restricted_push: + options.add( + cls._create_sf_push_restricted(cfg, robot_type, + domino_type)) + else: + options.add(cls._create_sf_push(cfg, robot_type, domino_type)) options.add(cls._create_sf_pick(cfg, robot_type, domino_type)) options.add(cls._create_sf_place(cfg, robot_type)) options.add(create_wait_option("Wait", cfg, robot_type)) + # A composed env carrying a FanComponent brings switches with + # it, and without a skill to start the fan it can never be + # turned on: every plan in a fan env starts there. Absent in + # the plain domino envs, whose types have no switch. + # + # HOW the fan starts is the difference between the two fan + # envs. pybullet_domino_fan gives the robot a button and a push + # skill to press it. pybullet_domino_declare parks the switch + # outside the workspace and the robot instead DECLARES it has + # finished building - no contact, nothing to reach around, and + # for a learner nothing mechanical to credit the wind to. + if "switch" in types: + if CFG.env in _DECLARE_TRIGGER_ENVS: + options.add( + create_declare_option("DeclareFinished", cfg, robot_type)) + else: + options |= cls._create_sf_switch_options( + cfg, robot_type, types["switch"], types.get("fan")) + return options + @classmethod + def _create_sf_switch_options( + cls, cfg: SkillConfig, robot_type: Type, switch_type: Type, + fan_type: Optional[Type]) -> Set[ParameterizedOption]: + """Press a switch on or off. + + A switch is pressed by pushing at its pose, so these are plain + push skills with the target taken from the switch - the same + construction ``fan/options.py`` uses, including its yaw + correction: a push skill faces (sin yaw, cos yaw) while a switch + reports its push direction as (cos rot, sin rot), so the two + conventions differ by a quarter turn, and on and off are that + quarter turn either side. + + Under ``fan_known_controls_relation`` the second argument is the + FAN, not the switch: the env hides SwitchOn/SwitchOff in that + mode and speaks only of FanOn/FanOff, so a process written over + fans needs an option it can share variables with. The switch is + then found from the fan, by the side it controls. + """ + known = CFG.fan_known_controls_relation and fan_type is not None + control_type = fan_type if known else switch_type + assert control_type is not None + option_types = [robot_type, control_type] + + def _switch_of(state: State, control: Object) -> Object: + if not known: + return control + switch = next( + (sw for sw in state.get_objects(switch_type) if state.get( + sw, "controls_fan") == state.get(control, "facing_side")), + None) + if switch is None: + raise utils.OptionExecutionFailure( + "No switch found for fan (controls_fan mismatch)") + return switch + + def _pose(state: State, objects: Sequence[Object], + sign: float) -> Tuple[float, float, float, float]: + _, control = objects + switch = _switch_of(state, control) + return (state.get(switch, + "x"), state.get(switch, + "y"), state.get(switch, "z"), + state.get(switch, "rot") + sign * np.pi / 2) + + def _on_pose(state: State, objects: Sequence[Object], params: Array, + config: SkillConfig) -> Tuple[float, float, float, float]: + del params, config + return _pose(state, objects, -1.0) + + def _off_pose( + state: State, objects: Sequence[Object], params: Array, + config: SkillConfig) -> Tuple[float, float, float, float]: + del params, config + return _pose(state, objects, +1.0) + + push_cfg = replace(cfg, transport_z=cls._transport_z_push) + return { + create_push_skill(name="TurnFanOn", + types=option_types, + config=push_cfg, + get_target_pose_fn=_on_pose), + create_push_skill(name="TurnFanOff", + types=option_types, + config=push_cfg, + get_target_pose_fn=_off_pose), + } + @classmethod def _build_skill_config( cls, @@ -149,6 +271,15 @@ def _build_skill_config( # every rollout - the cap dominated probe/validation wall # time in the 2026-07-17 run audits. wait_quiescence_eps=1e-4, + # A push-started cascade runs without pause, so 10 quiet + # steps means it is over. A WIND-started one has a lull + # built into it: the fan cuts out the moment the start + # block is down (a fallen domino is out of the airstream), + # and the chain then coasts on contact alone. Ten steps of + # that reads as settled and ends the Wait mid-cascade - + # measured at 35 steps against the ~70 the chain needs. + wait_quiescence_steps=(40 if CFG.env in _WIND_STARTED_ENVS + else 10), ) @classmethod diff --git a/predicators/ground_truth_models/domino/predicates.py b/predicators/ground_truth_models/domino/predicates.py index ede5294d2..cff93a0ab 100644 --- a/predicators/ground_truth_models/domino/predicates.py +++ b/predicators/ground_truth_models/domino/predicates.py @@ -6,10 +6,11 @@ single source of truth. """ -from typing import Dict, Set +from typing import Dict, Sequence, Set from predicators.ground_truth_models import GroundTruthPredicateFactory -from predicators.structs import Predicate, Type +from predicators.settings import CFG +from predicators.structs import Object, Predicate, State, Type class PyBulletDominoGroundTruthPredicateFactory(GroundTruthPredicateFactory): @@ -19,7 +20,9 @@ class PyBulletDominoGroundTruthPredicateFactory(GroundTruthPredicateFactory): def get_env_names(cls) -> Set[str]: return { "pybullet_domino", "pybullet_domino_real", - "pybullet_domino_real_geometry" + "pybullet_domino_real_geometry", "pybullet_domino_fan", + "pybullet_domino_declare", + "pybullet_domino_blow" } @classmethod @@ -31,8 +34,80 @@ def get_helper_predicates(cls, env_name: str, grid predicates. Only oracle / process-planning approaches consume these helpers; agent approaches run grid-free. """ - del env_name # unused + if env_name == "pybullet_domino_blow": + return _blow_helper_predicates(types) from predicators.envs.pybullet_domino.components.grid_component import \ GridComponent # pylint: disable=import-outside-toplevel return GridComponent(domino_type=types["domino"]).get_predicates() + + +# ── Blow task: the one thing the oracle knows and a learner must not ── + + +def _blow_slide_distance() -> float: + """How far this gust carries the block, in metres. + + A ground-truth constant, fitted from the env's own wind force by the + curve measured in settings.py (1.8 N -> 5.8 cm, 2.5 -> 11.5, 3.2 -> + 19.3): slide grows steeply and monotonically with force, which is + the property that makes this task's parameter learnable at all. + + This lives in the ORACLE's helper predicates, never in the env's, so + an agent approach cannot read it off the state. Knowing it is the + whole content of the task. + """ + # A quadratic least-squares fit to the measured curve over the + # 30-step gust, from 1.5 to 3.5 N (12.02 / 14.35 / 16.33 / 20.33 / + # 26.36 cm). Cheaper and clearer than shipping a table, and it + # extrapolates sensibly for a task generator that varies the force. + force = CFG.domino_blow_wind_force + return max(0.0, 0.02691 * force * force - 0.06525 * force + 0.16024) + + +def _blow_helper_predicates(types: Dict[str, Type]) -> Set[Predicate]: + """``ReadyToBlow``: the block is where the gust will deliver it. + + The oracle plans Place -> DeclareFinished -> Wait, and this is the + predicate that makes the Place worth doing: it is true exactly when + the block sits one slide-length upwind of the goal patch, within the + patch's own tolerance. The wind process then turns it into InGoal. + """ + domino_type = types["domino"] + region_type = types["region"] + + def _ready_holds(state: State, objects: Sequence[Object]) -> bool: + """The block is somewhere the gust can still deliver it. + + A CORRIDOR, from one slide-length upwind of the patch through to + the patch's far edge, rather than the single point the placement + aims at. Written as a point it was true only at the instant of + release: the wind then moved the block, the atom flipped, and + the Wait terminated after three steps with "atom change during + Wait" - killing the gust a twentieth of the way through its own + flight. It has to stay true while the thing it describes is + happening. + + Loosening it costs nothing, because it is not what makes the + task hard. The goal demands the block end up FLAT in the patch, + and no placement anywhere in this corridor achieves that on its + own. + """ + domino, region = objects + if state.get(domino, "is_held") > 0.5: + return False + gx = float(state.get(region, "x")) + half_x = float(state.get(region, "half_x")) + half_y = float(state.get(region, "half_y")) + # The fan blows +x, so the block travels from upwind toward the + # patch. Half a patch of slack at the upwind end is the + # placement tolerance; the far edge closes the corridor. + lo = gx - _blow_slide_distance() - half_x + hi = gx + half_x + x = float(state.get(domino, "x")) + dy = abs(float(state.get(domino, "y")) - float(state.get(region, "y"))) + return lo <= x <= hi and dy <= half_y + + return { + Predicate("ReadyToBlow", [domino_type, region_type], _ready_holds) + } diff --git a/predicators/ground_truth_models/domino/processes.py b/predicators/ground_truth_models/domino/processes.py index 19e139f20..0750348cb 100644 --- a/predicators/ground_truth_models/domino/processes.py +++ b/predicators/ground_truth_models/domino/processes.py @@ -97,6 +97,39 @@ def _push_sampler(state: State, goal: Set[GroundAtom], dtype=np.float32) +def _declare_sampler(state: State, goal: Set[GroundAtom], + rng: np.random.Generator, + objs: Sequence[Object]) -> Array: + """No parameters: a declaration has nothing to aim. + + Its option's params_space is empty, so an empty array is what the + option expects. Written out rather than reaching for null_sampler + to keep the contrast with _switch_push_sampler on the page: the + press needs an approach distance and a contact offset because it + has to arrive somewhere, and this does not. + """ + del state, goal, rng, objs + return np.array([], dtype=np.float32) + + +def _switch_push_sampler(state: State, goal: Set[GroundAtom], + rng: np.random.Generator, + objs: Sequence[Object]) -> Array: + """Approach distance and contact offset for pressing a switch. + + TurnFanOn is a push skill, so it wants the same two params every + push does - null_sampler hands it an empty array and the option's + clip against a 2-vector bounds raises. The values are + fan/processes.py's, measured there against this same switch model: + 0.075 clears an end-of-row switch on the approach without stalling + at the arm's reach on the far-side press. + """ + del state, goal, rng, objs + if not CFG.domino_use_skill_factories: + return np.array([], dtype=np.float32) + return np.array([0.075, 0.1], dtype=np.float32) + + def _place_sampler(state: State, goal: Set[GroundAtom], rng: np.random.Generator, objs: Sequence[Object]) -> Array: """Return a generator-faithful placement for the open-loop oracle. @@ -167,7 +200,9 @@ class PyBulletDominoGroundTruthProcessFactory(GroundTruthProcessFactory): def get_env_names(cls) -> Set[str]: return { "pybullet_domino_grid", "pybullet_domino", "pybullet_domino_real", - "pybullet_domino_real_geometry" + "pybullet_domino_real_geometry", "pybullet_domino_fan", + "pybullet_domino_declare", + "pybullet_domino_blow" } @classmethod @@ -176,7 +211,11 @@ def get_processes( Type], predicates: Dict[str, Predicate], options: Dict[str, ParameterizedOption]) -> Set[CausalProcess]: - del env_name # unused + if env_name == "pybullet_domino_blow": + # A different task, so a different model rather than the + # cascade one with pieces disabled: no chain, no topple, no + # grid. One block, one gust, one patch to land it in. + return _get_blow_processes(types, predicates, options) # These processes are defined over the grid (loc/angle/direction). # Only oracle / process-planning approaches request them, and they do @@ -208,8 +247,9 @@ def get_processes( # We would need to add it to the environment for the DominoFall # exogenous process - # Options - Push = options["Push"] + # Options. Push is absent in a fan env (see below), so it is + # looked up defensively rather than by subscript. + Push = options.get("Push") Pick = options["Pick"] Place = options["Place"] Wait = options["Wait"] @@ -229,7 +269,6 @@ def get_processes( option_vars = [robot] else: option_vars = [robot, domino] - option = Push condition_at_start = { LiftedAtom(HandEmpty, [robot]), LiftedAtom(StartBlock, [domino]), @@ -244,12 +283,16 @@ def get_processes( ignore_effects = {DominoAtPos, DominoAtRot, PosClear, AdjacentTo} delay_distribution = DiscreteGaussianDelay(mu=torch.tensor(1.0), sigma=torch.tensor(0.1)) - push_start_block_process = EndogenousProcess( - "PushStartBlock", parameters, condition_at_start, set(), - set(), add_effects, delete_effects, delay_distribution, - torch.tensor(1.0), option, option_vars, _push_sampler, - ignore_effects) - processes.add(push_start_block_process) + if Push is not None: + push_start_block_process = EndogenousProcess( + "PushStartBlock", parameters, condition_at_start, set(), set(), + add_effects, delete_effects, delay_distribution, + torch.tensor(1.0), Push, option_vars, _push_sampler, + ignore_effects) + # Withheld in a fan env, matching the option: the wind starts + # the chain there, and a planner left a Push will use it and + # never touch a switch. + processes.add(push_start_block_process) # PickDomino: Position-based pick process robot = Variable("?robot", robot_type) @@ -394,6 +437,117 @@ def get_processes( delay_distribution, torch.tensor(1.0)) processes.add(domino_tilting_delete_process) + # --- Wind, when the env has fans ------------------------------- + # A composed env carrying a FanComponent brings switches and + # fans; a plain domino env does not, and its predicate dict has + # none of these names. + if "FanOn" in predicates: + processes |= cls._get_fan_processes(types, predicates, options) + + return processes + + @classmethod + def _get_fan_processes( + cls, types: Dict[str, Type], predicates: Dict[str, Predicate], + options: Dict[str, ParameterizedOption]) -> Set[CausalProcess]: + """Turning a fan on, and the wind that follows. + + Two processes are enough, and that is the point. The fan env + needs a grid because a ball's whole trajectory is wind; a domino + chain's is not. Only the FIRST block is pushed by the wind - + ``DominoFallFromBeingInFrontOfTilting`` and + ``DominoTiltingDelete`` above carry the cascade from there. So + the wind needs exactly one bridging rule into the vocabulary the + chain already speaks. + + Written over FANS rather than switches because that is the + vocabulary the env exposes: under + ``fan_known_controls_relation`` FanComponent hides + SwitchOn/SwitchOff and publishes FanOn/FanOff, and the switch is + an implementation detail the option resolves for itself. + """ + robot_type = types["robot"] + domino_type = types["domino"] + fan_type = types["fan"] + + FanOn = predicates["FanOn"] + FanOff = predicates["FanOff"] + Upright = predicates["Upright"] + StartBlock = predicates["InitialBlock"] + Tilting = predicates["Tilting"] + + processes: Set[CausalProcess] = set() + + # Starting the fan. Endogenous either way: the robot does it. + # What differs between the two fan envs is only HOW, and so + # what the process is grounded on. + robot = Variable("?robot", robot_type) + fan = Variable("?fan", fan_type) + if CFG.env == "pybullet_domino_declare": + # The robot announces it has finished building and the fan + # starts. The option takes only the robot -- there is + # nothing to reach for -- so the fan appears in the + # process's variables and its effects but NOT in the + # option's arguments. That split is the whole content of + # this env: an effect with no contact to explain it. + processes.add( + EndogenousProcess( + "DeclareFinished", [robot, fan], + {LiftedAtom(FanOff, [fan])}, set(), set(), + {LiftedAtom(FanOn, [fan])}, {LiftedAtom(FanOff, [fan])}, + DiscreteGaussianDelay(mu=torch.tensor(1.0), + sigma=torch.tensor(0.1)), + torch.tensor(1.0), options["DeclareFinished"], [robot], + _declare_sampler)) + else: + processes.add( + EndogenousProcess( + "TurnFanOn", [robot, fan], {LiftedAtom(FanOff, [fan])}, + set(), set(), {LiftedAtom(FanOn, [fan])}, + {LiftedAtom(FanOff, [fan])}, + DiscreteGaussianDelay(mu=torch.tensor(1.0), + sigma=torch.tensor(0.1)), + torch.tensor(1.0), options["TurnFanOn"], [robot, fan], + _switch_push_sampler)) + + # The wind. Exogenous: nobody chooses it, it follows from the + # fan being on. + # + # Deliberately NOT conditioned on which way the fan faces, the + # way pybullet_fan's MoveToSide is. That needs a direction + # vocabulary the domino side has no use for, and the tasks are + # generated with the chain already laid along one fan's axis + # (domino_fan_aligned_tasks), so a fan press and a topple are + # one-to-one here. A task set where the planner had to CHOOSE a + # fan would need it, and this is where it would go. + # + # Effects mirror PushStartBlock exactly - Tilting added, Upright + # deleted - because the two are the same event reached two ways, + # and a rule that left Upright asserted could fire forever. + domino = Variable("?domino", domino_type) + fan2 = Variable("?fan", fan_type) + conds = { + LiftedAtom(FanOn, [fan2]), + LiftedAtom(StartBlock, [domino]), + LiftedAtom(Upright, [domino]), + } + # Delay 0: the block goes the moment the fan comes on. Anything + # longer opens a window the planner will use - at mu=2 it + # ordered TurnFanOn FOURTH of six and went on placing dominoes + # afterwards, believing it could finish the bridge while the + # start block was mid-topple. It cannot: a Place runs ~19 env + # steps and the topple is over in a fraction of that. With no + # window, InFront has to already hold when the fan is switched + # on, which forces the press to come last - the ordering the + # task actually has. + processes.add( + ExogenousProcess( + "WindTopplesStartBlock", [domino, fan2], conds, set(), set(), + {LiftedAtom(Tilting, [domino])}, + {LiftedAtom(Upright, [domino])}, + DiscreteGaussianDelay(mu=torch.tensor(0.0), + sigma=torch.tensor(0.1)), + torch.tensor(1.0))) return processes @@ -639,7 +793,9 @@ class PyBulletDominoGroundTruthSamplerFactory(GroundTruthSamplerFactory): def get_env_names(cls) -> Set[str]: return { "pybullet_domino_grid", "pybullet_domino", "pybullet_domino_real", - "pybullet_domino_real_geometry" + "pybullet_domino_real_geometry", "pybullet_domino_fan", + "pybullet_domino_declare", + "pybullet_domino_blow" } @classmethod @@ -650,3 +806,172 @@ def get_samplers(cls, env_name: str) -> Dict[str, ParameterizedSampler]: "Push": _push_option_sampler, "Place": _place_option_sampler, } + + +# ── Blow task: pick, place upwind, declare, and let the wind deliver ── + + +def _blow_place_sampler(state: State, subgoal_atoms: Set[GroundAtom], + rng: np.random.Generator, + objects: Sequence[Object]) -> Array: + """Put the block one slide-length upwind of the goal patch. + + The oracle's whole advantage in this env is this number. A learner + has to recover it from watching blocks slide; here it is read + straight off the ground-truth curve. + """ + del subgoal_atoms, objects + # pylint: disable-next=import-outside-toplevel + from predicators.ground_truth_models.domino.predicates import \ + _blow_slide_distance + regions = [o for o in state if o.type.name == "region"] + held = [ + o for o in state + if o.type.name == "domino" and state.get(o, "is_held") > 0.5 + ] + if not regions or len(held) != 1: + raise ValueError("blow place sampler: need a region and a held block") + region = regions[0] + x = float(state.get(region, "x")) - _blow_slide_distance() + y = float(state.get(region, "y")) + # A hair of jitter so backtracking can re-draw rather than retrying + # an identical pose, kept well inside the patch's own tolerance. + x += float(rng.uniform(-0.005, 0.005)) + # The canonical release height, NOT the held block's current z: the + # Place option's release_z is where the GRIPPER opens (its declared + # range is 0.5-0.6), and the block's carried z is neither that nor + # inside it, so every refinement was asking for a drop the skill + # could not make. + # yaw pi/2 puts the block's WIDE face into the wind. Dropping it at + # yaw 0 leaves the narrow edge facing the gust, which the wind + # creeps along without ever tipping: measured 5.0 cm and roll 0.000 + # where the same force on a turned block gives 14.4 cm and flat. + # The generator stages the block turned; the placement has to keep + # it that way. + return np.array([x, y, _DOMINO_DROP_Z, np.pi / 2], dtype=np.float32) + + +def _get_blow_processes( + types: Dict[str, Type], predicates: Dict[str, Predicate], + options: Dict[str, ParameterizedOption]) -> Set[CausalProcess]: + """Pick, place upwind, declare, and let the wind carry the block. + + Four processes and no grid. The one that matters is the last: the + wind is EXOGENOUS - the robot does not carry the block into the + goal, it arranges the world so that the wind will, and then says it + is done. That is the shape of the whole task, and it is why the + placement has to be right rather than merely somewhere. + """ + robot_type = types["robot"] + domino_type = types["domino"] + fan_type = types["fan"] + region_type = types["region"] + + HandEmpty = predicates["HandEmpty"] + Holding = predicates["Holding"] + FanOn = predicates["FanOn"] + FanOff = predicates["FanOff"] + ReadyToBlow = predicates["ReadyToBlow"] + InGoal = predicates["InGoal"] + + robot = Variable("?robot", robot_type) + block = Variable("?block", domino_type) + fan = Variable("?fan", fan_type) + region = Variable("?region", region_type) + + processes: Set[CausalProcess] = set() + + # Predicates a pick or a place disturbs incidentally. Lifting a + # block off the table changes whether it is Upright and whether it + # is where the wind would take it; a process that does not declare + # those as ignorable is rejected in refinement for effects it never + # claimed, which is what stalled every skeleton at step 0. + Upright = predicates["Upright"] + incidental = {Upright, ReadyToBlow, InGoal} + + # Pick the block up. + processes.add( + EndogenousProcess( + "PickBlock", [robot, block], {LiftedAtom(HandEmpty, [robot])}, + set(), set(), {LiftedAtom(Holding, [robot, block])}, + {LiftedAtom(HandEmpty, [robot])}, + DiscreteGaussianDelay(mu=torch.tensor(4.0), + sigma=torch.tensor(0.1)), + torch.tensor(1.0), options["Pick"], [robot, block], + _pick_sampler, incidental)) + + # Put it down one slide-length upwind of the patch. + processes.add( + EndogenousProcess( + "PlaceUpwind", [robot, block, region], + {LiftedAtom(Holding, [robot, block])}, set(), set(), { + LiftedAtom(HandEmpty, [robot]), + LiftedAtom(ReadyToBlow, [block, region]) + }, {LiftedAtom(Holding, [robot, block])}, + DiscreteGaussianDelay(mu=torch.tensor(3.0), + sigma=torch.tensor(0.1)), + torch.tensor(1.0), options["Place"], [robot], + _blow_place_sampler, incidental)) + + # Press the switch, and the fan starts. HandEmpty is a precondition + # and not decoration: without it the planner is free to press while + # still holding the block, and its first skeleton did exactly that - + # PickBlock, , PlaceUpwind - which blows the gust across an + # empty table while the arm is still carrying the thing it was + # supposed to move. + processes.add( + EndogenousProcess( + "TurnFanOn", [robot, fan, block, region], { + LiftedAtom(FanOff, [fan]), + LiftedAtom(HandEmpty, [robot]), + # The switch is only worth pressing once the block is + # where the gust can deliver it. HandEmpty alone is true + # at t=0, so without this the planner's first skeleton + # pressed the switch before it had even picked the block + # up and blew the gust across an empty table. + LiftedAtom(ReadyToBlow, [block, region]) + }, set(), set(), {LiftedAtom(FanOn, [fan])}, + {LiftedAtom(FanOff, [fan])}, + DiscreteGaussianDelay(mu=torch.tensor(1.0), + sigma=torch.tensor(0.1)), + torch.tensor(1.0), options["TurnFanOn"], [robot, fan], + _switch_push_sampler, incidental)) + + # The gust. Exogenous: the robot never carries the block in. + # condition_overall as well as condition_at_start: the gust only + # delivers the block if the fan STAYS on and the block STAYS where + # it was put for the whole flight, which is what the cascade's own + # exogenous processes assert too. + wind_conditions = { + LiftedAtom(FanOn, [fan]), + LiftedAtom(ReadyToBlow, [block, region]) + } + processes.add( + ExogenousProcess( + "WindCarriesToGoal", [fan, block, region], wind_conditions, + wind_conditions.copy(), set(), + {LiftedAtom(InGoal, [block, region])}, set(), + # Delay is in PROCESS steps, not simulator steps. Handing it + # the gust's 60 simulator steps put the effect beyond the + # planner's lookahead and every skeleton was exhausted + # without the goal ever becoming true. The cascade's own + # exogenous processes use 1-4 for the same reason. + DiscreteGaussianDelay(mu=torch.tensor(12.0), + sigma=torch.tensor(0.5)), + torch.tensor(1.0))) + + # Wait. No preconditions, no effects: it exists so the planner can + # let TIME pass. The gust needs about sixty simulator steps to carry + # the block, and without a Wait in the skeleton the episode ends the + # instant the robot finishes speaking. + # The Wait's ignore_effects are the point of the Wait. Everything + # this task is about happens DURING it - the block tips, slides and + # arrives - and a Wait that does not declare those changes ignorable + # is cut short by its own executor with "atom change during Wait". + processes.add( + EndogenousProcess("Wait", [robot], set(), set(), set(), set(), set(), + ConstantDelay(1), torch.tensor(1.0), + options["Wait"], [robot], null_sampler, + incidental)) + + return processes diff --git a/predicators/ground_truth_models/domino/types.py b/predicators/ground_truth_models/domino/types.py index a01524293..b5d70e803 100644 --- a/predicators/ground_truth_models/domino/types.py +++ b/predicators/ground_truth_models/domino/types.py @@ -19,7 +19,9 @@ class PyBulletDominoGroundTruthTypeFactory(GroundTruthTypeFactory): def get_env_names(cls) -> Set[str]: return { "pybullet_domino", "pybullet_domino_real", - "pybullet_domino_real_geometry" + "pybullet_domino_real_geometry", "pybullet_domino_fan", + "pybullet_domino_declare", + "pybullet_domino_blow" } @classmethod diff --git a/predicators/ground_truth_models/domino_fan/__init__.py b/predicators/ground_truth_models/domino_fan/__init__.py new file mode 100644 index 000000000..e4c8aefc3 --- /dev/null +++ b/predicators/ground_truth_models/domino_fan/__init__.py @@ -0,0 +1,7 @@ +"""Ground-truth models for the ball-free domino-fan environment.""" + +from .gt_simulator import PyBulletDominoFanGroundTruthSimulatorFactory + +__all__ = [ + "PyBulletDominoFanGroundTruthSimulatorFactory", +] diff --git a/predicators/ground_truth_models/domino_fan/gt_simulator.py b/predicators/ground_truth_models/domino_fan/gt_simulator.py new file mode 100644 index 000000000..83f8e70eb --- /dev/null +++ b/predicators/ground_truth_models/domino_fan/gt_simulator.py @@ -0,0 +1,207 @@ +"""Ground-truth simulator program for pybullet_domino_fan residual dynamics. + +The counterpart of ``fan/gt_simulator.py`` for the ball-free domino-fan +env: while a fan is on, the rule pushes the START domino along that +fan's facing direction and the base sim's engine owns everything +downstream -- the topple, the contact with the next block, the whole +cascade. Only the first block is wind-driven, which is what makes this +program so much smaller than the grid machinery ``pybullet_fan`` needs +for a ball whose entire trajectory is wind. + +Two things differ from the ball's wind, and both are forced by the fact +that a domino stands on a narrow base rather than rolling. + +**The push has to act above the centre of mass.** A force through the +centre is pure translation: the domino slides across the table +indefinitely and never tips, which is how a start block ends up +bulldozing into the target and "solving" a bridge task with none of the +bridge built. ``ApplyForce`` is centre-of-mass only by construction, so +the offset is expressed the way statics says it decomposes -- the same +force at the centre, plus the moment it would have made about it: + + r x F with r = (0, 0, lever), F = (Fx, Fy, 0) + = (-lever * Fy, lever * Fx, 0) + +a torque about the horizontal axis perpendicular to the wind, which is +exactly the tipping moment. Emitting both is equivalent to applying the +force at ``lever`` above the origin, and keeps the whole dynamic inside +the residual-command vocabulary an agent-synthesized simulator can use. + +**The wind stops when its target falls.** A standing domino presents +its face to the airstream; a fallen one lies flat, out of it. Without +this the fan goes on shoving a body that is already down. The threshold +is read from ``DominoComponent`` rather than restated, so the dynamics +stop exactly where the ``Toppled`` symbol flips. + +Both constants are fitted parameters, not fixed geometry: ``wind_force`` +and ``wind_lever`` are what a system-ID pass has to recover from watching +dominoes fall, and neither is observable from a single state. + +Because commands act through engine stepping, this artifact is scored +and fit by free-running rollout matching (``has_physics_rules`` +routing), never teacher-forced. +""" + +from __future__ import annotations + +from typing import Dict, List + +import numpy as np + +from predicators.code_sim_learning.commands import CommandBuffer +from predicators.code_sim_learning.fit_space import ParamSpec +from predicators.code_sim_learning.utils import Params, ResidualUpdate, \ + objs_by_type +from predicators.ground_truth_models import GroundTruthSimulatorFactory +from predicators.settings import CFG +from predicators.structs import Object, State + +# ── Constants ──────────────────────────────────────────────────── + +# Starting guess for the wind force (N). NOT the true value: the env +# blows at CFG.domino_fan_wind_force, and recovering that number from +# rollouts is the whole job of the parameter-learning rung. A 100 g +# domino 15 mm thick and 150 mm tall, pushed at 0.4 of its height, tips +# at m*g*(depth/2)/(0.4*height) = 0.123 N, so this init clears tipping +# with the same margin the ball's 0.06 N keeps over its stiction. +WIND_FORCE = 0.2 + +# Height above the domino's origin the wind effectively acts at (m), +# 0.4 * domino_height. Fitted init: the real lever depends on where the +# airstream meets the face, which no state feature reports. +WIND_LEVER = 0.06 + +# Past this roll the domino is down and out of the airstream. Read +# from DominoComponent rather than restated, so it cannot drift from +# the Toppled predicate the way a hardcoded 10.0 did (the predicate's +# threshold is 5 degrees). + +# Below this the summed wind is nothing (N). Opposing fans cancel to +# floating-point dust rather than to exact zero -- four fans at 0.2 N +# sum to ~1e-16 -- and an exact == 0.0 test lets that dust through as a +# force and torque command on every step of every action. +WIND_EPS = 1e-9 + + +def _is_start_block(state: State, domino: Object) -> bool: + """Green means the chain starts here. + + Roles in this env are colours, not a feature: the generator paints + the start block and the classifiers read it back. Kept in sync with + ``DominoComponent._StartBlock_holds`` by construction -- both + compare the same three channels against the same constant. + """ + from predicators.envs.pybullet_domino.components.domino_component import \ + DominoComponent # pylint: disable=import-outside-toplevel + eps = 1e-3 + return all( + abs( + float(state.get(domino, c)) - + DominoComponent.start_domino_color[i]) < eps + for i, c in enumerate(("r", "g", "b"))) + + +def _upright(state: State, domino: Object) -> bool: + """Still standing, so still in the wind. + + Roll is only meaningful modulo pi -- a box turned 180 degrees about + its own width axis is the same box -- so it is folded before being + compared, exactly as the env's own check does it. + """ + roll = float(state.get(domino, "roll")) + roll = (roll + np.pi / 2) % np.pi - np.pi / 2 + # pylint: disable-next=import-outside-toplevel + from predicators.envs.pybullet_domino.components.domino_component import \ + DominoComponent + return abs(roll) < DominoComponent.domino_roll_threshold + + +def _wind_topples_start_block(state: State, updates: ResidualUpdate, + params: Params, + cmds: CommandBuffer) -> ResidualUpdate: + """Each on-fan pushes the standing start domino along its local +X. + + Simultaneous fans sum as force vectors, so two facing each other + cancel to nothing -- the physical reading of ``pybullet_fan``'s + ``FanOn(f) and FanOff(opposite(f))`` precondition, and the reason a + plan that switches on both sides moves nothing at all. + """ + objs = objs_by_type(state) + dominos = objs.get("domino", []) + if not dominos: + return updates + + sign = -1.0 if CFG.fan_fans_blow_opposite_direction else 1.0 + fx = fy = 0.0 + for fan in objs.get("fan", []): + if state.get(fan, "is_on") <= 0.5: + continue + rot = float(state.get(fan, "rot")) + fx += sign * float(np.cos(rot)) * params["wind_force"] + fy += sign * float(np.sin(rot)) * params["wind_force"] + if abs(fx) < WIND_EPS and abs(fy) < WIND_EPS: + return updates + + lever = params["wind_lever"] + for domino in dominos: + if not _is_start_block(state, domino) or not _upright(state, domino): + continue + cmds.apply_force(domino, (fx, fy, 0.0)) + # The moment that same force would have made about the centre + # had it been applied ``lever`` higher up; see the module + # docstring for the cross product. + cmds.apply_torque(domino, (-lever * fy, lever * fx, 0.0)) + return updates + + +# ── Public API: consumed by read_simulator_components ──────────── +# Same contract used by agent-synthesized simulator files. + +RESIDUAL_RULES = [_wind_topples_start_block] + +# The search box the fit is allowed to look in. It MUST contain the +# env's true values or the rung is unwinnable by construction: hi=1.0 +# excluded the env's 1.5 N, and run_20260829_110429 spent three cycles +# creeping 0.2251 -> 0.2229 and scoring 0/1 every time, because the +# answer was outside the box. The bound is now set from the env's own +# setting with room either side, so recalibrating the wind cannot +# silently strand the fit again. +_FORCE_HI = max(3.0, 2.0 * CFG.domino_fan_wind_force) + +PARAM_SPECS: List[ParamSpec] = [ + ParamSpec("wind_force", WIND_FORCE, lo=0.0, hi=_FORCE_HI), + # 0.4 * domino_height = 0.06 for the default 0.15 m domino, so the + # true lever sits mid-box here. + ParamSpec("wind_lever", WIND_LEVER, lo=0.0, hi=0.15), +] + +# Features the wind dynamics own. Scored by the rollout objective +# against observations; NOT overwritten at plan time - the engine moves +# them under the emitted force and torque. Roll is in scope because the +# effect being modelled is a topple, not a shove: a program that got the +# force right and the lever wrong slides the block instead of tipping +# it, and only roll separates the two. +RESIDUAL_FEATURES: Dict[str, List[str]] = { + "domino": ["x", "y", "roll"], +} + +# ── Factory binding ────────────────────────────────────────────── + + +class PyBulletDominoFanGroundTruthSimulatorFactory(GroundTruthSimulatorFactory + ): + """GT residual-dynamics simulator for the ball-free domino-fan envs. + + Both of them: the wind is the same physics whether a button press + or a declaration turned the fan on. What starts the fan is a + process, not dynamics. + + The simulator components (``RESIDUAL_RULES``, ``PARAM_SPECS``, + ``RESIDUAL_FEATURES``) live as module globals above; this class only + pins the env-name binding so ``get_gt_simulator`` can locate the + right module via the factory registry. + """ + + @classmethod + def get_env_names(cls) -> set: + return {"pybullet_domino_fan", "pybullet_domino_declare"} diff --git a/predicators/ground_truth_models/skill_factories/declare.py b/predicators/ground_truth_models/skill_factories/declare.py new file mode 100644 index 000000000..b528f0bcf --- /dev/null +++ b/predicators/ground_truth_models/skill_factories/declare.py @@ -0,0 +1,114 @@ +"""Declare-finished: a skill whose whole effect is that it was run. + +The robot says "I am done building" and something in the world reacts. +It moves nothing, touches nothing, and its only trace is a flag on the +actions it emits, which the env reads in its own step. + +Why an environment would want this: pressing a physical button is easy +in simulation and awkward on real hardware -- the button has to be +reachable from every staging pose, the arm has to approach it without +sweeping through the scene it just built, and a missed press is +indistinguishable from a press that did not take. A declaration has +none of that, and for a LEARNING agent it is the more interesting +signal anyway: there is no contact to credit the effect to, so an agent +that works out "the wind starts after I declare" has found a genuinely +causal relation rather than a contact one. + +Modelled on ``wait.py``'s pose-holding policy, since the arm must stay +exactly where it is: a declaration that nudged the arm could knock the +staged chain, and then the topple would have a mechanical explanation +after all. +""" + +from typing import Dict, Optional, Sequence, Tuple, cast + +import numpy as np +from gym.spaces import Box + +from predicators import utils +from predicators.ground_truth_models.skill_factories.base import SkillConfig +from predicators.structs import DECLARE_FINISHED_KEY, Action, Array, \ + Object, ParameterizedOption, State, Type + +# Re-exported so a reader of this file finds the marker here too. An +# env that does not care ignores it, so adding this skill to a domain +# cannot change what any existing env does. +__all__ = ["DECLARE_FINISHED_KEY", "create_declare_option"] + + +def create_declare_option( + name: str, + config: SkillConfig, + robot_type: Type, + num_steps: int = 2, + params_description: Optional[Tuple[str, ...]] = None, +) -> ParameterizedOption: + """Create a no-motion option that announces itself to the env. + + Args: + name: Option name (e.g. "DeclareFinished"). + config: Shared skill configuration. See ``SkillConfig``. + robot_type: The robot ``Type`` object. + num_steps: How many actions to emit. More than one because a + single action can be swallowed by a controller that has not + settled, and the flag has to survive into a step the env + actually runs. + + Returns: + A ``ParameterizedOption`` that is always initiable and holds the + robot's pose for ``num_steps`` actions. + """ + robot = config.robot + mid_point = (config.open_fingers_joint + config.closed_fingers_joint) / 2 + + def _initiable(state: State, memory: Dict, objects: Sequence[Object], + params: Array) -> bool: + del state, objects, params + # A grounded option can be re-run (validation rollouts reuse the + # grounded plan), so the step count must not carry over. + memory["declare_steps"] = 0 + return True + + def _terminal(state: State, memory: Dict, objects: Sequence[Object], + params: Array) -> bool: + del state, objects, params + return memory.get("declare_steps", 0) >= num_steps + + def _policy(state: State, memory: Dict, objects: Sequence[Object], + params: Array) -> Action: + del params + memory["declare_steps"] = memory.get("declare_steps", 0) + 1 + robot_obj = objects[0] + # Hold the fingers where they are, exactly as Wait does: this + # skill must not disturb the scene it is declaring finished. + current_joint = config.fingers_state_to_joint( + robot, state.get(robot_obj, "fingers")) + if current_joint > mid_point: + finger_delta = config.finger_action_nudge_magnitude + else: + finger_delta = -config.finger_action_nudge_magnitude + pb_state = cast(utils.PyBulletState, state) + joint_positions = pb_state.joint_positions.copy() + f_action = joint_positions[robot.left_finger_joint_idx] + finger_delta + joint_positions[robot.left_finger_joint_idx] = f_action + joint_positions[robot.right_finger_joint_idx] = f_action + action_arr = np.array(joint_positions, dtype=np.float32) + n_action = robot.action_space.shape[0] + if action_arr.shape[0] < n_action: + action_arr = np.concatenate([ + action_arr, + np.zeros(n_action - action_arr.shape[0], dtype=np.float32) + ]) + return Action(np.clip(action_arr, robot.action_space.low, + robot.action_space.high), + extra_info={DECLARE_FINISHED_KEY: True}) + + return ParameterizedOption( + name, + types=[robot_type], + params_space=Box(0, 1, (0, )), + policy=_policy, + initiable=_initiable, + terminal=_terminal, + params_description=params_description or (), + ) diff --git a/predicators/settings.py b/predicators/settings.py index 9b5a1d837..9ddcec614 100644 --- a/predicators/settings.py +++ b/predicators/settings.py @@ -863,6 +863,71 @@ class GlobalSettings: # domino_min_block_num_blues); domino_min_block_tasks does not also # need to be set. domino_heavy_block_tasks = False + # Lay each chain out along one fan's wind axis, starting at the upwind + # end, for the ball-free pybullet_domino_fan env. Off, the generator + # picks the start pose uniformly and the travel direction at random, + # which is right for a task the ROBOT pushes and wrong for one the WIND + # starts: a chain crossing the wind cannot cascade no matter how hard + # the fan blows, so the task is unsolvable before a planner sees it. + # The chosen side is recorded per task in offline_task_metrics + # ("fan_side"), 0=left 1=right 2=back 3=front, matching + # FanComponent's side_idx. + domino_fan_aligned_tasks = False + # Wind force (N) the fan applies to the start domino in the ball-free + # pybullet_domino_fan env. NOT the FanComponent class default (2.0), + # which is the ball's. + # + # The static threshold is m*g*(depth/2)/(0.4*height) = 0.123 N for a + # 100 g domino 15 mm thick and 150 mm tall pushed at 0.4 of its + # height, and 0.2 N clears it. But clearing it is not enough: what + # sets this value is how FAST the block has to move. + # + # Wait terminates on quiescence, and at 0.2 N the block does not + # visibly move until step 28 - so Wait sees a still scene, declares + # it settled at step 11, and the plan ends before the wind has done + # anything. Measured onset: 28 steps at 0.2 N, 17 at 0.4, 11 at 0.8, + # 7 at 1.5, 4 at 3.0. 1.5 N moves the block well inside Wait's + # window. + # + # Nothing is lost by the higher force now that the wind pushes above + # the centre of mass and stops once its target is down: the cascade + # it produces is the same one (final rolls [81,66,45,11] at 1.5 N + # against [80,66,45,11] at 0.2 N). An earlier note here warned that + # force above ~0.3 N sends blocks flying metres; that was measured + # against the centre-of-mass push and the never-ending wind, and no + # longer holds. + domino_fan_wind_force = 1.5 + + # Wind force for the blow-to-goal task (N). Applied 0.4 of the way + # up the block, so the gust tips it AND keeps pushing it once it is + # down: the block ends up flat (which the robot cannot achieve by + # placing, so the goal cannot be reached without the wind) while the + # distance travelled stays a continuous, monotone function of this + # number - 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. In pybullet_domino_fan the same parameter is NOT fittable, + # because there the wind tips a block in about two steps and every + # force above threshold looks the same (see + # scripts/domino_debug/probe_wind_identifiability.py). + domino_blow_wind_force = 2.0 + # Steps the fan blows for once the robot has declared. Bounded so + # the block travels a finite, repeatable distance rather than being + # pushed until the episode ends. Measured: at 2.5 N this gust slides + # the block 11.6 cm, and the response is steep and monotone (1.5 N -> + # 3.7 cm, 2.5 -> 11.6, 4.0 -> 30.9) which is exactly the gradient the + # cascade env's saturating topple does not provide. Past ~6 N the + # block is launched off the table rather than slid. + # Thirty, not sixty, because the plan gets exactly ONE Wait however + # long the wind process's delay is made, and a Wait ends when the + # scene's atoms change. A 60-step gust was still pushing when its + # Wait expired at 40 steps and the block stopped short. + domino_blow_wind_steps = 30 + # Sides carrying a fan + switch in pybullet_domino_fan, in order + # left, right, down, up. One is the point of the task: the robot has + # a single switch to find and press. Four is the ball task's layout + # and only adds groundings the planner must search and fans that + # cancel each other. domino_fan_aligned_tasks lays every chain along + # one of the sides that exist. + domino_fan_num_sides = 1 # burger env parameters burger_render_set_of_marks = True diff --git a/predicators/structs.py b/predicators/structs.py index 10045d895..4ffe58c21 100644 --- a/predicators/structs.py +++ b/predicators/structs.py @@ -1988,6 +1988,15 @@ def copy_with(self, **kwargs: Any) -> _GroundNSRT: return _GroundNSRT(**default_kwargs) # type: ignore +# Key an Action carries in ``extra_info`` when the skill that produced +# it is a declaration rather than a motion: the robot announcing it has +# finished, for an env where that announcement is itself an event (see +# skill_factories/declare.py and PyBulletDominoDeclareEnv). It lives +# here, beside Action, because it is part of that contract and because +# both the skill and the env must name it without importing each other. +DECLARE_FINISHED_KEY = "declare_finished" + + @dataclass(eq=False) class Action: """An action in an environment. diff --git a/scripts/configs/predicatorv3/_domino_declare_rung1.yaml b/scripts/configs/predicatorv3/_domino_declare_rung1.yaml new file mode 100644 index 000000000..58ab0a207 --- /dev/null +++ b/scripts/configs/predicatorv3/_domino_declare_rung1.yaml @@ -0,0 +1,21 @@ +# GENERATED by scripts/domino_fan/run_rung.sh for rung 1. +# Edit run_rung.sh, not this file - it is overwritten on every run. +--- +includes: + - common.yaml + - envs/all.yaml + - approaches/all.yaml +ENVS: + domino_declare: + SKIP: False +APPROACHES: + oracle: + SKIP: False + FLAGS: + option_model_use_gui: False + agent_model_based_planning: + SKIP: True + agent_param_learning: + SKIP: True + agent_po_predicate_invention_al: + SKIP: True diff --git a/scripts/configs/predicatorv3/_domino_declare_rung2.yaml b/scripts/configs/predicatorv3/_domino_declare_rung2.yaml new file mode 100644 index 000000000..a9e590505 --- /dev/null +++ b/scripts/configs/predicatorv3/_domino_declare_rung2.yaml @@ -0,0 +1,22 @@ +# GENERATED by scripts/domino_fan/run_rung.sh for rung 2. +# Edit run_rung.sh, not this file - it is overwritten on every run. +--- +includes: + - common.yaml + - envs/all.yaml + - approaches/all.yaml +ENVS: + domino_declare: + SKIP: False +APPROACHES: + oracle: + SKIP: True + agent_model_based_planning: + SKIP: False + FLAGS: + option_model_use_gui: False + num_online_learning_cycles: 0 + agent_param_learning: + SKIP: True + agent_po_predicate_invention_al: + SKIP: True diff --git a/scripts/configs/predicatorv3/_domino_declare_rung4.yaml b/scripts/configs/predicatorv3/_domino_declare_rung4.yaml new file mode 100644 index 000000000..41f9b3b22 --- /dev/null +++ b/scripts/configs/predicatorv3/_domino_declare_rung4.yaml @@ -0,0 +1,22 @@ +# GENERATED by scripts/domino_fan/run_rung.sh for rung 4. +# Edit run_rung.sh, not this file - it is overwritten on every run. +--- +includes: + - common.yaml + - envs/all.yaml + - approaches/all.yaml +ENVS: + domino_declare: + SKIP: False +APPROACHES: + oracle: + SKIP: True + agent_model_based_planning: + SKIP: True + agent_param_learning: + SKIP: True + agent_po_predicate_invention_al: + SKIP: False + FLAGS: + option_model_use_gui: False + skip_initial_test: True diff --git a/scripts/configs/predicatorv3/_rung1.yaml b/scripts/configs/predicatorv3/_rung1.yaml new file mode 100644 index 000000000..06bc1a131 --- /dev/null +++ b/scripts/configs/predicatorv3/_rung1.yaml @@ -0,0 +1,19 @@ +# GENERATED by scripts/domino_fan/run_rung.sh for rung 1. +# Edit run_rung.sh, not this file - it is overwritten on every run. +--- +includes: + - common.yaml + - envs/all.yaml + - approaches/all.yaml +ENVS: + domino_fan: + SKIP: False +APPROACHES: + oracle: + SKIP: False + agent_model_based_planning: + SKIP: True + agent_param_learning: + SKIP: True + agent_po_predicate_invention_al: + SKIP: True diff --git a/scripts/configs/predicatorv3/_rung2.yaml b/scripts/configs/predicatorv3/_rung2.yaml new file mode 100644 index 000000000..829c0daa6 --- /dev/null +++ b/scripts/configs/predicatorv3/_rung2.yaml @@ -0,0 +1,21 @@ +# GENERATED by scripts/domino_fan/run_rung.sh for rung 2. +# Edit run_rung.sh, not this file - it is overwritten on every run. +--- +includes: + - common.yaml + - envs/all.yaml + - approaches/all.yaml +ENVS: + domino_fan: + SKIP: False +APPROACHES: + oracle: + SKIP: True + agent_model_based_planning: + SKIP: False + FLAGS: + num_online_learning_cycles: 0 + agent_param_learning: + SKIP: True + agent_po_predicate_invention_al: + SKIP: True diff --git a/scripts/configs/predicatorv3/_rung3.yaml b/scripts/configs/predicatorv3/_rung3.yaml new file mode 100644 index 000000000..1ddd7c2e2 --- /dev/null +++ b/scripts/configs/predicatorv3/_rung3.yaml @@ -0,0 +1,22 @@ +# GENERATED by scripts/domino_fan/run_rung.sh for rung 3. +# Edit run_rung.sh, not this file - it is overwritten on every run. +--- +includes: + - common.yaml + - envs/all.yaml + - approaches/all.yaml +ENVS: + domino_fan: + SKIP: False +APPROACHES: + oracle: + SKIP: True + agent_model_based_planning: + SKIP: True + agent_param_learning: + SKIP: False + FLAGS: + option_model_use_gui: False + skip_initial_test: True + agent_po_predicate_invention_al: + SKIP: True diff --git a/scripts/configs/predicatorv3/_rung4.yaml b/scripts/configs/predicatorv3/_rung4.yaml new file mode 100644 index 000000000..a764ba4ac --- /dev/null +++ b/scripts/configs/predicatorv3/_rung4.yaml @@ -0,0 +1,22 @@ +# GENERATED by scripts/domino_fan/run_rung.sh for rung 4. +# Edit run_rung.sh, not this file - it is overwritten on every run. +--- +includes: + - common.yaml + - envs/all.yaml + - approaches/all.yaml +ENVS: + domino_fan: + SKIP: False +APPROACHES: + oracle: + SKIP: True + agent_model_based_planning: + SKIP: True + agent_param_learning: + SKIP: True + agent_po_predicate_invention_al: + SKIP: False + FLAGS: + option_model_use_gui: False + skip_initial_test: True diff --git a/scripts/configs/predicatorv3/envs/all.yaml b/scripts/configs/predicatorv3/envs/all.yaml index a685903a7..f6ca83ba2 100644 --- a/scripts/configs/predicatorv3/envs/all.yaml +++ b/scripts/configs/predicatorv3/envs/all.yaml @@ -336,6 +336,150 @@ ENVS: domino_heavy_block_tasks: True domino_min_block_num_blues: 4 domino_test_turn_ratio: 1.0 + # Domino + fan, no ball: the fan blows the START (green) domino, so the + # task is a pure layout problem - arrange the chain so that switching the + # fan on topples the target. The robot never pushes anything itself. + # + # Runs: the oracle solves it 1/1 at reward 0.900 (robot bridges start + # to target with two blues, presses the switch, wind topples the + # chain). GT types/predicates/options/processes are registered in + # ground_truth_models/domino/, and the wind's residual dynamics - + # with wind_force and wind_lever as FITTED parameters - live in + # ground_truth_models/domino_fan/gt_simulator.py, which is what a + # learning arm has to recover. + # + # These tasks DO carry a DominoEvaluator, with TurnFanOn as the + # sanctioned trigger instead of Push: reward is + # 1[goal reached via a certified wind cascade] - 0.05 per blue used, + # so 0.900 is a two-blue solve. An episode where the arm knocks the + # target over earns no bonus. Ball variants still get no evaluator. + domino_fan: + NAME: "pybullet_domino_fan" + SKIP: True # un-skipped by exp_domino_fan.yaml + FLAGS: + max_initial_demos: 0 + excluded_objects_in_state_str: "loc,rot,angle,direction" + horizon: 500 + domino_initialize_at_finished_state: False + domino_use_domino_blocks_as_target: True + domino_use_continuous_place: True + process_planning_heuristic_weight: 2.0 + domino_has_glued_dominos: False + keep_failed_demos: True + predicate_invent_invent_derived_predicates: True + pybullet_birrt_extend_num_interp: 20 + pybullet_birrt_path_subsample_ratio: 2 + fan_known_controls_relation: True + fan_fans_blow_opposite_direction: False + # Lay each chain along one fan's wind axis. Without this the + # generator points chains anywhere, and a chain across the wind + # cannot cascade however hard the fan blows - the task is + # unsolvable before a planner sees it. + domino_fan_aligned_tasks: True + # Sized so the block moves before Wait declares the scene settled + # (onset 7 steps at 1.5 N against Wait's ~11), not just to clear + # the 0.123 N tipping threshold. See settings.py. + domino_fan_wind_force: 1.5 + # Straight chains only. Alignment guarantees the FIRST leg runs + # downwind; a turn puts the rest of the chain across the wind, + # and the turn geometry was tuned for a robot push anyway. + domino_test_turn_ratio: 0.0 + domino_train_turn_ratio: 0.0 + # The same task with the BUTTON removed: instead of pressing a + # switch, the robot runs DeclareFinished and the fan starts. Same + # scene, same wind, same certificate - only the trigger changes. + # + # Two reasons it is its own env. A physical button is awkward on a + # real arm: it has to be reachable from every staging pose, the + # approach must not sweep through the chain just built, and a missed + # press is indistinguishable from one 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. + domino_declare: + NAME: "pybullet_domino_declare" + SKIP: True # un-skipped by run_rung.sh --declare + FLAGS: + max_initial_demos: 0 + excluded_objects_in_state_str: "loc,rot,angle,direction" + horizon: 500 + domino_initialize_at_finished_state: False + domino_use_domino_blocks_as_target: True + domino_use_continuous_place: True + process_planning_heuristic_weight: 2.0 + domino_has_glued_dominos: False + keep_failed_demos: True + predicate_invent_invent_derived_predicates: True + pybullet_birrt_extend_num_interp: 20 + pybullet_birrt_path_subsample_ratio: 2 + fan_known_controls_relation: True + fan_fans_blow_opposite_direction: False + # Lay each chain along one fan's wind axis. Without this the + # generator points chains anywhere, and a chain across the wind + # cannot cascade however hard the fan blows - the task is + # unsolvable before a planner sees it. + domino_fan_aligned_tasks: True + # Sized so the block moves before Wait declares the scene settled + # (onset 7 steps at 1.5 N against Wait's ~11), not just to clear + # the 0.123 N tipping threshold. See settings.py. + domino_fan_wind_force: 1.5 + # Straight chains only. Alignment guarantees the FIRST leg runs + # downwind; a turn puts the rest of the chain across the wind, + # and the turn geometry was tuned for a robot push anyway. + domino_test_turn_ratio: 0.0 + domino_train_turn_ratio: 0.0 + # Place a block so the WIND carries it into a goal region. Not a + # cascade: one block, one gust, one patch. The robot picks the block + # up, puts it down upwind of the patch, and declares finished. + # + # This is the env where the wind's strength is actually learnable. + # In domino_fan the wind tips a standing block in ~2 steps, so every + # force above threshold looks identical and wind_force cannot be + # fitted. Here the wind pushes through the centre of mass, the block + # slides, and distance travelled is steep and monotone in the force + # (1.8 N -> 5.8 cm, 2.5 -> 11.5, 3.2 -> 19.3). Against a 4 cm goal + # half-width the robot must know the force to about a sixth. + domino_blow: + NAME: "pybullet_domino_blow" + SKIP: True # un-skipped by run_rung.sh --blow + FLAGS: + max_initial_demos: 0 + excluded_objects_in_state_str: "loc,rot,angle,direction" + horizon: 500 + domino_initialize_at_finished_state: False + domino_use_continuous_place: True + process_planning_heuristic_weight: 2.0 + domino_has_glued_dominos: False + keep_failed_demos: True + predicate_invent_invent_derived_predicates: True + pybullet_birrt_extend_num_interp: 20 + pybullet_birrt_path_subsample_ratio: 2 + fan_known_controls_relation: True + fan_fans_blow_opposite_direction: False + # Lay each chain along one fan's wind axis. Without this the + # generator points chains anywhere, and a chain across the wind + # cannot cascade however hard the fan blows - the task is + # unsolvable before a planner sees it. + # Sized so the block moves before Wait declares the scene settled + # (onset 7 steps at 1.5 N against Wait's ~11), not just to clear + # the 0.123 N tipping threshold. See settings.py. + # Exactly one block. Spare bodies are not harmless scenery here: + # they stay MovableBlock wherever they are parked, so the planner + # grounds PickBlock over them and builds skeletons that reach for + # a block ten metres off the table, which then cannot be refined. + domino_train_num_dominos: [1] + domino_test_num_dominos: [1] + domino_train_num_targets: [0] + domino_test_num_targets: [0] + # Left to settings.py: force and gust length are calibrated + # together against the measured slide curve, and pinning one of + # them here silently decoupled the pair - the launcher blew for + # 60 steps while the oracle's slide model assumed 30, so the + # block sailed past the goal every time. + # Straight chains only. Alignment guarantees the FIRST leg runs + # downwind; a turn puts the rest of the chain across the wind, + # and the turn geometry was tuned for a robot push anyway. + domino_train_turn_ratio: 0.0 # coffee: # NAME: "pybullet_coffee" # FLAGS: diff --git a/scripts/configs/predicatorv3/exp_domino_fan.yaml b/scripts/configs/predicatorv3/exp_domino_fan.yaml new file mode 100644 index 000000000..3d29932ba --- /dev/null +++ b/scripts/configs/predicatorv3/exp_domino_fan.yaml @@ -0,0 +1,79 @@ +# Thin launcher: the ball-free domino-fan task. The robot bridges the +# start block to the target with blue dominoes, presses the switch, and +# the WIND topples the chain - it never pushes a domino itself (Push is +# withheld in this env, option and process both). +# +# Usage: python scripts/local/launch_simp.py -c predicatorv3/exp_domino_fan.yaml +# +# THE LADDER. Each rung hands the agent less. Exactly one arm should be +# un-skipped at a time (launch_simp runs the ENVS x APPROACHES +# cross-product, so two un-skipped arms means two runs). +# +# rung 1 oracle GT processes + GT predicates, +# process planning. Proves the task +# is solvable and sets the reward +# ceiling. MEASURED: 1/1 at 0.900. +# rung 2a agent_model_based_planning GT monolithic sim + GT predicates, +# the AGENT plans. The gentler entry +# to rung 2 - no sketch scaffolding. +# rung 2b agent_oracle_hybrid_sim GT hybrid sim + GT params + GT +# predicates, agent plans. +# rung 3 agent_param_learning GT sim STRUCTURE, params fitted: +# told the wind exists, must recover +# how strong it is and where it acts. +# Isolates system-ID from structure +# discovery. +# rung 4 agent_po_predicate_invention_al +# Base sim only. Must discover the +# wind exists, write a model of it, +# and invent predicates. Partially +# observable. This is "ours". +# (agent_predicate_invention is the +# fully-observable variant.) +# +# NOT agent_oracle_mono_sim for rung 2: its +# agent_bilevel_plan_sketch_file points at a BOIL env sketch +# (tests/approaches/test_data/boil_plan_sketch.txt), which names objects +# this env does not have. It needs a domino-fan sketch written first. +# +# Reward scale, so a curve is not misread: train tasks are 1-blue +# bridges (best possible 0.95), test tasks 2-blue (best possible 0.900), +# since reward = 1[certified wind cascade] - 0.05 per blue used. A run +# going 0.95 on train and 0.900 on test is at ceiling on both, not +# declining. +--- +includes: + - common.yaml + - envs/all.yaml + - approaches/all.yaml +ENVS: + domino_fan: + SKIP: False +APPROACHES: + # rung 1 - the baseline. Fast (~1 min), no LLM. + oracle: + SKIP: True + # rung 2a + agent_model_based_planning: + SKIP: True + # rung 2b + agent_oracle_hybrid_sim: + SKIP: True + # rung 3 + agent_param_learning: + SKIP: False + # rung 4 - ours + agent_po_predicate_invention_al: + SKIP: True + FLAGS: + skip_initial_test: True + # NOT enabled: this flag is a no-op for this env and reads as if + # it were not. exp_fan.yaml can turn it on because the ball env + # split its sim core into pybullet_fan_base.py, a module holding + # geometry and switch mechanics and deliberately NO wind law. + # pybullet_domino_fan has no such split - the wind lives inside + # fan_component.py next to the fan bodies - so + # get_base_sim_source_files() returns [] and the run logs + # "declares no base-sim source files; providing none". + # Turning it on would need that module split first. + # agent_sim_provide_base_sim_source: True diff --git a/scripts/domino_debug/probe_wind_identifiability.py b/scripts/domino_debug/probe_wind_identifiability.py new file mode 100644 index 000000000..e6b5007f4 --- /dev/null +++ b/scripts/domino_debug/probe_wind_identifiability.py @@ -0,0 +1,119 @@ +"""Is the domino-fan wind force recoverable from rollouts? Measure it. + +Answers, in about a minute and with no LLM in the loop, the question +that otherwise costs a 40-minute learning run to answer badly: does a +change in ``domino_fan_wind_force`` produce a change a fitter could +see? + +It does not, and the reason is the domino rather than the optimizer. +The wind acts only while the start block is upright (a fallen domino +is out of the airstream), which at any force that topples at all is +about two simulation steps. So the entire observation is "tipped or +did not", and the map from force to that observation is a step: + + force (N) topple step drift (mm) + 0.10 never 0.02 + 0.25 never 0.00 + 0.50 never 0.01 + 0.75 never 0.03 + 1.00 5 7.70 + 1.50 2 9.49 <- the env's true value + 2.00 2 19.23 + 3.00 1 11.16 + +1.5 N and 2.0 N are indistinguishable. Below ~0.8 N nothing moves at +all -- static friction holds the block, so there is no gentle +sub-threshold signal either -- and the post-topple drift is +non-monotonic, being tumbling rather than a function of the parameter. +A run's own sysID diagnostics said the same thing in their own words +("weakly identified", "NOT identified (posterior ~= prior)"). + +Contrast ``pybullet_fan``, which fits this same parameter happily: its +wind pushes a BALL with no stopping condition, so every timestep's +position is a continuous monotone function of the force. Whether a +wind parameter is fittable is a property of what the wind is pushing. + +Run it after any change to the wind, the block geometry, or the +friction, to check whether the answer has moved: + + PYTHONHASHSEED=0 python scripts/domino_debug/probe_wind_identifiability.py +""" + +import numpy as np + +from predicators import utils + + +def main() -> None: + """Sweep the wind force; report what an observer could measure.""" + utils.reset_config({ + "env": "pybullet_domino_fan", + "seed": 0, + "num_test_tasks": 1, + "num_train_tasks": 0, + "domino_fan_aligned_tasks": True, + }) + # Imported after reset_config so the env sees the right settings. + # pylint: disable=import-outside-toplevel + from predicators.envs.pybullet_domino.components.domino_component import \ + DominoComponent + from predicators.envs.pybullet_domino.env import PyBulletDominoFanEnv + from predicators.settings import CFG + from predicators.structs import Action, EnvironmentTask + + env = PyBulletDominoFanEnv(use_gui=False) + base = env.get_test_tasks()[0] + thresh = DominoComponent.domino_roll_threshold + + # The same scene, but with every fan already blowing at t=0: the + # robot is held still, so the wind is the only thing acting and + # nothing else can be credited with the topple. + init = base.init.copy() + for obj in init: + if obj.type.name in ("fan", "switch"): + init.set(obj, "is_on", 1.0) + green = next(o for o in init + if o.type.name == "domino" and all( + abs(float(init.get(o, c)) - + DominoComponent.start_domino_color[i]) < 1e-3 + for i, c in enumerate(("r", "g", "b")))) + task = EnvironmentTask(init, base.goal) + + def drift(state) -> float: + """How far the block has moved from where it started (m).""" + return float( + np.hypot(state.get(green, "x") - init.get(green, "x"), + state.get(green, "y") - init.get(green, "y"))) + + def probe(force: float): + """(steps until the block topples, drift) at this wind force.""" + CFG.domino_fan_wind_force = force + env._current_task = task # pylint: disable=protected-access + env._set_state(init) # pylint: disable=protected-access + # Hold the arm exactly where it is. + hold = np.array(env._pybullet_robot.get_joints(), # pylint: disable=protected-access + dtype=np.float32) + state = init + for step in range(150): + env.step(Action(hold)) + state = env._get_state() # pylint: disable=protected-access + roll = float(state.get(green, "roll")) + # Roll is meaningful modulo pi: a box turned 180 degrees + # about its width axis is the same box. + roll = (roll + np.pi / 2) % np.pi - np.pi / 2 + if abs(roll) >= thresh: + return step, drift(state) + return None, drift(state) + + print(f"\n{'force (N)':>10} {'topple step':>13} {'drift (mm)':>12}") + for force in (0.10, 0.25, 0.50, 0.75, 1.00, 1.50, 2.00, 3.00): + step, dist = probe(force) + mark = " <- env's value" if abs(force - 1.5) < 1e-9 else "" + print(f"{force:>10.2f} {str(step):>13} {1000 * dist:>12.2f}{mark}", + flush=True) + print("\nDistinct topple-step values across a 30x range of force is the " + "\nwhole signal a fitter has. See the module docstring.\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/domino_fan/README.md b/scripts/domino_fan/README.md new file mode 100644 index 000000000..acc88f2b2 --- /dev/null +++ b/scripts/domino_fan/README.md @@ -0,0 +1,67 @@ +# domino-fan: running the ladder + +Three scripts. No YAML editing. + +```bash +scripts/domino_fan/run_rung.sh 1 # or 2, 3, 4, or "all" +scripts/domino_fan/dashboard.sh # watch results at :8765 +scripts/domino_fan/reset_runs.sh --yes # clear old runs +``` + +## The ladder + +Each rung hands the agent less and asks it to recover more. + +| rung | arm | given | must supply | +|------|-----|-------|-------------| +| 1 | `oracle` | simulator, predicates | nothing — the process planner plans | +| 2 | `agent_model_based_planning` | simulator, predicates | the plan and its continuous parameters | +| 3 | `agent_param_learning` | simulator *structure* | the wind's parameters, fitted from data | +| 4 | `agent_po_predicate_invention_al` | base simulator only | that a wind exists, code modelling it, and predicates | + +Rung 1 takes ~2 minutes and calls no LLM. Rungs 2–4 drive Claude and +run from minutes to hours; `run_rung.sh` refuses to start a second run +while one is going, and so does the dashboard button. + +## The task + +The robot bridges a start block to a target with blue dominoes, presses +a switch, and the **wind** topples the chain. It never pushes a domino: +`Push` is withheld in this env, option and process both, so the fan is +the only way to start a cascade. + +## Reading the score + +``` +reward = 1[goal reached via a certified wind cascade] − 0.05 × blues used +``` + +The certificate rejects an episode where the arm knocks the target over +instead of the wind — `TurnFanOn` is the sanctioned trigger here, in +place of `Push`. So **fewer blocks scores higher**, and the rungs are +not all chasing the same number: + +| | blocks used | reward | +|---|---|---| +| rung 1 (oracle, grid planner) | 2 | 0.900 | +| rung 2 (agent plans) | 1 | **0.950** | + +Rung 2 beats the oracle because the oracle's grid planner bridges with +two blues while a domino actually topples further than one `pos_gap`. +Treat 0.900 as the oracle's score, not the task's ceiling. + +Train tasks are shorter than test tasks (a 1-block bridge against a +2-block one), so a run at 0.95 on train and 0.900 on test is at ceiling +on both — not declining. + +## Where things land + +``` +logs//domino_fan-/seed0/run_/ transcripts, sandbox, fits +videos// .mp4 per episode +results/ .pkl metrics per cycle +docs/envs/domino_fan/ curated gifs, committed +``` + +Run artifacts are working data and not committed. Anything worth +keeping gets converted and put in `docs/envs/domino_fan/` deliberately. diff --git a/scripts/domino_fan/dashboard.sh b/scripts/domino_fan/dashboard.sh new file mode 100755 index 000000000..3c16cd0c9 --- /dev/null +++ b/scripts/domino_fan/dashboard.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Open the dashboard: every run, its rung, its score, and its video. +# +# scripts/domino_fan/dashboard.sh # http://localhost:8765 +# scripts/domino_fan/dashboard.sh 9000 # another port +# +# From there you can start a rung with the "run rung" buttons and watch +# it live - the row appears as soon as the run makes its log dir, and +# the page refreshes itself. One run at a time; use a row's kill button +# to stop one. +set -euo pipefail +cd "$(dirname "$0")/../.." +PY=".venv/bin/python"; [ -x "$PY" ] || PY="python3" +PORT="${1:-8765}" +echo "dashboard: http://localhost:$PORT (ctrl-c to stop)" +exec $PY scripts/log_viewer.py --port "$PORT" diff --git a/scripts/domino_fan/reset_runs.sh b/scripts/domino_fan/reset_runs.sh new file mode 100755 index 000000000..3c8fc86a3 --- /dev/null +++ b/scripts/domino_fan/reset_runs.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Clear domino-fan runs so the dashboard shows only the ladder. +# +# scripts/domino_fan/reset_runs.sh # dry run +# scripts/domino_fan/reset_runs.sh --yes # remove them +# scripts/domino_fan/reset_runs.sh 'domino-oracle*' # another pattern +# scripts/domino_fan/reset_runs.sh 'domino-oracle*' --yes +# +# Removes logs/, videos/, results/ and saved_approaches/ entries whose +# experiment id matches the pattern (default: every domino-fan run). +# Ladder runs are cheap to regenerate with run_rung.sh, and the +# debugging runs from developing this env clutter the dashboard without +# being results. +# +# Careful with a wider pattern: a real experiment's run is the only copy +# of its transcripts and fits. The domino_high_friction_turn runs, for +# instance, are the friction system-ID result, not scratch. +set -euo pipefail +cd "$(dirname "$0")/../.." + +DRY=1 +PATTERN='domino*fan*' +for a in "$@"; do + case "$a" in + --yes) DRY=0 ;; + *) PATTERN="$a" ;; + esac +done + +targets=() +while IFS= read -r d; do targets+=("$d"); done < <( + { ls -d logs/*/$PATTERN 2>/dev/null || true + ls -d videos/*/$PATTERN 2>/dev/null || true + ls results/*$PATTERN* 2>/dev/null || true + ls saved_approaches/*$PATTERN* 2>/dev/null || true + } | sort -u) + +if [ ${#targets[@]} -eq 0 ]; then echo "nothing to remove."; exit 0; fi +printf '%s\n' "${targets[@]}" +echo "---" +if [ "$DRY" = "1" ]; then + echo "${#targets[@]} paths match '$PATTERN'. Re-run with --yes to remove." +else + rm -rf "${targets[@]}" + echo "removed ${#targets[@]} paths." +fi diff --git a/scripts/domino_fan/run_rung.sh b/scripts/domino_fan/run_rung.sh new file mode 100755 index 000000000..ca1c93fff --- /dev/null +++ b/scripts/domino_fan/run_rung.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# Run one rung of the domino-fan ladder. No YAML editing. +# +# scripts/domino_fan/run_rung.sh 1 # oracle ~2 min, no LLM +# scripts/domino_fan/run_rung.sh 2 # agent plans ~15 min +# scripts/domino_fan/run_rung.sh 3 # learns wind params +# scripts/domino_fan/run_rung.sh 4 # learns wind code + predicates +# scripts/domino_fan/run_rung.sh all # 1 then 2 then 3 then 4 +# +# Add --declare for the button-free variant, where the robot runs +# DeclareFinished instead of pressing a switch: +# +# scripts/domino_fan/run_rung.sh --declare 1 +# +# --blow is the place-it-so-the-wind-carries-it task, where the wind's +# strength IS fittable and rung 3 is therefore worth running: +# +# scripts/domino_fan/run_rung.sh --blow 1 +# +# SEED=1 repeats a rung on another seed, for a second opinion: +# +# SEED=1 scripts/domino_fan/run_rung.sh 4 +# +# Each rung gives the agent less and asks it to recover more: +# 1 GT simulator + GT predicates, process planner plans. +# 2 GT simulator + GT predicates, the AGENT plans. +# 3 GT simulator STRUCTURE, its parameters fitted from data. +# 4 Base simulator only: discover the wind, model it, invent predicates. +# +# Results land in logs//rung_*/ and videos//, and show +# up in the dashboard (scripts/domino_fan/dashboard.sh). +set -euo pipefail +cd "$(dirname "$0")/../.." + +PY=".venv/bin/python" +[ -x "$PY" ] || PY="python3" + +usage() { sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 1; } + +# Which env the ladder runs on. Same arms, same rungs; only the thing +# that starts the fan differs. +ENV_KEY="domino_fan" +if [ "${1:-}" = "--declare" ]; then + ENV_KEY="domino_declare" + shift +elif [ "${1:-}" = "--blow" ]; then + ENV_KEY="domino_blow" + shift +fi + +# Which random seed to run. A rung's result on one seed is an anecdote: +# the agent's exploration is not deterministic, so a repeat with a +# different seed is the cheapest evidence that a result is the method +# rather than the draw. Runs land in seed/ and show as separate rows. +SEED="${SEED:-0}" +[ $# -ge 1 ] || usage + +run_one() { + local rung="$1" arm exp + case "$rung" in + 1) arm="oracle" ; exp="rung1_oracle" ;; + 2) arm="agent_model_based_planning" ; exp="rung2_agent_planner" ;; + 3) arm="agent_param_learning" ; exp="rung3_param_learning" + echo "NOTE: rung 3 fits the wind's parameters, and in this env they" >&2 + echo " are not identifiable: the wind acts for ~2 steps before" >&2 + echo " the start block tips, so the whole observation is 'tipped" >&2 + echo " or did not'. 1.5 N and 2.0 N give identical trajectories." >&2 + echo " Rung 4 learns the wind RULE instead, which is the open" >&2 + echo " question here. Running anyway." >&2 ;; + 4) arm="agent_po_predicate_invention_al" ; exp="rung4_full_learning" ;; + *) echo "unknown rung: $rung" >&2; usage ;; + esac + + # experiment_id is built by cluster_utils as "-", + # so it is domino_fan-; the dashboard maps that back to a rung. + local cfg="scripts/configs/predicatorv3/_${ENV_KEY}_rung${rung}_s${SEED}.yaml" + # Generated, not hand-edited: every arm parked except this one. + $PY - "$rung" "$arm" "$ENV_KEY" "$SEED" > "$cfg" <<'PYEOF' +import sys +rung, arm, env_key, seed = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] +arms = ["oracle", "agent_model_based_planning", "agent_param_learning", + "agent_po_predicate_invention_al"] +lines = [ + f"# GENERATED by scripts/domino_fan/run_rung.sh for rung {rung}.", + "# Edit run_rung.sh, not this file - it is overwritten on every run.", + "---", + "includes:", + " - common.yaml", + " - envs/all.yaml", + " - approaches/all.yaml", + f"START_SEED: {seed}", + "NUM_SEEDS: 1", + "ENVS:", + f" {env_key}:", + " SKIP: False", + "APPROACHES:", +] +for a in arms: + lines += [f" {a}:", f" SKIP: {a != arm}"] + if a != arm: + continue + flags = [ + # approaches/all.yaml sets option_model_use_gui: True on + # agent_param_learning (and on agent_sim_learning and + # agent_oracle_mono_sim). That opens a REAL PyBullet GUI client + # per option-model env, and this ladder rebuilds that env many + # times over a run: rung 3 had ten 1024x796 windows open in one + # process before it was half done. Useful when a person is + # watching a rollout, pure cost for a batch run started from a + # script or a dashboard button, where nobody is. + " option_model_use_gui: False", + ] + if a == "agent_model_based_planning": + # Nothing to learn with a GT model, so online cycles only + # re-derive a solved plan at ~$2/cycle. Matches what + # agent_oracle_hybrid_sim already does. + flags.append(" num_online_learning_cycles: 0") + elif a.startswith("agent_p"): + flags.append(" skip_initial_test: True") + if a == "agent_po_predicate_invention_al": + # Rung 4 spent 5 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 the grid + # seeding is a coordinate sweep - params x seed_points x passes + # rollout evaluations, each rebuilding a PyBullet env, each + # replaying every recorded trajectory, run once per refit. + # + # Worth cutting because that fit was not buying anything: it + # reported a Δ of exactly 0.0000 on all five params, five times + # over, which is the same identifiability wall + # probe_wind_identifiability.py measures - above the topple + # threshold the wind's effect saturates and SSE is flat. The + # discovery this rung is for happens in cycle 0 regardless, and + # the agent's own initial values already produced reward-0.95 + # episodes in the real env. + flags += [ + " num_online_learning_cycles: 2", + " code_sim_learning_rollout_grid_seed_points: 5", + ] + # One sweep pass is right where the fit cannot succeed anyway. + # On domino_blow it can and it must: the wind's magnitude sets + # how far the block travels, and a block that travels wrong + # misses the goal region, so a fit starved of passes fails the + # task for a reason that is not the agent's. A wide posterior is + # acceptable there; a fit that never moves is not. + flags.append(" code_sim_learning_rollout_grid_sweep_passes: " + + ("2" if env_key == "domino_blow" else "1")) + lines += [" FLAGS:"] + flags +print("\n".join(lines)) +PYEOF + + echo "==========================================================" + echo " RUNG $rung -> $arm (seed $SEED)" + echo " logs/*/${ENV_KEY}-$arm/ (rung $rung: $exp)" + echo "==========================================================" + PYTHONHASHSEED=0 $PY scripts/local/launch_simp.py \ + -c "predicatorv3/_${ENV_KEY}_rung${rung}_s${SEED}.yaml" +} + +if [ "$1" = "all" ]; then + for r in 1 2 3 4; do run_one "$r"; done +else + for r in "$@"; do run_one "$r"; done +fi diff --git a/scripts/log_viewer.py b/scripts/log_viewer.py index 321b5762c..754a890ee 100644 --- a/scripts/log_viewer.py +++ b/scripts/log_viewer.py @@ -124,6 +124,13 @@ # main.py logs this only after the pipeline returns, so it is the one # trustworthy "this run completed" marker; a crash or a kill leaves none. DONE_RE = re.compile(r"^Main script terminated in") +# The sysID verdict of a learning cycle: the physical parameters the fit +# recovered and handed to the planner. This is what "it learned +# something" looks like in a log, so the replay reel shows it between +# the rounds it separates. +APPLIED_PARAMS_RE = re.compile( + r"Applied identified physical params to base env: \{(.*)\}") +PARAM_KV_RE = re.compile(r"'([\w]+)':\s*'?(-?[\d.eE+]+)'?") # A live run is a main.py process whose flags name the run's log dir, # which utils.configure_logging builds as approach/experiment_id/seed. PS_ARG_RE = re.compile(r"--(approach|experiment_id|seed)[= ]+(\S+)") @@ -135,17 +142,9 @@ ".sh", ".tex" } CODE_EXTS = {".py"} -# Episode-grid geometry, in px. A task always gets TASK_W of space no -# matter how many times it was retried, so its chips land at the same x in -# every run and experiment; retries stack inside that space. MISC_W holds -# the round's explore/learn chips, which are unbounded and so wrap too. -TASK_W = 108 -ROUND_W = 34 -MISC_W = 3 * TASK_W # Fixed widths for the remaining columns of the index runs table, in the # order they are declared there. None is the episodes column, whose width # depends on the task count and is filled in at render time. -RUN_COL_W = (30, 200, 62, 96, None, 178, 68, 132, 132, 72) # Episodes of one run, in file order; see _parse_episode for the fields. EpList = List[Dict[str, Any]] # A run's videos as {(task, cycle tag): [(filename, is_failure)]}. @@ -845,6 +844,7 @@ def _parse_info_log(path: str) -> Dict[str, Any]: test_round: Dict[int, int] = {} pending: List[int] = [] pending_test: List[int] = [] + fits: List[Dict[str, Any]] = [] last_explore: Optional[int] = None done = False for line in text.splitlines(): @@ -884,6 +884,23 @@ def _parse_info_log(path: str) -> Dict[str, Any]: if verdict is not None: verdict["reward"] = float(tm.group(2)) continue + m = APPLIED_PARAMS_RE.search(line) + if m: + params = {k: float(v) for k, v in PARAM_KV_RE.findall(m.group(1))} + # Several fits can land in one cycle (the agent refits as it + # edits its simulator); the last one is what the planner + # actually carries into the next round. + if params: + if fits and fits[-1]["after_round"] == len(rounds): + fits[-1]["params"] = params + fits[-1]["refits"] += 1 + else: + fits.append({ + "after_round": len(rounds), + "params": params, + "refits": 1, + }) + continue m = SAVED_EP_RE.search(line) if m: if m.group(2) == "explore": @@ -918,6 +935,7 @@ def _parse_info_log(path: str) -> Dict[str, Any]: "rounds": rounds, "explore": explore, "test_round": test_round, + "fits": fits, "done": done, "video_rel": m_vid.group(1) if m_vid else "", } @@ -1011,6 +1029,7 @@ def run_summary(run_rel: str) -> Optional[Dict[str, Any]]: "test_results": parsed.get("totals", []), "explore_results": _explore_results(episodes), "rounds": rounds, + "fits": parsed.get("fits", []), "total_cost": total_cost, "done": parsed.get("done", False), } @@ -1451,104 +1470,255 @@ def ansi_to_html(text: str) -> str: # ------------------------------------------------------------ HTML shell CSS = """ +/* ── Design tokens ────────────────────────────────────────────────── + Light is the default; dark arrives either from the OS (the media + query, skipped when the user has explicitly chosen light) or from + the topbar toggle, which stamps data-theme on . Every colour + is defined on bare :root so neither path can leave one undefined. */ :root { - --bg: #ffffff; --fg: #24292f; --muted: #57606a; --border: #d0d7de; - --panel: #f6f8fa; --accent: #0969da; --ok: #1a7f37; --bad: #cf222e; - --code-bg: #f6f8fa; --add: #dafbe1; --del: #ffebe9; + --bg: hsl(0, 0%, 100%); + --surface: hsl(240, 5%, 97%); + --surface2: hsl(240, 5%, 94%); + --surface3: hsl(240, 5%, 90%); + --border: hsl(240, 6%, 88%); + --border2: hsl(240, 5%, 80%); + --fg: hsl(222, 30%, 18%); + --bright: hsl(222, 47%, 8%); + --muted: hsl(220, 9%, 42%); + --muted2: hsl(220, 9%, 60%); + --accent: hsl(214, 90%, 38%); + --accent-dim: hsl(214, 100%, 94%); + --ok: hsl(142, 71%, 32%); + --ok-dim: hsl(142, 76%, 92%); + --bad: hsl(0, 78%, 46%); + --bad-dim: hsl(0, 84%, 95%); + --warn: hsl(35, 91%, 36%); + --warn-dim: hsl(35, 100%, 92%); + --violet: hsl(266, 60%, 46%); + --violet-dim: hsl(266, 90%, 96%); + --panel: var(--surface); + --code-bg: var(--surface2); + --add: hsl(142, 76%, 92%); + --del: hsl(0, 84%, 95%); + --shadow: 0 1px 2px hsl(0 0% 0% / .06); + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + --mono: "JetBrains Mono", "SF Mono", ui-monospace, SFMono-Regular, + Menlo, Consolas, monospace; + --topbar-h: 44px; } @media (prefers-color-scheme: dark) { - :root { - --bg: #0d1117; --fg: #c9d1d9; --muted: #8b949e; --border: #30363d; - --panel: #161b22; --accent: #58a6ff; --ok: #3fb950; --bad: #f85149; - --code-bg: #161b22; --add: #12261e; --del: #35181a; + :root:not([data-theme="light"]) { + --bg: hsl(220, 8%, 7%); + --surface: hsl(225, 27%, 11%); + --surface2: hsl(225, 27%, 14%); + --surface3: hsl(218, 30%, 18%); + --border: hsl(232, 22%, 18%); + --border2: hsl(232, 22%, 26%); + --fg: hsl(0, 0%, 82%); + --bright: hsl(0, 0%, 96%); + --muted: hsl(0, 0%, 58%); + --muted2: hsl(0, 0%, 40%); + --accent: hsl(207, 90%, 62%); + --accent-dim: hsl(213, 53%, 18%); + --ok: hsl(140, 70%, 50%); + --ok-dim: hsl(140, 50%, 12%); + --bad: hsl(348, 90%, 63%); + --bad-dim: hsl(348, 40%, 15%); + --warn: hsl(36, 100%, 60%); + --warn-dim: hsl(36, 40%, 13%); + --violet: hsl(266, 90%, 74%); + --violet-dim: hsl(266, 40%, 18%); + --add: hsl(140, 45%, 12%); + --del: hsl(348, 40%, 15%); + --shadow: 0 1px 2px hsl(0 0% 0% / .4); } } +:root[data-theme="dark"] { + --bg: hsl(220, 8%, 7%); + --surface: hsl(225, 27%, 11%); + --surface2: hsl(225, 27%, 14%); + --surface3: hsl(218, 30%, 18%); + --border: hsl(232, 22%, 18%); + --border2: hsl(232, 22%, 26%); + --fg: hsl(0, 0%, 82%); + --bright: hsl(0, 0%, 96%); + --muted: hsl(0, 0%, 58%); + --muted2: hsl(0, 0%, 40%); + --accent: hsl(207, 90%, 62%); + --accent-dim: hsl(213, 53%, 18%); + --ok: hsl(140, 70%, 50%); + --ok-dim: hsl(140, 50%, 12%); + --bad: hsl(348, 90%, 63%); + --bad-dim: hsl(348, 40%, 15%); + --warn: hsl(36, 100%, 60%); + --warn-dim: hsl(36, 40%, 13%); + --violet: hsl(266, 90%, 74%); + --violet-dim: hsl(266, 40%, 18%); + --add: hsl(140, 45%, 12%); + --del: hsl(348, 40%, 15%); + --shadow: 0 1px 2px hsl(0 0% 0% / .4); +} + * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--fg); - font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, - Arial, sans-serif; } + font: 13px/1.5 var(--sans); -webkit-font-smoothing: antialiased; } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } -code, pre { font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; } -.topbar { position: sticky; top: 0; z-index: 10; display: flex; gap: 12px; - align-items: center; padding: 8px 16px; background: var(--panel); - border-bottom: 1px solid var(--border); } -.topbar h1 { font-size: 15px; margin: 0; white-space: nowrap; } -.topbar input { flex: 1; max-width: 420px; padding: 4px 10px; - border: 1px solid var(--border); border-radius: 6px; - background: var(--bg); color: var(--fg); } -.topbar .crumb { color: var(--muted); font-size: 13px; overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; } -button { padding: 4px 10px; border: 1px solid var(--border); - border-radius: 6px; background: var(--bg); color: var(--fg); - cursor: pointer; font-size: 12px; } -button:hover { border-color: var(--accent); } -.layout { display: flex; height: calc(100vh - 45px); } +code, pre { font: 12px/1.45 var(--mono); } +::-webkit-scrollbar { width: 9px; height: 9px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 5px; } +::-webkit-scrollbar-thumb:hover { background: var(--muted2); } + +/* ── Topbar ─────────────────────────────────────────────────────── */ +.topbar { position: sticky; top: 0; z-index: 10; display: flex; gap: 8px; + align-items: center; height: var(--topbar-h); padding: 0 14px; + background: var(--surface); border-bottom: 1px solid var(--border); + user-select: none; } +.topbar h1 { font-size: 11px; margin: 0; white-space: nowrap; + font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } +.topbar h1 a { color: var(--ok); } +.topbar h1 a:hover { text-decoration: none; opacity: .8; } +.topbar input { flex: 1; max-width: 340px; padding: 5px 10px; + border: 1px solid var(--border2); border-radius: 5px; + background: var(--bg); color: var(--bright); font-size: 12px; + font-family: var(--sans); } +.topbar input:focus { outline: none; border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-dim); } +.topbar input::placeholder { color: var(--muted2); } +.topbar a { white-space: nowrap; font-size: 11px; font-weight: 600; } +.topbar .crumb { color: var(--muted); font-size: 11px; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); + margin-left: auto; } +.topbar .sep { width: 1px; height: 16px; background: var(--border2); + flex-shrink: 0; } +button { height: 26px; padding: 0 10px; border: 1px solid transparent; + border-radius: 5px; background: transparent; color: var(--muted); + cursor: pointer; font-size: 11px; font-weight: 600; + font-family: var(--sans); letter-spacing: .02em; white-space: nowrap; + transition: color .1s, background .1s, border-color .1s; } +button:hover { background: var(--surface2); border-color: var(--border2); + color: var(--bright); } + +/* ── Layout ─────────────────────────────────────────────────────── */ +.layout { display: flex; height: calc(100vh - var(--topbar-h)); } .sidebar { width: 340px; min-width: 240px; overflow-y: auto; padding: 10px; - border-right: 1px solid var(--border); background: var(--panel); + border-right: 1px solid var(--border); background: var(--surface); resize: horizontal; } -.content { flex: 1; overflow-y: auto; padding: 16px 24px; } -.content { max-width: 100%; } +.content { flex: 1; overflow-y: auto; padding: 16px 22px; max-width: 100%; } .content pre.code { background: var(--code-bg); padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; overflow-x: auto; white-space: pre; } -.content h2, .content h3 { border-bottom: 1px solid var(--border); - padding-bottom: 4px; } +.content h2, .content h3 { color: var(--bright); + border-bottom: 1px solid var(--border); padding-bottom: 5px; } + +/* ── Collapsible sections ───────────────────────────────────────── */ details.section, details.turn { border: 1px solid var(--border); border-radius: 6px; margin: 8px 0; background: var(--bg); } details.section > summary, details.turn > summary { cursor: pointer; - padding: 6px 12px; font-weight: 600; background: var(--panel); - border-radius: 6px; } + padding: 6px 12px; font-weight: 600; color: var(--bright); + background: var(--surface); border-radius: 6px; } +details.section > summary:hover, details.turn > summary:hover { + background: var(--surface2); } details[open].section > summary, details[open].turn > summary { - border-bottom: 1px solid var(--border); - border-radius: 6px 6px 0 0; } + border-bottom: 1px solid var(--border); border-radius: 6px 6px 0 0; } details.section > *:not(summary), details.turn > *:not(summary) { margin-left: 12px; margin-right: 12px; } details.turn { margin-left: 8px; } -summary .hint { color: var(--muted); font-weight: 400; font-size: 12px; } -.chip { display: inline-block; padding: 0 7px; border-radius: 10px; - font-size: 11px; line-height: 18px; border: 1px solid var(--border); - color: var(--muted); margin-right: 3px; white-space: nowrap; } -.chip.ok { color: var(--ok); border-color: var(--ok); } -.chip.bad { color: var(--bad); border-color: var(--bad); } -.chip.kind-explore { color: #b083f0; border-color: #b083f0; } -.chip.kind-learn { color: #daaa3f; border-color: #daaa3f; } +summary .hint { color: var(--muted); font-weight: 400; font-size: 11px; } + +/* ── Chips ────────────────────────────────────────────────────────── + Filled (tinted background + saturated text) rather than outline-only: + a run's episode strip is read at a glance, and fills separate the + states far faster than border colour alone. */ +.chip { display: inline-block; padding: 1px 7px; border-radius: 4px; + font: 600 10px/16px var(--mono); border: 1px solid var(--border2); + background: var(--surface2); color: var(--muted); margin-right: 3px; + white-space: nowrap; vertical-align: middle; } +.chip.ok { color: var(--ok); border-color: var(--ok); background: var(--ok-dim); } +.chip.bad { color: var(--bad); border-color: var(--bad); background: var(--bad-dim); } +.chip.kind-explore { color: var(--violet); border-color: var(--violet); + background: var(--violet-dim); } +.chip.kind-learn { color: var(--warn); border-color: var(--warn); + background: var(--warn-dim); } +.chip.kind-test { color: var(--accent); border-color: var(--accent); + background: var(--accent-dim); } /* Lifecycle, not verdict: green and red stay reserved for env evals. */ .chip.live { color: var(--accent); border-color: var(--accent); + background: var(--accent-dim); } +.chip.live::before { content: "\\25cf"; margin-right: 4px; animation: pulse 1.8s ease-in-out infinite; } -.chip.stopped { color: #daaa3f; border-color: #daaa3f; } -@keyframes pulse { 50% { opacity: .45; } } +.chip.done { color: var(--muted); border-color: var(--border2); } +.chip.stopped { color: var(--warn); border-color: var(--warn); + background: var(--warn-dim); } +@keyframes pulse { 50% { opacity: .3; } } @media (prefers-reduced-motion: reduce) { - .chip.live { animation: none; } + .chip.live::before { animation: none; } } -.banner { padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; - border: 1px solid var(--border); background: var(--panel); - display: flex; gap: 18px; flex-wrap: wrap; } -.banner.warn { border-color: var(--bad); color: var(--bad); - font-weight: 600; } + +/* ── Banner / stat strip ────────────────────────────────────────── */ +.banner { padding: 9px 14px; border-radius: 6px; margin-bottom: 12px; + border: 1px solid var(--border); background: var(--surface); + display: flex; gap: 20px; flex-wrap: wrap; align-items: center; + font-size: 12px; } +.banner.warn { border-color: var(--bad); background: var(--bad-dim); + color: var(--bad); font-weight: 600; } +.banner b, .banner .num { font-family: var(--mono); color: var(--bright); } .ok { color: var(--ok); font-weight: 700; } .bad { color: var(--bad); font-weight: 700; } -table.grid { border-collapse: collapse; margin: 10px 0; } -table.grid th, table.grid td { border: 1px solid var(--border); - padding: 4px 10px; text-align: left; font-size: 13px; } -table.grid th { background: var(--panel); } -table.grid.runs { table-layout: fixed; } -table.epgrid { border-collapse: collapse; margin: 0; - table-layout: fixed; } -table.epgrid td { border: none; padding: 1px 0; line-height: 20px; - vertical-align: top; } + +/* ── Legend ───────────────────────────────────────────────────────── + The episode strip is a dense private notation (round tags, kind + chips, marks, rewards). Spelling it out on the page beats making + every reader hover each chip for its tooltip. */ +details.legend { border: 1px solid var(--border); border-radius: 6px; + background: var(--surface); margin: 0 0 12px; } +details.legend > summary { cursor: pointer; padding: 7px 12px; + font-size: 11px; font-weight: 700; letter-spacing: .08em; + text-transform: uppercase; color: var(--muted); } +details.legend > summary:hover { color: var(--bright); } +details.legend[open] > summary { border-bottom: 1px solid var(--border); } +.legend-grid { display: grid; gap: 6px 14px; padding: 10px 12px; + grid-template-columns: max-content 1fr; align-items: baseline; + font-size: 12px; } +.legend-grid dt { text-align: right; } +.legend-grid dd { margin: 0; color: var(--muted); } +.legend-grid dd b { color: var(--bright); font-weight: 600; } + +/* ── Tables ─────────────────────────────────────────────────────── */ +table.grid { border-collapse: collapse; margin: 0; } +table.grid th, table.grid td { border-bottom: 1px solid var(--border); + padding: 5px 10px; text-align: left; font-size: 12px; + vertical-align: middle; } +table.grid th { background: var(--surface2); color: var(--muted); + font-size: 9px; font-weight: 700; letter-spacing: .1em; + text-transform: uppercase; position: sticky; top: 0; z-index: 2; + border-bottom: 1px solid var(--border2); } +table.grid.runs { width: 100%; } +tr.runrow:hover > td { background: var(--surface2); } +tr.runrow td:nth-child(2) a { color: var(--bright); font-weight: 600; } +tr.runrow td:nth-child(2) a:hover { color: var(--accent); } +/* Numbers, timestamps and seeds line up column-wise when monospaced. */ +tr.runrow td:nth-child(3), tr.runrow td:nth-last-child(-n+4) { + font-family: var(--mono); font-size: 11px; } +/* ── Sidebar nav ────────────────────────────────────────────────── */ .sidebar .nav a { display: block; padding: 3px 6px; border-radius: 4px; - color: var(--fg); font-size: 13px; overflow: hidden; + color: var(--fg); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.sidebar .nav a:hover { background: var(--bg); text-decoration: none; } +.sidebar .nav a:hover { background: var(--surface2); + text-decoration: none; } .sidebar .nav a.active { background: var(--accent); color: #fff; } .sidebar .nav a.active .chip, .sidebar .nav a.active .muted { - color: #fff; border-color: #fff; } -.sidebar h3 { font-size: 12px; text-transform: uppercase; - letter-spacing: .5px; color: var(--muted); margin: 14px 0 4px; } + color: #fff; border-color: #fff; background: transparent; } +.sidebar h3 { font-size: 9px; text-transform: uppercase; + letter-spacing: .14em; color: var(--muted); margin: 16px 0 5px; + font-weight: 700; } .sidebar details { margin-left: 8px; } -.sidebar details summary { cursor: pointer; font-size: 13px; +.sidebar details summary { cursor: pointer; font-size: 12px; color: var(--muted); } + +/* ── Media ──────────────────────────────────────────────────────── */ .thumbs { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; } /* width/height attrs on reserve layout space before lazy images load (needed for exact scroll restore); auto on the free axis keeps @@ -1566,57 +1736,189 @@ def ansi_to_html(text: str) -> str: figure.vid video { max-width: 480px; width: 100%; border: 1px solid var(--border); border-radius: 6px; display: block; background: #000; } -figure.vid figcaption { font-size: 11px; margin-top: 4px; } +figure.vid figcaption { font-size: 11px; margin-top: 4px; + color: var(--muted); font-family: var(--mono); } .gallery { display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); } .gallery a { text-align: center; font-size: 11px; color: var(--muted); overflow-wrap: anywhere; } .gallery img { width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; } + +/* ── Logs and diffs ─────────────────────────────────────────────── */ pre.logview { white-space: pre-wrap; overflow-wrap: anywhere; } .diff .add { background: var(--add); display: block; } .diff .del { background: var(--del); display: block; } .diff .hunk { color: var(--accent); display: block; } + +/* ── Overlays ───────────────────────────────────────────────────── */ #lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 100; cursor: zoom-out; } #lightbox img { max-width: 96vw; max-height: 96vh; } .runrow.hidden { display: none; } #refreshpill { position: fixed; right: 18px; bottom: 18px; z-index: 50; - background: var(--accent); color: #fff; border: none; + background: var(--accent); color: #fff; border: none; height: auto; padding: 8px 14px; border-radius: 18px; font-weight: 600; box-shadow: 0 2px 8px rgba(0,0,0,.35); } -details.grp { border: 1px solid var(--border); border-radius: 6px; - margin: 8px 0; } -details.grp > summary { cursor: pointer; padding: 6px 12px; - font-weight: 600; background: var(--panel); border-radius: 6px; } -details.grp[open] > summary { border-bottom: 1px solid var(--border); - border-radius: 6px 6px 0 0; } -details.grp.family > summary { font-size: 15px; } -details.grp > *:not(summary) { margin: 8px 12px; } -details.grp.hidden { display: none; } -.muted { color: var(--muted); } -button.copybtn { padding: 0 3px; margin-left: 5px; border: none; - background: none; color: var(--muted); font-size: 12px; +#refreshpill:hover { background: var(--accent); color: #fff; + border-color: transparent; opacity: .9; } + +/* ── Replay reel ────────────────────────────────────────────────── */ +section.round { border: 1px solid var(--border); border-radius: 8px; + background: var(--surface); padding: 12px 14px; margin: 0 0 14px; } +section.round h3 { margin: 0 0 10px; font-size: 12px; color: var(--muted); + border: none; padding: 0; text-transform: uppercase; + letter-spacing: .14em; font-weight: 700; } +/* Clips sit side by side and small: the point of this page is + comparing episodes across a run, which a column of full-width + players makes impossible without scrolling past each one. */ +.strip { display: flex; flex-wrap: wrap; gap: 12px; } +figure.clip { margin: 0; width: 260px; } +figure.clip .vids { margin: 0; } +figure.clip figure.vid video { width: 260px; max-width: 260px; } +figure.clip figure.vid figcaption { display: none; } +figure.clip > figcaption { font-size: 11px; margin-top: 5px; + line-height: 1.45; } +.fitband { margin-top: 12px; padding: 10px 12px; border-radius: 6px; + border: 1px solid var(--ok); background: var(--ok-dim); font-size: 12px; } +.fitvals { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 6px 0; } +.fitvals code { color: var(--muted); } +.fitvals b { font-family: var(--mono); color: var(--bright); } +.rung { display: inline-block; margin-right: 7px; padding: 1px 6px; + border-radius: 4px; background: var(--violet-dim); color: var(--violet); + border: 1px solid var(--violet); font: 700 9px/15px var(--mono); + letter-spacing: .06em; white-space: nowrap; cursor: help; } +.novid { font-size: 10px; color: var(--muted2); text-transform: uppercase; + letter-spacing: .08em; cursor: help; white-space: nowrap; } +a.logslink { margin-left: 10px; font-size: 10px; color: var(--muted2); + text-transform: uppercase; letter-spacing: .08em; white-space: nowrap; } +tr.runrow:hover a.logslink, .topbar a.logslink:hover { + color: var(--accent); } +a.playlink { margin-left: 6px; color: var(--muted2); font-size: 11px; } +tr.runrow:hover a.playlink { color: var(--accent); } +a.playlink:hover { text-decoration: none; } +a.watchlink { display: inline-block; height: 26px; line-height: 24px; + padding: 0 10px; border: 1px solid var(--accent); border-radius: 5px; + background: var(--accent-dim); color: var(--accent); font-size: 11px; + font-weight: 600; } +a.watchlink:hover { text-decoration: none; filter: brightness(1.1); } + +.muted { color: var(--muted); font-weight: 400; } +/* The compare column: an unlabeled checkbox is a question, not a + control. Small and grey, but named. */ +th.cmphdr { font-size: 9px; letter-spacing: .08em; cursor: help; } +/* Which env a run is on, beside its rung: same ladder, different + trigger. Coloured apart so the two are never read as one series. */ +.trig { display: inline-block; margin-right: 6px; padding: 1px 6px; + border-radius: 4px; font-size: 9px; font-weight: 700; + letter-spacing: .06em; cursor: help; + border: 1px solid var(--border2); color: var(--muted); } +.trig-declare { border-color: var(--ok); color: var(--ok); + background: var(--ok-dim); } +.trig-blow { border-color: var(--warn); color: var(--warn); + background: var(--warn-dim); } +/* The "what is happening right now" panel above the run table. */ +.livepanel { border: 1px solid var(--border); border-radius: 8px; + background: var(--panel); padding: 12px 14px; margin: 0 0 14px; } +.livehead { display: flex; gap: 10px; align-items: baseline; + margin-bottom: 2px; } +.liverow { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; + padding: 8px 0; border-top: 1px solid var(--border); margin-top: 8px; } +.liverow .prog { color: var(--muted); font-family: var(--mono); + font-size: 11px; } +/* A pulse, because a still dot beside "running" is exactly what a + stalled page also shows. */ +.liverow .dot { width: 8px; height: 8px; border-radius: 50%; + background: var(--ok); flex-shrink: 0; animation: pulse 1.6s infinite; } +@keyframes pulse { 50% { opacity: .25; } } +.launchrow { display: flex; gap: 8px; align-items: center; + flex-wrap: wrap; margin-top: 10px; } +button.rungbtn { height: 26px; padding: 0 12px; font-weight: 600; } +button.rungbtn[disabled] { opacity: .4; cursor: not-allowed; } +/* Armed: one more click starts it. Warn-coloured, because the click + after this one spends real time and real tokens. */ +button.rungbtn.armed { border-color: var(--warn); color: var(--warn); + background: var(--warn-dim); } +/* The confirm bar and the toast. Both are appended to , which + sits behind a content div of height 100vh - topbar: without fixed + positioning they render below the fold and a click looks like it did + nothing at all. */ +#askbar { position: fixed; left: 50%; transform: translateX(-50%); + bottom: 22px; z-index: 200; display: flex; gap: 10px; + align-items: center; max-width: min(760px, 92vw); + padding: 12px 14px; border: 1px solid var(--warn); + border-radius: 8px; background: var(--panel); + box-shadow: 0 8px 30px rgba(0, 0, 0, .35); } +#askbar .askmsg { font-size: 12px; line-height: 1.4; } +#askbar button { height: 28px; padding: 0 12px; flex-shrink: 0; } +#askbar button.askyes { border-color: var(--warn); color: var(--warn); + background: var(--warn-dim); font-weight: 600; } +#toast { position: fixed; left: 50%; transform: translateX(-50%); + bottom: 22px; z-index: 200; max-width: min(760px, 92vw); + padding: 10px 14px; border: 1px solid var(--border2); + border-radius: 8px; background: var(--panel); font-size: 12px; + box-shadow: 0 8px 30px rgba(0, 0, 0, .35); } +#toast.bad { border-color: var(--bad); color: var(--bad); } +#toast.warn { border-color: var(--warn); color: var(--warn); } +.lmsg { font-size: 11px; color: var(--muted); } +.lmsg.warn { color: var(--warn); } +.lmsg.ok { color: var(--ok); } +.lmsg.bad { color: var(--bad); } +.kindbar { display: inline-flex; border: 1px solid var(--border2); + border-radius: 5px; overflow: hidden; flex-shrink: 0; } +.kindbar button.kindbtn { height: 24px; border: none; border-radius: 0; + border-right: 1px solid var(--border2); } +.kindbar button.kindbtn:last-child { border-right: none; } +.kindbar button.kindbtn.on { background: var(--accent-dim); + color: var(--accent); } +.help { display: inline-block; width: 14px; height: 14px; margin-left: 6px; + border: 1px solid var(--border2); border-radius: 50%; color: var(--muted2); + font: 700 9px/12px var(--sans); text-align: center; cursor: help; + vertical-align: middle; } +.help:hover { color: var(--accent); border-color: var(--accent); } + +/* ── Row action buttons ─────────────────────────────────────────── */ +button.copybtn { height: auto; padding: 0 3px; margin-left: 5px; + border: none; background: none; color: var(--muted2); font-size: 12px; visibility: hidden; } tr.runrow:hover button.copybtn { visibility: visible; } -button.copybtn:hover { color: var(--accent); } +button.copybtn:hover { color: var(--accent); background: none; } button.copybtn.copied { color: var(--ok); visibility: visible; } -button.rowbtn { padding: 0 5px; margin-left: 5px; +button.rowbtn { height: auto; padding: 0 5px; margin-left: 5px; border: 1px solid transparent; border-radius: 4px; background: none; - color: var(--muted); font-size: 11px; visibility: hidden; } + color: var(--muted2); font-size: 11px; visibility: hidden; } tr.runrow:hover button.rowbtn { visibility: visible; } -button.rowbtn:hover { color: var(--bad); border-color: var(--bad); } -""" + f""" -table.epgrid td.task {{ width: {TASK_W}px; }} -table.epgrid td.misc {{ width: {MISC_W}px; }} -table.epgrid td.rnd {{ width: {ROUND_W}px; }} +button.rowbtn:hover { color: var(--bad); border-color: var(--bad); + background: var(--bad-dim); } """ JS = """ function $(s, r) { return (r || document).querySelector(s); } function $all(s, r) { return Array.from((r || document).querySelectorAll(s)); } +// Theme: 'auto' (follow the OS) | 'light' | 'dark'. Applied here in +// , before the body paints, so a stored choice never flashes the +// other theme first. +function themeMode() { return localStorage.getItem('lv-theme') || 'auto'; } +function applyTheme() { + var m = themeMode(); + if (m === 'auto') document.documentElement.removeAttribute('data-theme'); + else document.documentElement.setAttribute('data-theme', m); +} +function toggleTheme() { + var next = {auto: 'light', light: 'dark', dark: 'auto'}[themeMode()]; + localStorage.setItem('lv-theme', next); + applyTheme(); + paintThemeBtn(); +} +function paintThemeBtn() { + var b = $('#themebtn'); + if (b) b.textContent = {auto: '\\u25d1 auto', light: '\\u2600 light', + dark: '\\u263e dark'}[themeMode()]; +} +applyTheme(); + // Lightbox for images. document.addEventListener('click', function(e) { var a = e.target.closest('a.thumb, a.shot'); @@ -1672,51 +1974,102 @@ def ansi_to_html(text: str) -> str: } // Index page: filter + compare + collapsible groups. -function groupKey(d) { return 'lv-grp:' + d.dataset.key; } -function restoreGroups() { - $all('details.grp').forEach(function(d) { - var v = localStorage.getItem(groupKey(d)); - if (v !== null) d.open = v === '1'; +// No native dialogs anywhere in this page. +// This used to use confirm()/alert(). A browser that has been told to +// block a page's dialogs -- one checkbox on any earlier alert, and +// Chrome offers it -- makes confirm() return false with no UI at all, +// so buttons silently did nothing and there was nowhere to read why. +// ask() and toast() are ordinary DOM, so they cannot be suppressed. +function toast(text, cls) { + var t = $('#toast'); + if (!t) { + t = document.createElement('div'); + t.id = 'toast'; + document.body.appendChild(t); + } + t.className = cls || ''; + t.textContent = text; + t.hidden = false; + clearTimeout(window._toastTimer); + window._toastTimer = setTimeout(function() { t.hidden = true; }, 8000); +} +function askOpen() { return !!$('#askbar'); } +function closeAsk() { + var b = $('#askbar'); + if (b) b.remove(); +} +function ask(msg, yesLabel, onYes) { + closeAsk(); + var bar = document.createElement('div'); + bar.id = 'askbar'; + var t = document.createElement('span'); + t.className = 'askmsg'; + t.textContent = msg; + var yes = document.createElement('button'); + yes.className = 'askyes'; + yes.textContent = yesLabel; + yes.onclick = function() { closeAsk(); onYes(); }; + var no = document.createElement('button'); + no.textContent = 'cancel'; + no.onclick = function() { + closeAsk(); + setLaunchMsg('', ''); + }; + bar.appendChild(t); + bar.appendChild(yes); + bar.appendChild(no); + document.body.appendChild(bar); + yes.focus(); +} +function setLaunchMsg(cls, text) { + var m = $('#launchmsg'); + if (!m) return; + m.className = 'lmsg ' + cls; + m.textContent = text; +} +function launchRung(n) { + var btn = $('button.rungbtn[data-rung="' + n + '"]'); + var what = btn ? btn.dataset.warn : ''; + // Also say it in the panel, which is inside the scrolling content and + // certain to be on screen. The ask bar is fixed-positioned and the + // click has to leave SOME visible trace even if that ever breaks. + setLaunchMsg('warn', 'Confirm at the bottom of the page to start rung ' + + n + '.'); + ask('Start rung ' + n + '? ' + what, 'start rung ' + n, function() { + setLaunchMsg('', 'starting rung ' + n + '\u2026'); + fetch('/launch?rung=' + n, {method: 'POST'}).then(function(r) { + return r.text().then(function(t) { + setLaunchMsg(r.ok ? 'ok' : 'bad', t); + // The row appears once the run makes its log dir. + if (r.ok) setTimeout(function() { location.reload(); }, 6000); + }); + }).catch(function(e) { setLaunchMsg('bad', 'launch failed: ' + e); }); }); } -document.addEventListener('DOMContentLoaded', restoreGroups); -document.addEventListener('toggle', function(e) { - var d = e.target; - if (d.classList && d.classList.contains('grp') && !window._filtering) - localStorage.setItem(groupKey(d), d.open ? '1' : '0'); -}, true); -function setAllGroups(open) { - window._filtering = true; - $all('details.grp').forEach(function(d) { - d.open = open; - localStorage.setItem(groupKey(d), open ? '1' : '0'); +function kindFilter() { return sessionStorage.getItem('lv-kind') || 'all'; } +function setKind(k) { + sessionStorage.setItem('lv-kind', k); + paintKindBtns(); + applyFilters(); +} +function paintKindBtns() { + var k = kindFilter(); + $all('button.kindbtn').forEach(function(b) { + b.classList.toggle('on', b.dataset.kind === k); }); - window._filtering = false; } -function filterRuns(text) { - text = text.toLowerCase(); - sessionStorage.setItem('lv-filter', text); - window._filtering = true; +function applyFilters() { + var text = (sessionStorage.getItem('lv-filter') || '').toLowerCase(); + var kind = kindFilter(); $all('.runrow').forEach(function(row) { - row.classList.toggle('hidden', - !!text && row.dataset.key.indexOf(text) === -1); - }); - $all('details.grp.exp').forEach(function(d) { - var any = $all('.runrow', d).some(function(r) { - return !r.classList.contains('hidden'); - }); - d.classList.toggle('hidden', !any); - if (text) d.open = any; + var hide = (!!text && row.dataset.key.indexOf(text) === -1) || + (kind !== 'all' && row.dataset.kind !== kind); + row.classList.toggle('hidden', hide); }); - $all('details.grp.family').forEach(function(d) { - var any = $all('details.grp.exp', d).some(function(x) { - return !x.classList.contains('hidden'); - }); - d.classList.toggle('hidden', !any); - if (text) d.open = any; - }); - if (!text) restoreGroups(); - window._filtering = false; +} +function filterRuns(text) { + sessionStorage.setItem('lv-filter', text); + applyFilters(); } // Index page: sort runs by seed (the server order) or by start time. // Time mode interleaves each experiment's seeds newest-first; the @@ -1745,35 +2098,48 @@ def ansi_to_html(text: str) -> str: rows.forEach(function(r) { tbody.appendChild(r); }); }); } +function paintCompare() { + var n = $all('input.cmp:checked').length; + var b = $('#cmpbtn'); + if (!b) return; + b.disabled = n < 2; + b.textContent = n ? 'Compare ' + n + ' runs' : 'Compare'; + b.title = n < 2 ? 'Tick two or more runs in the cmp column' + : 'Line these runs up side by side'; +} function compareSelected() { var sel = $all('input.cmp:checked').map(function(c) { return c.value; }); - if (sel.length < 2) { alert('Select at least two runs to compare.'); return; } + if (sel.length < 2) { + toast('Tick at least two runs to compare them.', 'warn'); + return; + } location.href = '/compare?runs=' + encodeURIComponent(sel.join(';')); } // Kill / delete buttons on index run rows. POST only, so the auto- // refresh GETs can never trip these; reload shortly after success so // the status chip reflects the process actually exiting. -function postRun(url, msg) { - if (!confirm(msg)) return; - fetch(url, {method: 'POST'}).then(function(r) { - r.text().then(function(t) { - if (!r.ok) { alert(t); return; } - setTimeout(function() { location.reload(); }, 600); - }); - }).catch(function(e) { alert('request failed: ' + e); }); +function postRun(url, msg, yesLabel) { + ask(msg, yesLabel, function() { + fetch(url, {method: 'POST'}).then(function(r) { + r.text().then(function(t) { + if (!r.ok) { toast(t, 'bad'); return; } + setTimeout(function() { location.reload(); }, 600); + }); + }).catch(function(e) { toast('request failed: ' + e, 'bad'); }); + }); } function killRun(rel) { postRun('/kill?d=' + encodeURIComponent(rel), - 'Kill the live process of\\n' + rel + ' ?'); + 'Stop the running process of ' + rel + '?', 'stop it'); } function deleteRun(rel, live) { var msg = live - ? 'This run appears LIVE:\\n' + rel + - '\\nKill its process AND delete its log dir (and videos)?' - : 'Delete the log dir (and videos) of\\n' + rel + ' ?'; + ? 'This run is LIVE: ' + rel + + ' - stop its process AND delete its log dir and videos?' + : 'Delete the log dir and videos of ' + rel + '?'; postRun('/delete?d=' + encodeURIComponent(rel) + (live ? '&kill=1' : ''), - msg); + msg, live ? 'stop and delete' : 'delete'); } // Run page: hash routing into the content pane. @@ -1886,21 +2252,27 @@ def ansi_to_html(text: str) -> str: if (window._stamp === undefined) { window._stamp = s; return; } if (s !== window._stamp) { window._stamp = s; - if (autoOn()) { location.reload(); } else { showRefreshPill(); } + // Never reload with a question on screen: the reader would lose + // the click they were about to make. + if (autoOn() && !askOpen()) { location.reload(); } + else { showRefreshPill(); } } }).catch(function() {}); } document.addEventListener('DOMContentLoaded', function() { paintAutoBtn(); + paintThemeBtn(); + paintKindBtns(); + paintCompare(); paintSortBtn(); applySort(); pollStamp(); setInterval(pollStamp, REFRESH_MS); var f = $('#runfilter'); - if (f) { - f.value = sessionStorage.getItem('lv-filter') || ''; - if (f.value) filterRuns(f.value); - } + if (f) f.value = sessionStorage.getItem('lv-filter') || ''; + // Unconditional: a stored kind filter has to be reapplied even when + // the text box is empty. + if ($('.runrow')) applyFilters(); var r = window._restore; if (r) { var sb = $('.sidebar'); @@ -1926,13 +2298,52 @@ def ansi_to_html(text: str) -> str: def page(title: str, topbar_extra: str, body: str) -> str: """Wrap body in the full HTML page shell (topbar, CSS, and JS).""" - return ( - "" - f"{esc(title)}" - f"" - "

log viewer

" - f"{topbar_extra}" - f"
{body}") + return ("" + f"{esc(title)}" + f"" + "

log viewer

" + f"{topbar_extra}" + "" + "" + f"
{body}") + + +# A short reading key for the table below it. It used to explain the +# index's chip notation; the flat table has none, so it explains the +# columns that are actually on screen instead. +LEGEND_HTML = ( + "
What am I looking at?" + "
" + "
run
" + "
One execution of main.py, named by when it " + "started. Click it (or ▶ watch) to see the robot; " + "logs opens the transcripts and files instead.
" + "
RUNG n
" + "
Where a domino-fan run sits on the ladder - each rung hands " + "the agent less. 1: ground-truth simulator and predicates, the " + "process planner plans. 2: same, but the AGENT plans. 3: the " + "simulator's structure only, its parameters fitted from data - " + "SKIPPED on domino-fan, where the wind acts for ~2 steps and its " + "force is not identifiable. 4: the base simulator alone - it must " + "find the wind, model it, and invent predicates; this is the open " + "question here. Hover a badge for that rung's line. Start one " + "with the buttons at the top of this page.
" + "
approach
" + "
The method. An oracle_* approach is handed the " + "ground-truth model and learns nothing - it is an upper bound. An " + "agent_* one has to learn.
" + "
experiment
" + "
One environment + arm configuration. Change a flag and it is " + "a different experiment; rerun with another seed and it is not." + "
" + "
result
" + "
solved/total on held-out tasks, with the mean reward. " + "This is the score. On the domino tasks reward is 1 for a " + "certified solve minus a penalty per domino spent, so a run using " + "fewer blocks scores higher: on domino-fan 0.950 is a one-block " + "solve and 0.900 a two-block one.
" + "
") def chip(label: Any, cls: str = "", title: str = "") -> str: @@ -1981,83 +2392,6 @@ def explore_mark(ep: Dict[str, Any]) -> Tuple[str, str, str]: return mark, cls, title -def _split_episodes( - episodes: EpList -) -> Tuple[Dict[int, Dict[int, EpList]], Dict[int, EpList]]: - """Episodes bucketed by round, as (test-by-task, non-test).""" - tests: Dict[int, Dict[int, EpList]] = {} - misc: Dict[int, EpList] = {} - for ep in episodes: - rnd = ep.get("round", 0) - if ep["kind"] == "test" and ep["task"] is not None: - tests.setdefault(rnd, {}).setdefault(ep["task"], []).append(ep) - else: - misc.setdefault(rnd, []).append(ep) - return tests, misc - - -def grid_layout(runs: List[EpList]) -> Dict[str, Any]: - """Column layout shared by every run's episode grid. - - Holding the task set and the round column fixed across every run on - the page is what lets task t1's chips land at the same x whether the - run above it retried t0 twice or not, and whether it belongs to the - same experiment or not. - """ - tasks: Set[int] = set() - rounds = False - misc = False - for episodes in runs: - by_round, misc_by_round = _split_episodes(episodes) - rounds = rounds or max(list(by_round) + list(misc_by_round), - default=0) > 0 - misc = misc or bool(misc_by_round) - for by_task in by_round.values(): - tasks.update(by_task) - return {"tasks": sorted(tasks), "rounds": rounds, "misc": misc} - - -def grid_width(layout: Dict[str, Any]) -> int: - """Pixel width of an episode grid drawn with this layout.""" - return (len(layout["tasks"]) * TASK_W + - (ROUND_W if layout["rounds"] else 0) + - (MISC_W if layout["misc"] else 0)) - - -def episode_grid(episodes: EpList, - layout: Optional[Dict[str, Any]] = None) -> str: - """Chips laid out one row per test round, one column per task. - - Vertical alignment makes it easy to compare a task's outcome against - earlier rounds and against other runs; retries within a round stack - inside their task's column rather than widening it. - """ - if layout is None: - layout = grid_layout([episodes]) - tests, misc = _split_episodes(episodes) - if not tests and not misc: - return "" - n_rounds = max(list(tests) + list(misc)) + 1 - rows = [] - for rnd in range(n_rounds): - cells = [] - if layout["rounds"]: - label = f"r{int(rnd + 1)}" if n_rounds > 1 else "" - cells.append(f"{label}") - for task in layout["tasks"]: - # One retry per line: two short chips would otherwise share a - # line while longer ones stack, making the column read ragged. - chips = "".join(f"
{_test_chip(ep)}
" - for ep in tests.get(rnd, {}).get(task, [])) - cells.append(f"{chips}") - if layout["misc"]: - chips = "".join(_misc_chip(ep) for ep in misc.get(rnd, [])) - cells.append(f"{chips}") - rows.append(f"{''.join(cells)}") - return (f"" - f"{''.join(rows)}
") - - def run_status(r: Dict[str, Any], summary: Dict[str, Any], live: LiveProcs, is_newest: bool) -> Tuple[str, str]: """(label, css class) for a run's lifecycle state. @@ -2096,45 +2430,212 @@ def status_chip(r: Dict[str, Any], summary: Dict[str, Any], live: LiveProcs, return chip(label, cls, title) -def _test_chip(ep: Dict[str, Any]) -> str: - """Chip for one test episode, e.g. "003 t1 ✓".""" - mark, cls, title = test_mark(ep) - label = f"{int(ep['num']):03} t{ep['task']}" - if mark: - label += " " + mark - return chip(label, cls, title) +# The domino-fan ladder, keyed by the arm that experiment_id carries. +# Each rung hands the agent less; the label says what it must supply, so +# a row reads as a rung of the experiment rather than an approach name. +# The ladder is the same on both fan envs, so the rung comes from the +# ARM and the env is shown beside it: what differs between the two is +# not what the agent is given but what starts the cascade. +_RUNG_BY_ARM = { + "oracle": (1, "GT sim + GT predicates, process planner plans"), + "agent_model_based_planning": + (2, "GT sim + GT predicates, the AGENT plans"), + "agent_param_learning": + (3, "GT sim structure; must fit the wind's parameters"), + "agent_po_predicate_invention_al": + (4, "base sim only; must find the mechanism and invent predicates"), +} +# How the cascade is started, which is the whole difference between the +# two envs and so the thing a reader most needs on the row. +_TRIGGER_BY_ENV = { + "domino_fan": ("BUTTON", "the robot presses a switch to start the fan"), + "domino_declare": + ("DECLARE", "no switch: the robot declares finished and the fan starts"), + "domino_blow": + ("BLOW", "place a block so the wind knocks it FLAT into a goal region; " + "the only env here where the wind's strength is fittable"), +} -def _misc_chip(ep: Dict[str, Any]) -> str: - """Chip for one non-test episode, e.g. "002 explore ✓ 0.70".""" - label = f"{int(ep['num']):03} {ep['kind']}" - title = "" - if "env_accepted" in ep: - mark, _, title = explore_mark(ep) - label += " " + mark - return chip(label, "kind-" + ep["kind"], title) +def launch_rung(rung: str) -> Tuple[bool, str]: + """Start scripts/domino_fan/run_rung.sh for one rung, detached. -# ----------------------------------------------------------------- pages + Detached on purpose: the run outlives this request (rungs 3 and 4 + are hour-scale), and its output goes to the run's own log dir, which + the dashboard is already watching - so progress shows up as the rows + and episode chips it draws anyway, with no plumbing of its own. + Refuses to start a second one. Two PyBullet runs at once is how a + laptop ends up with a dozen physics servers open, and the dashboard + would have no way to say which row belongs to which. + """ + if rung not in {"1", "2", "3", "4"}: + return False, f"unknown rung: {rung!r}" + # LOGS_ROOT is /logs; the script sits beside it. + repo_root = os.path.dirname(os.path.abspath(LOGS_ROOT)) + script = os.path.join(repo_root, "scripts", "domino_fan", "run_rung.sh") + if not os.path.isfile(script): + return False, f"missing {script}" + if _live_proc_matches(): + return False, ("a run is already going - stop it first with the " + "kill button on its row") + env = dict(os.environ, PYTHONHASHSEED="0") + with open(os.devnull, "wb") as devnull: + subprocess.Popen( # pylint: disable=consider-using-with + ["/bin/bash", script, rung], + cwd=repo_root, + stdout=devnull, + stderr=subprocess.STDOUT, + stdin=devnull, + start_new_session=True, + env=env) + return True, (f"rung {rung} started - its row appears once the run " + "creates its log dir (a few seconds)") + + +# Each rung's one-line warning, shown when its button is armed. The +# cost of a click is the thing a reader most needs before making it. +_RUNG_BLURB = { + "1": "Oracle: ground-truth simulator and predicates, the process " + "planner plans. ~2 minutes, no LLM calls.", + "2": "Ground-truth simulator and predicates, but the AGENT plans. " + "Calls Claude; usually a few minutes.", + "3": "The simulator's structure only - the agent must fit the wind " + "parameters from data. Calls Claude; can run for an hour.", + "4": "Base simulator alone - find the wind, model it, invent " + "predicates. Calls Claude; the longest rung, hours.", +} -def run_row(r: Dict[str, Any], summary: Dict[str, Any], layout: Dict[str, Any], - live: LiveProcs, is_newest: bool) -> str: - """Table row summarizing one run for the index page.""" + +def _live_progress(summary: Dict[str, Any]) -> str: + """What a running run has got through so far, in a few words.""" eps = summary.get("episodes", []) + n_test = sum(1 for e in eps if e["kind"] == "test") + n_exp = sum(1 for e in eps if e["kind"] == "explore") + n_fit = len(summary.get("fits", [])) + bits = [] + if n_exp: + bits.append(f"{n_exp} practice") + if n_test: + bits.append(f"{n_test} test") + if n_fit: + bits.append(f"{n_fit} fit" + ("s" if n_fit != 1 else "")) + # No episode has been logged yet: the run is in setup (importing + # pybullet, building the env), which takes tens of seconds. + return " \u00b7 ".join(bits) if bits else "starting up" + + +def live_panel(runs: List[Dict[str, Any]], summaries: Dict[str, Dict[str, + Any]], + live: LiveProcs, newest: Dict[Tuple[str, str], + Tuple[float, str]]) -> str: + """What is running right now, and the buttons to start something. + + The first question anyone brings to this page is "is it running?", + and it used to be answerable only by finding the right row in a + table sorted by something else. So the live runs come first, with + their elapsed time and how far they have got, and the ladder + buttons sit beside them - where the answer to "can I start one?" + is visible at the moment of asking. + """ + now = time.time() + cards = [] + for r in runs: + summary = summaries.get(r["rel"], {}) + is_newest = newest.get((r["exp"], r["seed"]), (0.0, ""))[1] == r["rel"] + status, _ = run_status(r, summary, live, is_newest) + if status != "running": + continue + start_ts = _run_start_ts(r["name"], r["mtime"]) + _, _, expname = r["exp"].partition("/") + expname = expname or r["exp"] + esc_rel = esc(r["rel"]) + cards.append( + f"
" + f"{rung_badge(r['exp'])}{esc(expname)}" + f"{esc(r['seed'])}" + f"running {_fmt_duration(now - start_ts)}" + f"" + f"{_live_progress(summary)}" + f"logs" + f"watch" + f"
") + if cards: + head = ("
Running now" + "updates on its own; the run keeps " + "going if you close this page
") + state = head + "".join(cards) + # The server refuses a second run anyway (two PyBullet servers at + # once is how the machine ends up thrashing); saying so on the + # buttons beats saying it in an error after the click. + btn_note = " disabled title='Stop the running one first'" + else: + state = ("
Nothing running" + "start a rung of the domino-fan " + "ladder below
") + btn_note = "" + btns = "".join( + f"" + for n in ("1", "2", "3", "4")) + return ("
" + state + + f"
{btns}" + "
") + + +def rung_badge(exp: str) -> str: + """"RUNG n" chip for a domino-fan experiment, else empty.""" + key = exp.split("/")[-1] + env_key, _, arm = key.partition("-") + entry = _RUNG_BY_ARM.get(arm) + trigger = _TRIGGER_BY_ENV.get(env_key) + if entry is None or trigger is None: + return "" + num, what = entry + word, how = trigger + cls = "trig" + if env_key == "domino_declare": + cls = "trig trig-declare" + elif env_key == "domino_blow": + cls = "trig trig-blow" + return (f"RUNG {num}" + f"{word}") + + +def run_row(r: Dict[str, Any], summary: Dict[str, Any], live: LiveProcs, + is_newest: bool) -> str: + """One flat row on the index: what it was, how it went, where to look.""" tr_str = test_results_str(summary) or "-" - cost = summary.get("total_cost", 0.0) fmt = "%Y-%m-%d %H:%M" start_ts = _run_start_ts(r["name"], r["mtime"]) sstr = datetime.datetime.fromtimestamp(start_ts).strftime(fmt) - mstr = datetime.datetime.fromtimestamp(r["activity"]).strftime(fmt) dur_str = _fmt_duration(max(0.0, r["activity"] - start_ts)) status, _ = run_status(r, summary, live, is_newest) - # The status joins the filter key, so "running" narrows to live runs. + fam, _, expname = r["exp"].partition("/") + expname = expname or fam + # Approach and experiment join the filter key, so what used to be a + # group heading is now something you type into the filter box. key = f"{r['exp']} {r['seed']} {r['name']} {status}".lower() - cost_str = f"${cost:.2f}" if cost else "-" - # Path to paste into a terminal at the server's working directory. + # An oracle arm is handed the ground-truth model and learns nothing; + # everything else is a learner. The approach name is the only thing + # that says which, and it says it reliably. + kind = "oracle" if fam.startswith("oracle") else "learning" copy_path = os.path.relpath(os.path.join(LOGS_ROOT, r["rel"])) + # A run with no videos gets no watch link and its name points at the + # logs instead: the reel would only be able to say "nothing here". + has_vids = run_has_videos(r["rel"]) + if has_vids: + watch_cell = (f"" + "▶ watch") + primary = f"/replays?d={q(r['rel'])}" + else: + watch_cell = ("no video") + primary = f"/run?d={q(r['rel'])}" is_live = status == "running" esc_rel = esc(r["rel"]) kill_btn = "" @@ -2148,40 +2649,42 @@ def run_row(r: Dict[str, Any], summary: Dict[str, Any], layout: Dict[str, Any], live_flag = "true" if is_live else "false" del_btn = (f"") + "\u2715") return ("" - f"" + f"data-kind='{kind}' data-start='{start_ts:.0f}'>" + f"" "" - f"{esc(r['name'])}" + f"{watch_cell}" + f"{esc(r['name'])}" + f"logs" f"{del_btn}" + f"title='Copy run path'>\u29c9{del_btn}" + f"{esc(fam)}" + f"{rung_badge(r['exp'])}" + f"{esc(expname)}" f"{esc(r['seed'])}" f"{status_chip(r, summary, live, is_newest)}{kill_btn}" - f"{episode_grid(eps, layout)}" - f"{esc(tr_str)}" - f"{cost_str}" + f"{esc(tr_str)}" f"{sstr}" - f"{mstr}" f"{dur_str}") def index_page() -> str: - """Runs overview page grouped by family and experiment.""" + """One flat table of every run, newest first. + + This page used to nest runs two deep (approach, then experiment) in + collapsible groups. The hierarchy was real, but it was also the + first thing a reader had to fight: three levels of chrome around the + rows they came for. Approach and experiment are columns now - + visible on every row and typeable into the filter box, which is what + the groups were actually used for. + """ runs = find_runs() - families: Dict[str, Dict[str, List[Dict[str, Any]]]] = {} - for r in runs: - fam, _, rest = r["exp"].partition("/") - families.setdefault(fam, {}).setdefault(rest or fam, []).append(r) summaries = {r["rel"]: run_summary(r["rel"]) or {} for r in runs} - # The task and round columns are laid out once for the whole page, so - # a task's chips line up across runs, experiments, and families. The - # explore/learn column sits to the right of every task column, so it - # can stay per-family without costing any of that alignment - which - # spares families that never explore its reserved width. - page_layout = grid_layout( - [s.get("episodes", []) for s in summaries.values()]) live = live_runs(runs) # A process lsof could not pin owns the newest run of its experiment # and seed: the one it made at startup. Older runs of that key are @@ -2193,52 +2696,59 @@ def index_page() -> str: if ts >= newest.get(key, (0.0, ""))[0]: newest[key] = (ts, r["rel"]) body = [ - "
" + "
", + live_panel(runs, summaries, live, newest), LEGEND_HTML ] if not runs: body.append( f"

No run_* directories found under {esc(LOGS_ROOT)}.

") - table_head = ("runseedstatus" - "episodestest results (info.log)" - "coststartedmodified" - "time") - for fam in sorted(families): - exps = families[fam] - fam_runs = [r for rs in exps.values() for r in rs] - fam_misc = grid_layout( - [summaries[r["rel"]].get("episodes", []) for r in fam_runs]) - layout = dict(page_layout, misc=fam_misc["misc"]) - widths = [w or grid_width(layout) for w in RUN_COL_W] - cols = "" + "".join(f"" - for w in widths) + "" - body.append(f"
{esc(fam)} " - f"({len(exps)} experiments, {len(fam_runs)} runs)" - "") - for expname in sorted(exps): - rows = "".join( - run_row(r, summaries[r["rel"]], layout, live, newest[( - r["exp"], r["seed"])][1] == r["rel"]) - for r in exps[expname]) - body.append(f"
" - f"{esc(expname)} " - f"({len(exps[expname])} runs)" - f"" - f"{cols}{table_head}{rows}
") - body.append("
") + else: + head = ( + "cmp" + "" + "run" + "approach" + "experiment" + "seed" + "status" + "result" + "started" + "time" + "") + rows = "".join( + run_row(r, summaries[r["rel"]], live, newest[( + r["exp"], r["seed"])][1] == r["rel"]) for r in runs) + body.append(f"{head}{rows}
") body.append("
") - topbar = ("" + "" + "" + "" + "" + "" "" - "" - "" - "{esc(LOGS_ROOT)}") + "newest runs first'>" + "" + f"{esc(LOGS_ROOT)}") return page("runs - log viewer", topbar, "".join(body)) @@ -2287,6 +2797,190 @@ def file_tree_html(run_abs: str, run_rel: str, rel: str = "") -> str: return "".join(out) +def replay_caption(ep: Dict[str, Any]) -> Tuple[str, str, str]: + """(heading, plain-English explanation, verdict html) for one clip. + + The chips elsewhere are shorthand for people who already know the + pipeline. Here the same facts are spelled out, because this page + exists for the question "what is the robot actually doing?" rather + than "how did this run score". + """ + rnd = int(ep.get("round", 0)) + if ep["kind"] == "test": + head = f"Round {rnd + 1} - test on task {ep['task']}" + why = ("A held-out task the robot did not practise on. This is " + "the episode that scores the run.") + else: + head = f"Round {rnd + 1} - practice episode" + why = ("The robot tries the task itself to gather data. What it " + "sees here is what the next round learns from.") + verdict = "no env verdict recorded" + solved = ep.get("env_solved", ep.get("env_accepted")) + if solved is not None: + reward = (f" · reward {ep['env_reward']:.2f}" + if "env_reward" in ep else "") + cls, word = ("ok", "solved") if solved else ("bad", "failed") + verdict = f"{word}{reward}" + return head, why, verdict + + +def _clip_card(ep: Dict[str, Any], vids: str, run_rel: str) -> str: + """One small captioned player in a round's filmstrip.""" + if ep["kind"] == "test": + what = f"test · task {ep['task']}" + else: + what = "practice" + solved = ep.get("env_solved", ep.get("env_accepted")) + if solved is None: + verdict = "no verdict" + else: + cls, word = ("ok", "solved") if solved else ("bad", "failed") + reward = (f" {ep['env_reward']:.2f}" if "env_reward" in ep else "") + verdict = f"{word}{reward}" + return (f"
{vids}" + f"
{what} · {verdict}
" + f"transcript →
" + "
") + + +def _fit_card(fit: Dict[str, Any]) -> str: + """The sysID verdict that separates one round from the next.""" + rows = "".join(f"
{esc(k)} → " + f"{v:.4g}
" + for k, v in sorted(fit["params"].items())) + refit = (f" ({int(fit['refits'])} fits this " + "cycle; the last one is what the planner carries " + "forward)" if fit["refits"] > 1 else "") + return ("
⚙ What it learned here" + f"{refit}
{rows}
" + "These physical parameters were " + "recovered from the practice episodes above and handed to " + "the planner for the next round.
") + + +def _is_oracle(run_rel: str) -> bool: + """True for an oracle_* approach: handed ground truth, learns nothing. + + Read off the approach family in the path rather than off how much + the run happened to log. A learner killed in its first round has no + fits and one round too, and calling that "does not learn" describes + the interruption as if it were the method. + """ + return run_rel.split("/")[0].startswith("oracle") + + +def _reel_intro(run_rel: str, summary: Dict[str, Any]) -> str: + """The one line above the reel, saying what kind of run this is.""" + if _is_oracle(run_rel): + return ("This approach does not learn: it is handed the " + "ground-truth model and solves the held-out tasks once. " + "What follows is that test.") + if not summary.get("fits"): + # An agent arm with no online learning cycles: it still has to + # plan and act, it just never refits a model mid-run. + return ("The agent plans and acts here with the model it was " + "given - this run has no online learning cycles, so " + "there is no fit between rounds. What follows is its " + "test.") + return ("The whole run, oldest first. Each round is one turn of the " + "loop: the robot practices to gather data, the fit " + "recovers what the world is really like, and a test " + "on a held-out task scores it. Read top to bottom to watch " + "it learn.") + + +def _no_videos_reason(run_rel: str, summary: Dict[str, Any]) -> str: + """Why an empty reel is empty. + + "No videos" has three quite different causes and only one of them + is about flags: a run still going has not written any yet, and a + killed one never will. Saying the flag line to all three sends a + reader to change a config that was already correct. + """ + if not summary.get("done"): + runs = find_runs() + live = live_runs(runs) + r = next((x for x in runs if x["rel"] == run_rel), None) + if r is not None: + newest = max( + (y for y in runs + if (y["exp"], y["seed"]) == (r["exp"], r["seed"])), + key=lambda y: _run_start_ts(y["name"], y["mtime"])) + status, _ = run_status(r, summary, live, + newest["rel"] == run_rel) + if status == "running": + return ("This run is still going. Clips appear here as " + "its episodes finish - the page refreshes " + "itself.") + return ("This run was stopped before it recorded a video. " + "Its transcripts and logs are still there under " + "logs & transcripts.") + return ("This run recorded no videos. They are written when main.py " + "runs with --make_test_videos / --make_interaction_videos.") + + +def replays_page(run_rel: str) -> Optional[str]: + """A run's whole pipeline as a reel: practice, what it learned, test. + + 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? Here the clips + are small and side by side, grouped into the rounds of the online + loop, with the sysID verdict shown between the rounds it separates. + """ + run_abs = safe_join(run_rel) + if not run_abs or not os.path.isdir(run_abs): + return None + summary = run_summary(run_rel) + assert summary is not None + # Clips grouped by round; a replanned task's two transcripts share + # one video, so identical players are shown once rather than + # reading as two attempts that never happened. + by_round: Dict[int, List[str]] = {} + seen: Set[str] = set() + claimed: Set[str] = set() + for ep in summary["episodes"]: + names = episode_video_names(ep, run_rel) + claimed.update(n for n, _ in names) + vids = episode_videos(ep, run_rel) + if not vids or vids in seen: + continue + seen.add(vids) + rnd = int(ep.get("round", 0)) + by_round.setdefault(rnd, []).append(_clip_card(ep, vids, run_rel)) + # Videos no transcript owns still belong on the reel; an oracle run + # has nothing BUT those. + for rnd, cards in _orphan_clips(run_rel, summary, claimed).items(): + by_round.setdefault(rnd, []).extend(cards) + fits_by_round: Dict[int, Dict[str, Any]] = { + int(f["after_round"]): f + for f in summary.get("fits", []) + } + out = [] + for rnd in sorted(set(by_round) | set(fits_by_round)): + clips = by_round.get(rnd, []) + strip = ("
" + "".join(clips) + + "
" if clips else + "

No videos recorded for this round.

") + fit = fits_by_round.get(rnd) + band = _fit_card(fit) if fit else "" + out.append(f"

Round {rnd + 1}

" + f"{strip}{band}
") + if not out: + out = [f"

{_no_videos_reason(run_rel, summary)}

"] + intro = f"" + body = (f"
{intro}" + f"{''.join(out)}
") + topbar = ("← all runs" + f"" + "logs & transcripts" + f"{esc(run_rel)}") + return page(run_rel + " replays - log viewer", topbar, body) + + def run_page(run_rel: str) -> Optional[str]: """Single-run page: episode sidebar, file tree, and content pane.""" run_abs = safe_join(run_rel) @@ -2304,6 +2998,11 @@ def run_page(run_rel: str) -> Optional[str]: mark, cls, title = test_mark(ep) elif "env_accepted" in ep: mark, cls, title = explore_mark(ep) + # An episode with no env verdict (a learn query) still + # gets its kind colour, so the sidebar speaks the same chip + # vocabulary as the index's episode strip. + if not cls: + cls = "kind-" + ep["kind"] cost = (f" ${ep['solve_cost']:.2f}" if "solve_cost" in ep else "") task_str = (f" task{ep['task']}" if ep["task"] is not None else "") mark_str = " " + mark if mark else "" @@ -2353,8 +3052,11 @@ def run_page(run_rel: str) -> Optional[str]: f"

Select a file.

" "
") - topbar = f"{esc(run_rel)}" - return page(run_rel + " - log viewer", topbar, body) + topbar = ("← all runs" + f"" + "▶ watch videos" + f"{esc(run_rel)}") + return page(run_rel + " logs - log viewer", topbar, body) # ------------------------------------------------------------- fragments @@ -2393,6 +3095,23 @@ def _video_base(run_rel: str) -> Optional[str]: return None +def run_has_videos(run_rel: str) -> bool: + """Does this run have any video to show? + + Videos are only written when main.py runs with --make_test_videos / + --make_interaction_videos, so plenty of runs have none. Offering a + watch link for those and explaining the emptiness only after the + click wastes the click. + """ + base = _video_base(run_rel) + if not base: + return False + try: + return any(n.endswith(".mp4") for n in os.listdir(base)) + except OSError: + return False + + def video_url_rel(run_rel: str, name: str) -> str: """The /rawvideo path of one of a run's video files.""" base = _video_base(run_rel) @@ -2462,14 +3181,24 @@ def interaction_videos_for_run( def _video_figure(url: str, label: str) -> str: """One video player with its caption and raw link.""" + # #t=0.1 makes the browser seek there and paint that frame as the + # poster. Without it a preload='metadata' player is a black + # rectangle until someone presses play, which is unreadable on the + # replay reel where a dozen clips sit side by side. return (f"
{label} · " + f"src='{url}#t=0.1'>" + f"
{label} · " f"raw" f"
") -def episode_videos(ep: Dict[str, Any], run_rel: str) -> str: - """Players for the env episode this transcript belongs to. +def episode_video_names(ep: Dict[str, Any], + run_rel: str) -> List[Tuple[str, str]]: + """(filename, label) of the videos the env episode of this + transcript owns. + + Split out from the rendering so the replay reel can tell which of a + run's videos no transcript claims -- see _orphan_clips. main.py names a test video by task and learning cycle rather than by query, so the two transcripts of a replanned task (001/002 @@ -2483,9 +3212,8 @@ def episode_videos(ep: Dict[str, Any], run_rel: str) -> str: if ep["kind"] == "test" and ep["task"] is not None: key = (int(ep["task"]), _cycle_tag(int(ep.get("round", 0)))) for name, is_failure in videos_for_run(run_rel).get(key, []): - url = "/rawvideo?p=" + q(video_url_rel(run_rel, name)) label = "failure video" if is_failure else "test video" - out.append(_video_figure(url, label)) + out.append((name, label)) elif ep["kind"] == "explore": # Interaction videos are stamped with the 0-based learning cycle # directly (no cycleNone offset like test rounds), and an explore @@ -2499,9 +3227,87 @@ def episode_videos(ep: Dict[str, Any], run_rel: str) -> str: label = "interaction video" else: continue # another explore session's episode - url = "/rawvideo?p=" + q(video_url_rel(run_rel, name)) - out.append(_video_figure(url, label)) - return f"
{''.join(out)}
" if out else "" + out.append((name, label)) + return out + + +def episode_videos(ep: Dict[str, Any], run_rel: str) -> str: + """Players for the env episode this transcript belongs to.""" + figs = [ + _video_figure("/rawvideo?p=" + q(video_url_rel(run_rel, name)), label) + for name, label in episode_video_names(ep, run_rel) + ] + return f"
{''.join(figs)}
" if figs else "" + + +def _round_of_tag(tag: str, is_test: bool) -> int: + """The reel round a video's cycle tag belongs to. + + The inverse of _cycle_tag for test videos, whose round r>0 carries + the tag of learning cycle r-1. Interaction videos are stamped with + their cycle directly, so for those the tag IS the round. + """ + if tag in ("cycleNone", "cycle"): + return 0 + try: + cycle = int(tag[len("cycle"):]) + except ValueError: + return 0 + return cycle + 1 if is_test else cycle + + +def _orphan_clips(run_rel: str, summary: Dict[str, Any], + claimed: Set[str]) -> Dict[int, List[str]]: + """Cards for a run's videos that no transcript claims. + + An oracle run writes no agent transcripts at all -- there is no LLM + in the loop to write them -- so every one of its videos is an + orphan, and a reel built only from transcripts showed such a run as + "recorded no videos" while the mp4 sat on disk. The env verdict + still comes from info.log, which oracle runs do write. + """ + rounds = summary.get("rounds", []) + out: Dict[int, List[str]] = {} + for (task, tag), entries in sorted(videos_for_run(run_rel).items()): + rnd = _round_of_tag(tag, is_test=True) + for name, is_failure in entries: + if name in claimed: + continue + verdict = None + if rnd < len(rounds): + verdict = rounds[rnd].get(task) + what = f"test · task {task}" + label = "failure video" if is_failure else "test video" + out.setdefault(rnd, []).append( + _orphan_card(run_rel, name, label, what, verdict)) + for tag, ivs in sorted(interaction_videos_for_run(run_rel).items()): + rnd = _round_of_tag(tag, is_test=False) + for _, name in ivs: + if name in claimed: + continue + out.setdefault(rnd, []).append( + _orphan_card(run_rel, name, "interaction video", "practice", + None)) + return out + + +def _orphan_card(run_rel: str, name: str, label: str, what: str, + verdict: Optional[Dict[str, Any]]) -> str: + """One filmstrip card for a video with no transcript behind it.""" + url = "/rawvideo?p=" + q(video_url_rel(run_rel, name)) + vids = f"
{_video_figure(url, label)}
" + if verdict is None: + note = "no verdict" + else: + cls, word = ("ok", "solved") if verdict["solved"] else ("bad", + "failed") + reward = ("" if verdict.get("reward") is None else + f" {verdict['reward']:.2f}") + note = f"{word}{reward}" + return (f"
{vids}" + f"
{what} · {note}
" + "no transcript — this approach " + "writes none
") def episode_banner(ep: Dict[str, Any], n_rounds: int = 0) -> str: @@ -2805,7 +3611,7 @@ def stat_row(label: str, fn: Callable[[Dict[str, Any]], str]) -> str: "total cost", lambda s: (f"${s['total_cost']:.2f}") if s["total_cost"] else "-")) rows_html = "".join(rows) - body = ("

Run comparison

" "" f"{head}{rows_html}
") @@ -2897,7 +3703,9 @@ def do_POST(self) -> None: k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items() } - if parsed.path == "/kill": + if parsed.path == "/launch": + ok, msg = launch_rung(params.get("rung", "")) + elif parsed.path == "/kill": ok, msg = kill_run(params.get("d", "")) elif parsed.path == "/delete": ok, msg = delete_run(params.get("d", ""), @@ -2944,6 +3752,12 @@ def route(self) -> None: self.send_html("

Run not found.

", status=404) else: self.send_html(html_text) + elif route == "/replays": + html_text = replays_page(params.get("d", "")) + if html_text is None: + self.send_html("

Run not found.

", status=404) + else: + self.send_html(html_text) elif route == "/view": self.send_html( view_fragment(params.get("d", ""), params.get("f", "")))