diff --git a/docs/amps/empiric-from-assets.md b/docs/amps/empiric-from-assets.md index 20b12b2a2..957a62e0f 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 4efff28a1..395ce596c 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 b9abbe286..d74c8a9af 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 1b8d318d0..b1a265610 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 0909a5e3c..f9dfd7a69 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 c6e53afa2..e651a631d 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 86ac3a4eb..a91a2c490 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 0f0ecceeb..c765ba71f 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 da0ccf764..67db2745b 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 1e53dbe3e..7191581e8 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,11 @@ 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, skip_residual_dynamics=skip_residual_dynamics) self._robot = Object("robot", self._type_named("robot")) @@ -283,16 +304,32 @@ 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. - - 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 - from the observed types, so a fresh world that has bound no body - yet accepts the menu another world of the same scene reports. + """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. 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} + 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() @@ -300,6 +337,36 @@ 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`` + 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.""" @@ -346,26 +413,34 @@ 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 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 d8dca8fe4..e7abae389 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 38b506c6d..0fbaacc47 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 6508ebeb6..fb479ba75 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) @@ -412,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) @@ -438,18 +467,65 @@ 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 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) 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 706b42901..f7a137c40 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 ec5067518..8b9021049 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 @@ -197,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) @@ -223,3 +222,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 aba4631fd..2146ec428 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: