Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/amps/empiric-from-assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
17 changes: 14 additions & 3 deletions predicators/agent_sdk/play_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``).
Expand All @@ -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 = [
Expand All @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion predicators/agent_sdk/prompts/play_system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion predicators/agent_sdk/prompts/subclass_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<type>_mass` (total mass, with inertia scaled to it), `<type>_lateral_friction`, `<type>_spinning_friction` (resistance to twisting about a contact), `<type>_rolling_friction`, `<type>_restitution`, `<type>_linear_damping` and `<type>_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.
Expand Down Expand Up @@ -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.

<!-- section: undeclared_materials_kept -->
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.

<!-- section: undeclared_materials_sampled -->
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.

<!-- section: dynamics -->
## Step and restoration behavior

Expand Down
70 changes: 69 additions & 1 deletion predicators/approaches/agent_continual_from_assets_approach.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
9 changes: 7 additions & 2 deletions predicators/approaches/agent_sim_learning_approach.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
16 changes: 15 additions & 1 deletion predicators/code_sim_learning/fit_space.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions predicators/code_sim_learning/parameter_belief.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading