From 221d122cc3c0312386825e4450a8ac899a3d62f7 Mon Sep 17 00:00:00 2001 From: Yichao Liang Date: Thu, 8 Oct 2026 10:52:12 -0400 Subject: [PATCH 1/2] Keep the from-assets arm's untested physics uncertain Seed 1 of the Domino fixes_r1 round lost its test level on two engine materials its scene never declared: the dominoes' spinning and rolling friction ran at PyBullet's 0 (the world: 0.5 and 0.006), and every rehearsal predicted a cascade that the world did not produce. Its level-1 recording, a straight chain, could not identify either value, and declaring them would not have helped: the fit kept them at their starting values with a narrow belief, because its prior is 0.75 times the starting value (a spinning friction started at 0.05 came out 0.057-0.12). - code_sim_learning_prior_spans_bounds widens the fit's prior on every parameter to at least its declared range, so a parameter the recordings do not constrain keeps that whole range in the belief. The from_assets_opus menu entry sets it, and the arm refuses to start without it; the supplied-base arms keep the anchored prior, whose starting values are calibrated defaults. - SceneBase.sampled_material_specs offers every material the scene does not declare as a parameter over a plausible range around the scene's own value (SAMPLED_MATERIALS), and accepts a value for it, as a rehearsal draw sets one. The from-assets arm joins these prior-only factors to its belief (join_beliefs), so joint draws and physics sweeps vary them; they are never fitted and cost no replays. - The from-assets prompts say so: undeclared materials are sampled, and a declared parameter's prior spans its declared range. --- docs/amps/empiric-from-assets.md | 17 +++++ predicators/agent_sdk/play_prompts.py | 17 ++++- predicators/agent_sdk/prompts/play_system.md | 4 +- .../agent_sdk/prompts/subclass_model.md | 9 ++- .../agent_continual_from_assets_approach.py | 70 +++++++++++++++++- .../agent_continual_real_to_sim_approach.py | 1 + .../approaches/agent_sim_learning_approach.py | 9 ++- predicators/code_sim_learning/fit_space.py | 16 ++++- .../code_sim_learning/parameter_belief.py | 34 +++++++++ predicators/code_sim_learning/scene_base.py | 72 ++++++++++++++++--- predicators/settings.py | 10 +++ scripts/configs/empiric/approaches.yaml | 3 + ...st_agent_continual_real_to_sim_approach.py | 71 ++++++++++++++++-- .../test_log_scale_params.py | 14 ++++ .../test_parameter_belief.py | 31 +++++++- tests/test_cluster_utils_configs.py | 8 ++- 16 files changed, 356 insertions(+), 30 deletions(-) diff --git a/docs/amps/empiric-from-assets.md b/docs/amps/empiric-from-assets.md index 20b12b2a29..957a62e0fa 100644 --- a/docs/amps/empiric-from-assets.md +++ b/docs/amps/empiric-from-assets.md @@ -27,6 +27,23 @@ The generic wrapper still supplies the robot controller, grasp conventions, gene The scalar store does not implement domain dynamics or joint/readout mappings. This is a reduction in supplied domain implementation, not reconstruction from raw vision or a bare-engine-only benchmark. +## Belief over the scene's physics + +Added October 2026, after seed 1 of the Domino round `fixes_r1` lost its test level. +That agent never declared the dominoes' spinning and rolling friction, so its model ran both at PyBullet's 0 while the world uses 0.5 and 0.006. +Its rehearsals predicted a cascade that the world did not produce. +Its level-1 recording, a straight chain, could not have identified either value. +Two settings keep this arm's belief from claiming knowledge the data do not give: + +- The fit's prior on each parameter spans the parameter's declared range (`code_sim_learning_prior_spans_bounds`, set by the `from_assets_opus` menu entry and required by the arm). + Under the anchored prior of the supplied-base arms, 0.75 times the starting value, a spinning friction started at 0.05 came out with a 68% interval of 0.057 to 0.12 on data that said nothing about it. +- Rehearsal samples every engine material the scene does not declare. + `SceneBase.sampled_material_specs` gives each one a plausible range around the scene's own value (`SAMPLED_MATERIALS`), and the arm joins these prior-only factors to its belief. + Joint draws and physics sweeps vary them; they are never fitted, so they cost no replays. + +The supplied-base arms keep the anchored prior: their starting values are the base's calibrated defaults. +The real-to-sim comparison fits nothing and samples nothing. + ## Pilot The configuration is [continual_from_assets_pilot_r1.yaml](https://github.com/BasisResearch/predicators/blob/72d7258ed3992728c8501c615ca1261ec3f49feb/scripts/configs/predicatorv3/continual_from_assets_pilot_r1.yaml). diff --git a/predicators/agent_sdk/play_prompts.py b/predicators/agent_sdk/play_prompts.py index 4efff28a1f..395ce596ce 100644 --- a/predicators/agent_sdk/play_prompts.py +++ b/predicators/agent_sdk/play_prompts.py @@ -384,6 +384,7 @@ def build_model_contract( frozen: bool = False, supplied_model: bool = False, scene_built: bool = False, + sampled_materials: bool = False, ) -> str: """The contract of the model files, for the model arm's system prompt (``play_model_contract.md``). @@ -399,7 +400,9 @@ def build_model_contract( dynamics) keeps only the predicate contract: the agent never writes ``simulator.py``. ``scene_built`` (the agentic real-to-sim arm) replaces the domain-twin subclass contract with the ``SceneBase`` - one: the agent loads the scene itself. + one: the agent loads the scene itself. ``sampled_materials`` (EMPIRIC + from assets) says rehearsal samples the materials the scene leaves + undeclared; otherwise they keep the scene's values. """ if supplied_model: parts = [ @@ -409,10 +412,18 @@ def build_model_contract( if partially_observable: parts.append(render("play_model_contract", "predicates_latent")) return _join_contract(parts) + if scene_built: + simulator = render( + "subclass_model", + "simulator_scene", + undeclared_materials=render( + "subclass_model", "undeclared_materials_sampled" + if sampled_materials else "undeclared_materials_kept")) + else: + simulator = render("subclass_model", "simulator") parts = [ render("play_model_contract", "intro"), - render("subclass_model", - "simulator_scene" if scene_built else "simulator"), + simulator, render("subclass_model", "dynamics"), ] noise = ObservationNoise.from_cfg() diff --git a/predicators/agent_sdk/prompts/play_system.md b/predicators/agent_sdk/prompts/play_system.md index b9abbe2867..d74c8a9afa 100644 --- a/predicators/agent_sdk/prompts/play_system.md +++ b/predicators/agent_sdk/prompts/play_system.md @@ -531,7 +531,9 @@ You own scene construction, joint/readout mappings, mechanisms, parameters and i A scalar surviving a reset does not mean its dynamics or joint mapping are implemented. The manifest is supplied reconstructed geometry, not a calibrated dynamics model. Asset files can contain nominal physical constants; treat them as assumptions to validate, not identified values. -An engine material you do not declare runs at its default, which can be far from the world's; declare the ones your tasks' contacts depend on. +An engine material you do not declare is not fitted: rehearsal samples it over a plausible range, so a plan that works only near its default fails on some draws. +Declare the materials your tasks' contacts depend on, so the recordings can fit them. +A declared parameter's prior spans its declared range, and one the recordings do not constrain keeps that whole range in the belief: declare ranges you can defend. Declare uncertain constants with `AGENT_PARAM_SPECS`; the harness retains fitting, belief handling and uncertainty-aware rehearsal. Use `sim.fit()` and `sim.validate()` on recorded experience, and compare held-out action outcomes where available. diff --git a/predicators/agent_sdk/prompts/subclass_model.md b/predicators/agent_sdk/prompts/subclass_model.md index 1b8d318d04..b1a265610d 100644 --- a/predicators/agent_sdk/prompts/subclass_model.md +++ b/predicators/agent_sdk/prompts/subclass_model.md @@ -53,7 +53,7 @@ Implement the mechanisms in `_domain_specific_step(self)`; ordinary Python funct Declare learnable constants in the class's `AGENT_PARAM_SPECS` and read their current values with `self.agent_param(name)`. The base offers the engine materials of your bodies as parameters: for every observed type whose objects have bodies, `_mass` (total mass, with inertia scaled to it), `_lateral_friction`, `_spinning_friction` (resistance to twisting about a contact), `_rolling_friction`, `_restitution`, `_linear_damping` and `_angular_damping`; and `support_lateral_friction`, `support_spinning_friction`, `support_rolling_friction` and `support_restitution` for the static bodies you load without an observed name. Declare any of these names in `AGENT_PARAM_SPECS` to make it a parameter of your model: the base sets it on every body of its group after each reset and whenever its value changes, so do not also set it in your own code. -Undeclared, a material keeps the value your scene gives it, or PyBullet's default: lateral friction 0.5, spinning and rolling friction 0, restitution 0 and damping 0.04. +__UNDECLARED_MATERIALS__ Declare `RESIDUAL_FEATURES` on the class or module as `{type_name: [feature_name, ...]}` to select the observed quantities the fit scores your scene on: the poses that forces and contacts move and the readings your mechanisms change. The deployment gate requires it; an empty one scores every feature of the scene's objects that the recorded data shows changing. Export only `RESIDUAL_ENV` as the dynamics implementation. @@ -84,6 +84,13 @@ RESIDUAL_ENV = MyScene A URDF may place a body's origin away from the observed pose reference; check that `sim.reset(current=True)` reconstructs the initial observation before relying on the scene. When a level's scene changes, edit `initialize_pybullet` to load the new bodies; the harness rebuilds your world when the file changes. + +Undeclared, a material keeps the value your scene gives it, or PyBullet's default: lateral friction 0.5, spinning and rolling friction 0, restitution 0 and damping 0.04. + + +Undeclared, a material is not fitted: each joint draw of `sim.run` samples it over a plausible range around the value your scene gives it, or PyBullet's default (lateral friction 0.5, spinning and rolling friction 0, restitution 0 and damping 0.04), and `physics_sweep` tests the ends of that range. +Declare it to fit it to the recordings; declare it with a narrow range to fix it. + ## Step and restoration behavior diff --git a/predicators/approaches/agent_continual_from_assets_approach.py b/predicators/approaches/agent_continual_from_assets_approach.py index 0909a5e3c4..f9dfd7a697 100644 --- a/predicators/approaches/agent_continual_from_assets_approach.py +++ b/predicators/approaches/agent_continual_from_assets_approach.py @@ -6,13 +6,20 @@ """ from __future__ import annotations +import hashlib import logging import os from typing import Any, Dict, List, Optional, Set, Tuple +import numpy as np + from predicators.agent_sdk.sandbox_setup import ReferenceFiles from predicators.approaches.agent_continual_approach import \ AgentContinualApproach +from predicators.code_sim_learning.fit_space import ParamSpec +from predicators.code_sim_learning.orchestrator import prior_parameter_belief +from predicators.code_sim_learning.parameter_belief import BeliefConfig, \ + ParameterBelief, join_beliefs, stable_seed from predicators.code_sim_learning.scene_base import SceneBase, \ scene_base_class from predicators.code_sim_learning.utils import read_residual_env @@ -24,8 +31,21 @@ class AgentContinualFromAssetsApproach(AgentContinualApproach): """Build the scene twin from the engine, the manifest and the assets.""" _save_suffix = "AgentContinualFromAssets" + # The model's physics starts from the agent's guesses: the fit's prior + # spans each declared range (code_sim_learning_prior_spans_bounds), and + # rehearsal samples the engine materials the model does not declare. + # The real-to-sim comparison fits nothing and samples nothing. + _belief_over_guesses = True def __init__(self, *args: Any, **kwargs: Any) -> None: + if self._belief_over_guesses and \ + not CFG.code_sim_learning_prior_spans_bounds: + raise ValueError( + "EMPIRIC from assets fits parameters that start from the " + "agent's guesses; run it with " + "code_sim_learning_prior_spans_bounds (as the " + "from_assets_opus entry of scripts/configs/empiric/" + "approaches.yaml does)") self._scene_base: Optional[type] = None super().__init__(*args, **kwargs) # The domain twin never renders or predicts for this arm: the @@ -103,7 +123,10 @@ def _play_prompt_options(self) -> Dict[str, Any]: return {"scene_built": True} def _play_model_contract(self, **options: Any) -> str: - return super()._play_model_contract(scene_built=True, **options) + return super()._play_model_contract( + scene_built=True, + sampled_materials=self._belief_over_guesses, + **options) def _physical_params_prompt_section(self) -> str: # No supplied base, no supplied parameter menu. @@ -112,6 +135,51 @@ def _physical_params_prompt_section(self) -> str: def _no_model_section(self) -> str: return "no_model_assets" + # -- Materials the model does not declare ------------------------------- + + def _sampled_material_specs(self) -> List[ParamSpec]: + """The engine materials of the agent's scene it does not declare, as + rehearsal samples them; none before the scene loads.""" + if not self._belief_over_guesses or \ + getattr(self, "_residual_env_cls", None) is None: + return [] + sampler = getattr(self._base_env, "sampled_material_specs", None) + return list(sampler()) if callable(sampler) else [] + + def _physical_param_names(self) -> Set[str]: + return super()._physical_param_names() | { + spec.name + for spec in self._sampled_material_specs() + } + + def parameter_belief(self) -> Optional[ParameterBelief]: + """The model's belief joined with a factor for each engine material it + does not declare: never fitted, its prior over a plausible range. + + A plan whose success hangs on such a material then fails on some + joint draws, and a physics sweep tests the ends of its range. + """ + belief = super().parameter_belief() + sampled = self._sampled_material_specs() + if belief is None or not sampled: + return belief + ranges = tuple( + (spec.name, spec.init_value, spec.lo, spec.hi) for spec in sampled) + fitted = hashlib.sha256( + repr(belief.names).encode() + + np.ascontiguousarray(belief.draws).tobytes()).hexdigest() + key = ("sampled materials", fitted, ranges) + cache = self._belief_cache() + if key not in cache: + if len(cache) > 8: + cache.clear() + prior = prior_parameter_belief(sampled, {}, + BeliefConfig.from_cfg(), + seed=stable_seed( + CFG.seed, "materials", ranges)) + cache[key] = join_beliefs(belief, prior) + return cache[key] + # -- No domain twin behind the probe ------------------------------------- def _base_physics_probe_model(self) -> Any: diff --git a/predicators/approaches/agent_continual_real_to_sim_approach.py b/predicators/approaches/agent_continual_real_to_sim_approach.py index c6e53afa29..e651a631da 100644 --- a/predicators/approaches/agent_continual_real_to_sim_approach.py +++ b/predicators/approaches/agent_continual_real_to_sim_approach.py @@ -40,6 +40,7 @@ class AgentContinualRealToSimApproach(AgentContinualFromAssetsApproach, """Agent-built scenes without harness fitting or uncertainty.""" _save_suffix = "AgentContinualRealToSim" + _belief_over_guesses = False def __init__(self, *args: Any, **kwargs: Any) -> None: wrong = [name for name in _UNCERTAINTY_FLAGS if getattr(CFG, name)] diff --git a/predicators/approaches/agent_sim_learning_approach.py b/predicators/approaches/agent_sim_learning_approach.py index 86ac3a4eba..a91a2c490b 100644 --- a/predicators/approaches/agent_sim_learning_approach.py +++ b/predicators/approaches/agent_sim_learning_approach.py @@ -235,7 +235,7 @@ def __init__(self, # ones the env applies and the rule parameters read through the # draw scope. self._tool_context.physical_param_names_provider = \ - lambda: {s.name for s in self._physical_param_specs} + self._physical_param_names self._tool_context.joint_draw_scope = self.joint_draw_scope # Env predicates surfaced to the agent (see # KEPT_INITIAL_PREDICATE_NAMES). Computed once here; everything @@ -953,7 +953,7 @@ def _stress_points(self) -> List[Dict[str, float]]: belief = self.parameter_belief() if belief is None: return [] - physical = {s.name for s in self._physical_param_specs} + physical = self._physical_param_names() base = { n: float(belief.map_estimate[n]) for n in belief.names if n in physical @@ -965,6 +965,11 @@ def _stress_points(self) -> List[Dict[str, float]]: points.append({**base, name: float(value)}) return points + def _physical_param_names(self) -> Set[str]: + """The parameters a rollout world applies; a joint draw's other + parameters are rule parameters, read through the draw scope.""" + return {s.name for s in self._physical_param_specs} + def _current_simulator_digest(self) -> Optional[str]: """The hash of the current ``simulator.py``, or None without one.""" try: diff --git a/predicators/code_sim_learning/fit_space.py b/predicators/code_sim_learning/fit_space.py index 0f0ecceeb7..c765ba71fa 100644 --- a/predicators/code_sim_learning/fit_space.py +++ b/predicators/code_sim_learning/fit_space.py @@ -193,6 +193,10 @@ def fit_space_bounds( def prior_widths(param_specs: List[ParamSpec], scale: float) -> np.ndarray: """Positive Gaussian-prior width (sigma) per parameter, in FIT space. + Under ``code_sim_learning_prior_spans_bounds`` no width is narrower + than the parameter's declared range (in fit space), for arms whose + starting values are guesses rather than calibrated defaults. + Linear parameters scale by ``|init|`` so a signed (negative-init) parameter gets a positive width, falling back to half the (finite) bound range when ``init`` is ~0 so a zero-centred parameter still @@ -209,7 +213,17 @@ def prior_widths(param_specs: List[ParamSpec], scale: float) -> np.ndarray: fallback = np.where(finite, 0.5 * (hi - lo), 1.0) linear_sigma = np.where(sigma > 1e-9, sigma, fallback) log_mask = np.array([is_log(s) for s in param_specs], dtype=bool) - return np.where(log_mask, float(scale), linear_sigma) + widths = np.where(log_mask, float(scale), linear_sigma) + # pylint: disable-next=import-outside-toplevel + from predicators.settings import CFG + if CFG.code_sim_learning_prior_spans_bounds and param_specs: + # Starting values that are guesses: the prior spans the declared + # range, so data that do not constrain a parameter leave its + # posterior as wide as that range. + lo_fit, hi_fit = fit_space_bounds(param_specs) + span = hi_fit - lo_fit + widths = np.where(np.isfinite(span), np.maximum(widths, span), widths) + return widths def scalar_to_fit_space(spec: ParamSpec, value: float) -> float: diff --git a/predicators/code_sim_learning/parameter_belief.py b/predicators/code_sim_learning/parameter_belief.py index da0ccf764f..67db2745b1 100644 --- a/predicators/code_sim_learning/parameter_belief.py +++ b/predicators/code_sim_learning/parameter_belief.py @@ -362,6 +362,40 @@ def from_dict(cls, data: Dict[str, Any]) -> ParameterBelief: }) +def join_beliefs(first: ParameterBelief, + second: ParameterBelief) -> ParameterBelief: + """The product of two beliefs over disjoint parameters, draw ``i`` of each + paired into joint draw ``i``. + + ``second`` must carry at least ``first``'s number of draws. + """ + overlap = sorted(set(first.names) & set(second.names)) + if overlap: + raise ValueError(f"Joined beliefs share parameters {overlap}.") + num = first.num_draws + if second.num_draws < num: + raise ValueError(f"Joining {num} draws needs as many from each " + f"factor; got {second.num_draws}.") + return ParameterBelief(names=list(first.names) + list(second.names), + scales=list(first.scales) + list(second.scales), + map_estimate={ + **first.map_estimate, + **second.map_estimate + }, + lines={ + **first.lines, + **second.lines + }, + noise_scale=first.noise_scale, + draws=np.hstack([first.draws, second.draws[:num]]), + held=list(first.held) + list(second.held), + evaluations=first.evaluations + second.evaluations, + discrete={ + **first.discrete, + **second.discrete + }) + + def stable_seed(*parts: Any) -> int: """A 63-bit seed from ``parts``, stable across processes.""" text = "|".join(repr(p) for p in parts).encode() diff --git a/predicators/code_sim_learning/scene_base.py b/predicators/code_sim_learning/scene_base.py index 1e53dbe3e3..451822700c 100644 --- a/predicators/code_sim_learning/scene_base.py +++ b/predicators/code_sim_learning/scene_base.py @@ -31,6 +31,7 @@ import pybullet as p from predicators import utils +from predicators.code_sim_learning.fit_space import ParamSpec from predicators.envs.pybullet_env import PyBulletEnv from predicators.pybullet_helpers.geometry import Quaternion from predicators.pybullet_helpers.robots.single_arm import \ @@ -63,6 +64,19 @@ "linear_damping": (0.0, 1.0, "linear", "damping of linear velocity"), "angular_damping": (0.0, 1.0, "linear", "damping of angular velocity"), } +# The range rehearsal samples an undeclared material over, when the model +# does not fit it: what a body of unknown make plausibly has, narrower than +# the fit boxes above (property -> lo, hi, scale). A mass is relative to the +# scene's own value. +SAMPLED_MATERIALS: Dict[str, Tuple[float, float, str]] = { + "mass": (1.0 / 3.0, 3.0, "log"), + "lateral_friction": (0.1, 1.5, "log"), + "spinning_friction": (0.0, 1.0, "linear"), + "rolling_friction": (0.0, 0.02, "linear"), + "restitution": (0.0, 0.5, "linear"), + "linear_damping": (0.0, 0.2, "linear"), + "angular_damping": (0.0, 0.2, "linear"), +} # Static bodies with no observed name (tables, walls, fixtures) form the # support group; mass and damping mean nothing for a body that never moves. SUPPORT_GROUP = "support" @@ -100,7 +114,9 @@ class SceneBase(PyBulletEnv): with bodies, or ``support`` for the static bodies without an observed name. A name the subclass declares in ``AGENT_PARAM_SPECS`` is set on every body of its group after each reset and whenever its value - changes; an undeclared one only describes the scene. + changes. An undeclared one keeps the scene's own value unless an + override sets it: rehearsal samples it as one of + :meth:`sampled_material_specs`. """ # Bound by scene_base_class. _scene_env_name: ClassVar[str] = "agent_scene" @@ -123,6 +139,8 @@ def __init__(self, List[int]], Dict[str, Tuple[str, str]]]] = None + # Values set on undeclared materials (a rehearsal draw's). + self._sampled_materials: Dict[str, float] = {} super().__init__(use_gui=use_gui, skip_residual_dynamics=skip_residual_dynamics) self._robot = Object("robot", self._type_named("robot")) @@ -283,16 +301,21 @@ def get_physical_param_info(self) -> Dict[str, Dict]: return info def apply_physical_param_overrides(self, params: Dict[str, float]) -> None: - """Set declared parameters, materials included. + """Set declared parameters and materials. - An undeclared material is not a setter: a fit pins every - parameter it does not estimate to its default, which must not - override what the scene's own code set. Material names are known + A value for an undeclared material (a rehearsal draw's) is set + on its group's bodies like a declared one; a fit that pins it to + its default sets the scene's own value. Material names are known from the observed types, so a fresh world that has bound no body yet accepts the menu another world of the same scene reports. """ materials = self._material_names() declared = {spec.name for spec in type(self).AGENT_PARAM_SPECS} + self._sampled_materials.update({ + name: float(value) + for name, value in params.items() + if name in materials and name not in declared + }) super().apply_physical_param_overrides({ name: value for name, value in params.items() @@ -300,6 +323,29 @@ def apply_physical_param_overrides(self, params: Dict[str, float]) -> None: }) self._apply_materials() + def sampled_material_specs(self) -> List[ParamSpec]: + """The materials of the scene the model does not declare, as the + parameters rehearsal samples: each ranges over ``SAMPLED_MATERIALS`` + and starts at the scene's own value, moved into that range.""" + declared = {spec.name for spec in type(self).AGENT_PARAM_SPECS} + groups, menu = self._materials() + specs = [] + for name, (group, prop) in sorted(menu.items()): + if name in declared: + continue + lo, hi, scale = SAMPLED_MATERIALS[prop] + own = self._material_baseline.get( + (group, prop), self._read_material(groups[group][0], prop)) + if prop == "mass": + lo, hi = own * lo, own * hi + specs.append( + ParamSpec(name, + min(max(own, lo), hi), + lo=lo, + hi=hi, + scale=scale)) + return specs + @classmethod def _material_names(cls) -> Set[str]: """Every material name the menu can offer for this scene's types.""" @@ -355,17 +401,21 @@ def _note_material_baselines(self) -> None: groups[group][0], prop) def _apply_materials(self) -> None: - """Set every declared material parameter on its group's bodies.""" - declared = [spec.name for spec in type(self).AGENT_PARAM_SPECS] - if not declared: + """Set every declared material parameter, and every value set on an + undeclared material, on its group's bodies.""" + values = dict(self._sampled_materials) + values.update({ + spec.name: self._agent_param_values[spec.name] + for spec in type(self).AGENT_PARAM_SPECS + }) + if not values: return groups, menu = self._materials() - for name in declared: + for name, value in values.items(): if name in menu: group, prop = menu[name] for body in groups[group]: - self._write_material(body, prop, - self._agent_param_values[name]) + self._write_material(body, prop, value) def _body_links(self, body: int) -> range: return range( diff --git a/predicators/settings.py b/predicators/settings.py index d8dca8fe41..e7abae389e 100644 --- a/predicators/settings.py +++ b/predicators/settings.py @@ -2401,6 +2401,16 @@ class GlobalSettings: # data-equivalence tolerance as the grid flat set, so a genuinely # identified param (whose revert destroys the SSE) is never touched. code_sim_learning_rollout_anchor_ablation = True + # The fit's Gaussian prior on each parameter is at least as wide as + # the parameter's declared range (fit space), instead of 0.75x its + # starting value. That prior width suits starting values that are + # calibrated defaults (a supplied domain base); for an arm whose + # starting values are the agent's guesses (EMPIRIC from assets), a + # parameter the recordings do not constrain must keep a posterior as + # wide as its range, or the belief claims a knowledge it lacks + # (2026-10-04: a spinning friction started at 0.05 came out + # 0.057-0.12 with the world at 0.5). + code_sim_learning_prior_spans_bounds = False # Goodness-of-fit trimming threshold for rollout sysID, as a # multiple of the fit's noise_sigma (0 disables): a segment whose # best-achievable RMS over the candidate param grid exceeds diff --git a/scripts/configs/empiric/approaches.yaml b/scripts/configs/empiric/approaches.yaml index 38b506c6d9..0fbaacc474 100644 --- a/scripts/configs/empiric/approaches.yaml +++ b/scripts/configs/empiric/approaches.yaml @@ -22,6 +22,9 @@ APPROACHES: FLAGS: agent_sdk_model_name: claude-opus-5 continual_require_model_on_test: true + # Its starting values are the agent's guesses: a parameter the data + # do not constrain keeps its whole declared range in the belief. + code_sim_learning_prior_spans_bounds: true # Model-free: the same agent with the model-side machinery switched # off, so the two differ only in the model. mf_opus: diff --git a/tests/approaches/test_agent_continual_real_to_sim_approach.py b/tests/approaches/test_agent_continual_real_to_sim_approach.py index 6508ebeb69..e18c4922b9 100644 --- a/tests/approaches/test_agent_continual_real_to_sim_approach.py +++ b/tests/approaches/test_agent_continual_real_to_sim_approach.py @@ -43,6 +43,7 @@ FROM_ASSETS_FLAGS = { "agent_sim_learn_declared_params_only": False, "continual_uncertainty_decisions": True, + "code_sim_learning_prior_spans_bounds": True, } # The agent's simulator for the Boil training scene, written the way the # prompt describes: a SceneBase subclass whose initialize_pybullet loads @@ -326,9 +327,14 @@ def query(message: str, *_args: Any, **_kwargs: Any) -> Any: if from_assets: # EMPIRIC's joint belief rehearses on its joint draws, each on a # fresh world at its own planner seed; this replaces trials. + # Each draw also samples the materials the scene leaves + # undeclared. code = ("r = sim.reset(current=True).run('Wait(robot:robot)[2]')\n" "seeds = {d['planner_seed'] for d in r.draws}\n" "assert len(r.draws) > 1 and len(seeds) == len(r.draws)\n" + "spins = {d['params']['jug_spinning_friction'] " + "for d in r.draws}\n" + "assert len(spins) > 1, spins\n" "print('independent')") else: code = ("r = sim.reset(current=True).run(" @@ -351,6 +357,23 @@ def query(message: str, *_args: Any, **_kwargs: Any) -> Any: worlds[0].apply_physical_param_overrides({"heat_rate": 0.07}) assert worlds[0].agent_param("heat_rate") == pytest.approx(0.07) assert worlds[1].agent_param("heat_rate") == pytest.approx(0.01) + + # A draw's value for an undeclared material reaches its world only. + def spin(world: Any) -> float: + return p.getDynamicsInfo( + world.body("jug0"), + -1, + physicsClientId=world._physics_client_id)[7] + + own = spin(worlds[1]) + worlds[0].apply_physical_param_overrides( + {"jug_spinning_friction": own + 0.5}) + assert spin(worlds[0]) == pytest.approx(own + 0.5) + assert spin(worlds[1]) == pytest.approx(own) + sampled = {s.name for s in approach._sampled_material_specs()} + assert ("jug_spinning_friction" in sampled) == from_assets + assert (sampled <= approach._physical_param_names()) and \ + (bool(sampled) == from_assets) if from_assets: assert "step applied" in _call(approach, "env_step", @@ -383,8 +406,9 @@ def test_scene_base_offers_engine_materials(tmp_path: Any) -> None: and of the static support. A declared one is set on every body of its group at each reset and - on change; an undeclared one, which a fit pins to its default, never - overrides the scene's own value. + on change. An undeclared one keeps the scene's own value until an + override sets it, as a rehearsal draw does, and is offered as a + sampled parameter over a plausible range. """ _config(tmp_path, **_arm_flags()) env = create_new_env("pybullet_boil", do_cache=False, use_gui=False) @@ -438,18 +462,51 @@ def dynamics(body: int) -> Any: assert dynamics(jug)[7] == pytest.approx(0.3) world.apply_physical_param_overrides({"jug_spinning_friction": 0.6}) assert dynamics(jug)[7] == pytest.approx(0.6) - # A fit pins undeclared entries to their defaults; the scene's own - # value stays. - p.changeDynamics(jug, -1, lateralFriction=0.9, physicsClientId=client) + # The undeclared materials are sampled over plausible ranges around + # the scene's own values; the declared one is not. + sampled = {spec.name: spec for spec in world.sampled_material_specs()} + assert "jug_spinning_friction" not in sampled + assert {"jug_mass", "jug_rolling_friction", "support_lateral_friction" + } <= set(sampled) + assert "support_mass" not in sampled + assert (sampled["jug_mass"].lo, sampled["jug_mass"].hi) == \ + pytest.approx((0.2 / 3, 0.6)) + assert sampled["jug_rolling_friction"].init_value == pytest.approx(0.0) + assert sampled["jug_lateral_friction"].init_value == pytest.approx( + info["jug_lateral_friction"]["default"]) + # A value set on an undeclared material, as a draw sets one, holds + # after each reset; a fit pinning it to its default sets the scene's + # own value. + world.apply_physical_param_overrides({"jug_rolling_friction": 0.01}) + assert dynamics(jug)[6] == pytest.approx(0.01) + world._set_state(init) + assert dynamics(jug)[6] == pytest.approx(0.01) world.apply_physical_param_overrides( - {"jug_lateral_friction": info["jug_lateral_friction"]["default"]}) - assert dynamics(jug)[1] == pytest.approx(0.9) + {"jug_rolling_friction": info["jug_rolling_friction"]["default"]}) + assert dynamics(jug)[6] == pytest.approx(0.0) # A reset re-applies the declared value. p.changeDynamics(jug, -1, spinningFriction=0.0, physicsClientId=client) world._set_state(init) assert dynamics(jug)[7] == pytest.approx(0.6) +def test_from_assets_needs_the_range_prior(tmp_path: Any) -> None: + """EMPIRIC from assets refuses to start without the prior its guessed + starting values need.""" + flags = _benchmark_flags("pybullet_bridge") + flags.update(FROM_ASSETS_FLAGS, + approach="agent_continual_from_assets", + env="pybullet_boil", + code_sim_learning_prior_spans_bounds=False) + _config(tmp_path, **flags) + env = create_new_env("pybullet_boil", do_cache=False, use_gui=False) + with pytest.raises(ValueError, match="prior_spans_bounds"): + create_approach("agent_continual_from_assets", env.predicates, + get_gt_options(env.get_name()), env.types, + env.action_space, + [t.task for t in env.get_train_tasks()]) + + def test_manifest_lists_visual_only_bodies(tmp_path: Any) -> None: """The manifest lists the bodies a camera sees without touching. diff --git a/tests/code_sim_learning/test_log_scale_params.py b/tests/code_sim_learning/test_log_scale_params.py index 706b429014..f7a137c403 100644 --- a/tests/code_sim_learning/test_log_scale_params.py +++ b/tests/code_sim_learning/test_log_scale_params.py @@ -12,6 +12,7 @@ import pytest import predicators.approaches # noqa: F401 # pylint: disable=unused-import +from predicators import utils from predicators.code_sim_learning.active_experiment import laplace_ensemble, \ perturbation_ensemble from predicators.code_sim_learning.fit_space import FitResult, ParamSpec, \ @@ -87,6 +88,19 @@ def test_prior_widths_log_param_is_constant_in_log_space(): assert np.isclose(widths[1], 0.3) +def test_prior_widths_span_the_declared_range_for_guessed_starts(): + """Under code_sim_learning_prior_spans_bounds no prior is narrower than its + parameter's declared range, in fit space.""" + specs = _mixed_specs() + [ParamSpec("spin", 0.05, lo=0.0, hi=1.0)] + utils.reset_config({"code_sim_learning_prior_spans_bounds": True}) + widths = prior_widths(specs, 0.75) + assert np.isclose(widths[0], np.log(2.0) - np.log(0.01)) + assert np.isclose(widths[1], 0.6) + assert np.isclose(widths[2], 1.0) + utils.reset_config({"code_sim_learning_prior_spans_bounds": False}) + assert np.isclose(prior_widths(specs, 0.75)[2], 0.0375) + + # ── Grid candidates ─────────────────────────────────────────────── diff --git a/tests/code_sim_learning/test_parameter_belief.py b/tests/code_sim_learning/test_parameter_belief.py index ec50675182..80a0791c67 100644 --- a/tests/code_sim_learning/test_parameter_belief.py +++ b/tests/code_sim_learning/test_parameter_belief.py @@ -5,7 +5,8 @@ from predicators.code_sim_learning.fit_space import ParamSpec from predicators.code_sim_learning.parameter_belief import BeliefConfig, \ - LinePosterior, ParameterBelief, build_parameter_belief, stable_seed + LinePosterior, ParameterBelief, build_parameter_belief, join_beliefs, \ + prior_belief, stable_seed _SIGMA_N = 0.05 @@ -223,3 +224,31 @@ def test_prior_belief_is_the_bounded_prior(): freq = np.array([np.mean(belief.draws[:, 1] == v) for v in values]) assert np.allclose(freq, expected, atol=0.03) assert belief.noise_scale == 1.0 and belief.evaluations == 0 + + +def test_joined_beliefs_pair_their_draws(): + """Joining a fitted belief with a prior-only one keeps each factor's draws + and lines, pairs draw i with draw i, and refuses overlapping or short + factors.""" + config = BeliefConfig(num_draws=8) + fitted = _build([ParamSpec("a", 0.0, lo=-1.0, hi=1.0)], {"a": 0.0}, + _gaussian_residuals(["a"], [0.0], [[1.0]]), + config=config) + spin = ParamSpec("spin", 0.0, lo=0.0, hi=1.0) + prior = prior_belief([spin], {"spin": 0.0}, {"spin": 1.0}, + config=config, + seed=1) + joined = join_beliefs(fitted, prior) + assert joined.names == ["a", "spin"] + assert joined.num_draws == 8 + assert np.allclose(joined.draws[:, 0], fitted.draws[:, 0]) + assert np.allclose(joined.draws[:, 1], prior.draws[:, 0]) + assert joined.interval("spin", 0.95) == prior.interval("spin", 0.95) + assert all(set(d) == {"a", "spin"} for d in joined.draw_dicts()) + with pytest.raises(ValueError, match="share"): + join_beliefs(joined, prior) + short = prior_belief([spin], {"spin": 0.0}, {"spin": 1.0}, + config=BeliefConfig(num_draws=4), + seed=1) + with pytest.raises(ValueError, match="as many"): + join_beliefs(fitted, short) diff --git a/tests/test_cluster_utils_configs.py b/tests/test_cluster_utils_configs.py index aba4631fd3..2146ec4285 100644 --- a/tests/test_cluster_utils_configs.py +++ b/tests/test_cluster_utils_configs.py @@ -95,7 +95,8 @@ def test_benchmark_is_seven_arms_on_five_settings() -> None: def test_from_assets_arm_runs_only_by_name() -> None: """EMPIRIC from assets is parked: the default launch leaves it out, and - naming it runs EMPIRIC's flags under its own approach class.""" + naming it runs EMPIRIC's flags, plus the range-spanning prior its guessed + starting values need, under its own approach class.""" assert not any("from_assets" in r.experiment_id for r in _benchmark_runs(round_name="r9")) runs = _benchmark_runs(round_name="r9", @@ -106,7 +107,10 @@ def test_from_assets_arm_runs_only_by_name() -> None: assert set(by_arm) == {"mb_opus_r9", "from_assets_opus_r9"} from_assets = by_arm["from_assets_opus_r9"] assert from_assets.approach == "agent_continual_from_assets" - assert from_assets.flags == by_arm["mb_opus_r9"].flags + assert from_assets.flags == { + **by_arm["mb_opus_r9"].flags, "code_sim_learning_prior_spans_bounds": + True + } def test_benchmark_subsets_and_rounds() -> None: From d753bd333c995f745e52d6ce7e01829cc64560e2 Mon Sep 17 00:00:00 2001 From: Yichao Liang Date: Thu, 8 Oct 2026 11:10:39 -0400 Subject: [PATCH 2/2] Give each body its own material back when a fit pins the default The fit pins every parameter it does not estimate to its default. For an undeclared material that default is read from its group's first body, so applying it set every body of the group to that value: the fit worlds of seed 1's Domino scene ran its tables at the ground plane's friction, and the fit started from three times the error on the same data. A default now restores each body's own value; a rehearsal draw's value still sets the whole group. --- predicators/code_sim_learning/scene_base.py | 47 ++++++++++++++----- ...st_agent_continual_real_to_sim_approach.py | 27 +++++++++-- .../test_parameter_belief.py | 2 - 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/predicators/code_sim_learning/scene_base.py b/predicators/code_sim_learning/scene_base.py index 451822700c..7191581e87 100644 --- a/predicators/code_sim_learning/scene_base.py +++ b/predicators/code_sim_learning/scene_base.py @@ -139,6 +139,9 @@ def __init__(self, List[int]], Dict[str, Tuple[str, str]]]] = None + # (body, property) -> the value the scene itself gave the body, + # restored when an undeclared material is set back to its default. + self._body_baseline: Dict[Tuple[int, str], float] = {} # Values set on undeclared materials (a rehearsal draw's). self._sampled_materials: Dict[str, float] = {} super().__init__(use_gui=use_gui, @@ -304,18 +307,29 @@ def apply_physical_param_overrides(self, params: Dict[str, float]) -> None: """Set declared parameters and materials. A value for an undeclared material (a rehearsal draw's) is set - on its group's bodies like a declared one; a fit that pins it to - its default sets the scene's own value. Material names are known - from the observed types, so a fresh world that has bound no body - yet accepts the menu another world of the same scene reports. + on its group's bodies like a declared one. Its default, which a + fit pins every parameter it does not estimate to, stands for the + scene's own values: each body gets its own back, so a group of + bodies the scene made different (the ground and a table) stays + so. Material names are known from the observed types, so a fresh + world that has bound no body yet accepts the menu another world + of the same scene reports. """ materials = self._material_names() declared = {spec.name for spec in type(self).AGENT_PARAM_SPECS} - self._sampled_materials.update({ - name: float(value) - for name, value in params.items() - if name in materials and name not in declared - }) + groups, menu = self._materials() + for name, value in params.items(): + if name not in materials or name in declared: + continue + if name in menu: + group, prop = menu[name] + default = self._material_baseline.get( + (group, prop), self._read_material(groups[group][0], prop)) + if float(value) == float(default): + if self._sampled_materials.pop(name, None) is not None: + self._restore_own_materials(groups[group], prop) + continue + self._sampled_materials[name] = float(value) super().apply_physical_param_overrides({ name: value for name, value in params.items() @@ -323,6 +337,13 @@ def apply_physical_param_overrides(self, params: Dict[str, float]) -> None: }) self._apply_materials() + def _restore_own_materials(self, bodies: List[int], prop: str) -> None: + """Give each body the value the scene itself gave it.""" + for body in bodies: + own = self._body_baseline.get((body, prop)) + if own is not None: + self._write_material(body, prop, own) + def sampled_material_specs(self) -> List[ParamSpec]: """The materials of the scene the model does not declare, as the parameters rehearsal samples: each ranges over ``SAMPLED_MATERIALS`` @@ -392,13 +413,17 @@ def _materials( return groups, menu def _note_material_baselines(self) -> None: - """Record what the scene gives each newly seen group, before any - declared value is set on it.""" + """Record what the scene gives each newly seen group and body, before + any declared or sampled value is set on it.""" groups, menu = self._materials() for group, prop in menu.values(): if (group, prop) not in self._material_baseline: self._material_baseline[(group, prop)] = self._read_material( groups[group][0], prop) + for body in groups[group]: + if (body, prop) not in self._body_baseline: + self._body_baseline[(body, prop)] = self._read_material( + body, prop) def _apply_materials(self) -> None: """Set every declared material parameter, and every value set on an diff --git a/tests/approaches/test_agent_continual_real_to_sim_approach.py b/tests/approaches/test_agent_continual_real_to_sim_approach.py index e18c4922b9..fb479ba75a 100644 --- a/tests/approaches/test_agent_continual_real_to_sim_approach.py +++ b/tests/approaches/test_agent_continual_real_to_sim_approach.py @@ -436,10 +436,15 @@ def initialize_pybullet(cls, using_gui: bool) -> Any: table = p.createCollisionShape(p.GEOM_BOX, halfExtents=[0.4, 0.4, 0.2], physicsClientId=client) - p.createMultiBody(0.0, - table, - basePosition=[0.75, 1.35, 0.2], - physicsClientId=client) + table_id = p.createMultiBody(0.0, + table, + basePosition=[0.75, 1.35, 0.2], + physicsClientId=client) + # The scene gives the table a friction of its own. + p.changeDynamics(table_id, + -1, + lateralFriction=0.7, + physicsClientId=client) return client, robot, bodies world = Scene(use_gui=False, skip_residual_dynamics=False) @@ -484,6 +489,20 @@ def dynamics(body: int) -> Any: world.apply_physical_param_overrides( {"jug_rolling_friction": info["jug_rolling_friction"]["default"]}) assert dynamics(jug)[6] == pytest.approx(0.0) + # A group the scene made different (the ground and the table) takes a + # set value on every body, and each body's own value back at the + # default. + support = world._materials()[0]["support"] + own = [dynamics(body)[1] for body in support] + assert len(set(own)) > 1, own + world.apply_physical_param_overrides({"support_lateral_friction": 1.4}) + assert [dynamics(body)[1] for body in support] == \ + pytest.approx([1.4] * len(support)) + world.apply_physical_param_overrides({ + "support_lateral_friction": + info["support_lateral_friction"]["default"] + }) + assert [dynamics(body)[1] for body in support] == pytest.approx(own) # A reset re-applies the declared value. p.changeDynamics(jug, -1, spinningFriction=0.0, physicsClientId=client) world._set_state(init) diff --git a/tests/code_sim_learning/test_parameter_belief.py b/tests/code_sim_learning/test_parameter_belief.py index 80a0791c67..8b90210499 100644 --- a/tests/code_sim_learning/test_parameter_belief.py +++ b/tests/code_sim_learning/test_parameter_belief.py @@ -198,8 +198,6 @@ def test_config_reads_the_flags(): def test_prior_belief_is_the_bounded_prior(): """Before any fit, the factor is the declared prior within the box.""" - # pylint: disable-next=import-outside-toplevel - from predicators.code_sim_learning.parameter_belief import prior_belief specs = [ ParamSpec("mass", 1.0, lo=0.2, hi=5.0, scale="log"), ParamSpec("slot", 1.0, lo=0.0, hi=3.0, discrete=True)