diff --git a/predicators/agent_sdk/sketch_prompts.py b/predicators/agent_sdk/sketch_prompts.py index 697d97cce..b4eaa49aa 100644 --- a/predicators/agent_sdk/sketch_prompts.py +++ b/predicators/agent_sdk/sketch_prompts.py @@ -232,6 +232,21 @@ def build_solve_prompt( "the mechanism worked - a short plan that exercises the " "unknown beats a long one that spends the episode's steps " "on what the model already predicts.\n\n" + "What a cycle's data must contain. Across a cycle's " + "episodes the real environment must see (a) at least one " + "attempt at the FULL goal - every goal atom, executed to " + "the end, with the parameters you believe most likely to " + "work in reality even where the belief model predicts " + "failure - and (b) the top-ranked open question's " + "experiment executed as it is specified (its option " + "sequence and parameters), not a variation of your own. " + "One episode usually carries both, because when the open " + "question is a mechanism the goal requires, the goal " + "attempt IS its experiment; when the budget forces a " + "choice, the cycle's first episode attempts the goal and a " + "later one runs the ledger's top experiment - the " + "scheduled-plans section below tells you what this cycle " + "already covers.\n\n" "Experiment design - one episode, many measurements. Before " "sketching, list the mechanisms the goal depends on and " "mark each KNOWN (the belief model has predicted it " @@ -266,7 +281,15 @@ def build_solve_prompt( "domain's language suggests), so the first learning phase " "sees each mechanism at least once, instead of spending the " "episode polishing a single goal attempt whose failure " - "reveals only its first missing mechanism.\n\n" + "reveals only its first missing mechanism. Carry each " + "interaction to its CONSEQUENCE, not just its setup: bring " + "the prepared surfaces into actual contact, release, wait " + "long enough for a delayed effect, then probe the result " + "(lift, push, or move one body and watch whether the other " + "follows). An interaction the episode stages but never " + "consummates - glue applied to a face that touches nothing, " + "parts placed near but not against each other - leaves the " + "learner with no event to model, and the cycle is spent.\n\n" "Ledger upkeep is part of the deliverable. Append " "measurements to ./journal.md as you go (a short entry per " "experiment - lead with the numbers), and when a result " diff --git a/predicators/agent_sdk/synthesis_backend.py b/predicators/agent_sdk/synthesis_backend.py index 7a0e77553..3064b2bb7 100644 --- a/predicators/agent_sdk/synthesis_backend.py +++ b/predicators/agent_sdk/synthesis_backend.py @@ -72,6 +72,7 @@ def _publish_probe_fit( fit_result: Optional[FitResult] = None, sse: float = float("nan"), applied_physical: Optional[Dict[str, float]] = None, + sigma_points: Optional[List[Dict[str, float]]] = None, ) -> None: """Deploy a canonical ``sim.fit`` result to the candidate probe.""" diff --git a/predicators/agent_sdk/tools/params_view.py b/predicators/agent_sdk/tools/params_view.py index fff1f23d4..b7351cb37 100644 --- a/predicators/agent_sdk/tools/params_view.py +++ b/predicators/agent_sdk/tools/params_view.py @@ -19,10 +19,14 @@ def __init__(self, params: Dict[str, float]) -> None: def __getitem__(self, key: str) -> float: if key not in self._params: + known = ", ".join(sorted(self._params)) or "" raise KeyError( - f"params[{key!r}] accessed before any parameter fit; " - "call sim.fit() to " - "populate self._fitted_params first.") + f"params[{key!r}] is not among the current simulator " + f"parameters (available: {known}). Declare " + f"ParamSpec({key!r}, ...) in simulator.py, or update " + "the classifier to use a declared parameter; after a " + "structural edit the values refresh at the next sim " + "call (run sim.fit() to fit them).") return self._params[key] def __contains__(self, key: object) -> bool: diff --git a/predicators/agent_sdk/tools/synthesis.py b/predicators/agent_sdk/tools/synthesis.py index cf1b34984..f0ff47a79 100644 --- a/predicators/agent_sdk/tools/synthesis.py +++ b/predicators/agent_sdk/tools/synthesis.py @@ -188,7 +188,7 @@ def create_synthesis_tools( fit_rule_parameters_latent from predicators.code_sim_learning.grid_seed import grid_candidates from predicators.code_sim_learning.identifiability import \ - format_identifiability + format_identifiability, physics_sigma_points from predicators.code_sim_learning.orchestrator import run_rollout_sysid from predicators.code_sim_learning.rollout_env import \ physical_param_anchors @@ -367,6 +367,30 @@ def _evaluate_rollout_fit(rules: list, # applied - do NOT print fitted values or identifiability # verdicts computed on zero surviving data (chaos makes # the probe report "identified" for everything). + if not exploratory: + # Record the refusal as this file's canonical fit + # (pinned at the declared inits): the deployed model + # reuses it instead of re-running the same refused fit + # under a misleading FIT FALLBACK warning, and probe + # results stop saying UNFITTED after the agent did + # fit. Mirroring the joint-rollout fallback's + # no-survivor case, nothing is applied to the planning + # env and no sigma points are recorded. + trim_rule_names = {s.name for s in rule_specs} + approach._publish_probe_fit( # pylint: disable=protected-access + { + n: v + for n, v in + outcome.fit_result.point_estimate.items() + if n in trim_rule_names + }, + version_tag, + simulator_file, + fit_result=outcome.fit_result, + sse=float("nan")) + if hasattr(approach, "_record_sysid_diagnostics"): + approach._record_sysid_diagnostics( # pylint: disable=protected-access + {}, physical_names, 0, len(rollouts), outcome.traj_rms) rms_str = ", ".join(f"{r:.4g}" for r in outcome.traj_rms) return "\n".join([ f"[{version_tag}] NO FIT RAN: all {len(rollouts)} " @@ -377,6 +401,10 @@ def _evaluate_rollout_fit(rules: list, "", "Parameters were left at their baselines; nothing was " "applied to the planning base env.", + ("Recorded as this file's canonical fit (pinned at the " + "declared inits): the deployed model will use these " + "values without a harness refit." if not exploratory else + "Exploratory call: nothing recorded."), "", "This usually means the recorded interactions are not " "repeatable under replay (prolonged scraping/jamming " @@ -404,7 +432,15 @@ def _evaluate_rollout_fit(rules: list, simulator_file, fit_result=outcome.fit_result, sse=post_sse, - applied_physical=dict(applied)) + applied_physical=dict(applied), + # Physics-margin points for the capture gate, restored + # when this fit is deployed as the cycle's model. + sigma_points=physics_sigma_points( + applied, + ident_report, + physical_specs, + num_points=CFG. + agent_plan_validation_physics_margin_points)) if hasattr(approach, "_record_sysid_diagnostics"): approach._record_sysid_diagnostics( # pylint: disable=protected-access ident_report, physical_names, outcome.num_survivors, diff --git a/predicators/approaches/agent_sim_learning_approach.py b/predicators/approaches/agent_sim_learning_approach.py index a593dcf4a..e09d9f426 100644 --- a/predicators/approaches/agent_sim_learning_approach.py +++ b/predicators/approaches/agent_sim_learning_approach.py @@ -1525,6 +1525,7 @@ def _publish_probe_fit( fit_result: Optional[FitResult] = None, sse: float = float("nan"), applied_physical: Optional[Dict[str, float]] = None, + sigma_points: Optional[List[Dict[str, float]]] = None, ) -> None: """Deploy a canonical ``sim.fit`` result to the candidate probe. @@ -1550,6 +1551,7 @@ def _publish_probe_fit( state["fit_result"] = fit_result state["sse"] = sse state["applied_physical"] = dict(applied_physical or {}) + state["sigma_points"] = list(sigma_points or []) self._probe_model_cache().clear() self._tool_context.probe_param_status = f"fitted ({version_tag})" logger.info("Synthesis probe: sim.fit deployed %d params (%s).", @@ -2322,14 +2324,28 @@ def _build_synthesis_learn_message( Evidence discipline for rules that WRITE physical state (poses, \ velocities): ground them in recorded transitions the base sim \ mispredicts. A mechanism you suspect but have never observed \ -end-to-end in the data is a HYPOTHESIS - record it in the decision \ -record with the experiment that would confirm it (so the next \ -exploration phase can run that experiment), instead of shipping a \ -speculative rule; a speculative pose-writer fabricates states the \ -environment never produces, and plans validated against it fail in \ -reality. The converse error is just as costly: do not delete a rule \ -whose mechanism you have confirmed merely because one fit metric is \ -noisy - decide from the recorded evidence either way. +end-to-end in the data is a HYPOTHESIS, and what to do with it \ +depends on whether the goal needs it. When the goal is reachable \ +without it, record it in the decision record with the experiment \ +that would confirm it and ship no rule: a speculative pose-writer \ +fabricates states the environment never produces, and plans \ +validated against it fail in reality. When the goal REQUIRES it - \ +without the mechanism the goal is unreachable in your model (an \ +assembly that must be carried as one body, a latch that must hold, \ +an activation that must take effect) - omitting it is NOT the \ +cautious choice: it turns "unknown" into "impossible" for every \ +consumer of the model, so the explorer can no longer certify a goal \ +attempt and the test session proves the goal unreachable and gives \ +up. Ship a goal-required mechanism as a LABELLED HYPOTHESIS: a rule \ +whose trigger geometry and timing are declared ParamSpecs at your \ +best physical estimate with honest ranges (the ensemble spreads over \ +them and the capture gate validates plans under that spread), a \ +HYPOTHESIS marker on it in the decision record, and its confirming \ +experiment as the FIRST entry of open_questions.md, written so that \ +the next exploration's goal attempt exercises it. The converse error \ +is just as costly: do not delete a rule whose mechanism you have \ +confirmed merely because one fit metric is noisy - decide from the \ +recorded evidence either way. Work through EVERY divergence this cycle's new trajectories reveal \ in this one session: enumerate each mechanism the episodes \ @@ -2361,7 +2377,10 @@ def _build_synthesis_learn_message( point to a learned threshold - compared against the measured \ execution scatter. NO-GO, or a margin thinner than the scatter, \ means the next test episode will likely fail: put exactly what is \ -missing at the top of `./open_questions.md`. +missing at the top of `./open_questions.md`. A GO that rests on a \ +hypothesised mechanism says so in the verdict; it is still a GO - \ +the plan it certifies is the experiment that confirms or refutes \ +the hypothesis in the real environment. Also maintain `./open_questions.md`: a short RANKED ledger of the \ model's remaining uncertainties - mechanisms never observed, \ @@ -2513,7 +2532,14 @@ def _fit_params_after_synthesis( len(expected), self._fit_sse) applied = self._probe_fit_state().get("applied_physical") if self._physical_param_specs and applied: + # Mirror _fit_parameters_joint_rollout's deploy: the + # cycle-level applied snapshot and the physics-margin + # sigma points come from the published fit (applying + # resets the points, so set them after). self._apply_identified_physical_params(dict(applied)) + self._cycle_applied_physical = dict(applied) + self._identified_physical_sigma_points = list( + self._probe_fit_state().get("sigma_points") or []) else: if CFG.agent_sim_learn_oracle_sim_program: logger.info("Oracle sim program: fitting its " diff --git a/predicators/explorers/agent_bilevel_explorer.py b/predicators/explorers/agent_bilevel_explorer.py index f9352b2a6..d56fb5afd 100644 --- a/predicators/explorers/agent_bilevel_explorer.py +++ b/predicators/explorers/agent_bilevel_explorer.py @@ -432,9 +432,11 @@ def _build_experiment_guidance(self) -> str: "The learning phase left this ranked ledger of OPEN " "QUESTIONS - uncertainties it could not settle from the " "data collected so far, each with the experiment that " - "would settle it. Settling ledger entries is this " - "episode's highest-value use; design the episode to " - "cover as many as its step budget allows:\n" + ledger) + "would settle it. The TOP entry is mandatory for this " + "cycle: run its experiment as specified (its option " + "sequence and parameters) unless a plan already " + "scheduled this cycle covers it, and fold in as many " + "lower entries as the step budget allows:\n" + ledger) if CFG.agent_explorer_info_seeking: parts.append( "Your explicit continuous parameters execute exactly as " diff --git a/predicators/ground_truth_models/skill_factories/base.py b/predicators/ground_truth_models/skill_factories/base.py index 76f8188e6..0a47d6e6c 100644 --- a/predicators/ground_truth_models/skill_factories/base.py +++ b/predicators/ground_truth_models/skill_factories/base.py @@ -460,6 +460,16 @@ def build(self) -> ParameterizedOption: def _initiable(self, state: State, memory: Dict, objects: Sequence[Object], params: Array) -> bool: del state, objects, params # unused + # A grounded option is re-executed by several callers (the + # explorer's certified-plan replay, execution-monitor suffix + # replans, validation rollouts). Everything this skill keeps in + # memory - the BiRRT waypoint cache, the learned aim offset, + # retry/dwell/stall counters, finger targets - describes ONE + # execution; carried into the next one it drives the phases from + # stale state (cached waypoints from a different start pose, + # exhausted retries, a correction offset for a landing that never + # happened). Start every execution clean. + memory.clear() memory["phase_idx"] = 0 return True diff --git a/tests/agent_sdk/test_probe_synthesis.py b/tests/agent_sdk/test_probe_synthesis.py index be19b8abe..0e385d674 100644 --- a/tests/agent_sdk/test_probe_synthesis.py +++ b/tests/agent_sdk/test_probe_synthesis.py @@ -16,7 +16,8 @@ from predicators import utils from predicators.agent_sdk.belief_probe import BeliefProbe, \ build_probe_namespace -from predicators.agent_sdk.tools import ToolContext, create_mcp_tools +from predicators.agent_sdk.tools import ToolContext, _ParamsView, \ + create_mcp_tools from predicators.approaches.agent_sim_learning_approach import \ AgentSimLearningApproach from predicators.code_sim_learning.fit_space import FitResult @@ -348,3 +349,20 @@ def test_probe_run_reports_subgoal_divergence() -> None: rendered = repr(ProbeResult([step], False, [], task.init, [])) assert "SUBGOAL NOT REACHED: {WidgetAtFixture(widget0, fixture0)}" \ in rendered + + +def test_params_view_missing_key_names_available_parameters() -> None: + """A dangling params[...] reference (spec deleted or never declared) names + the missing key and the available parameters instead of advising a + sim.fit() that cannot fix it (run_20260829 cycle 1: an agent deleted + ParamSpec("butt_gap_tol") while a predicate still read it and burned turns + on the old "call sim.fit()" advice).""" + view = _ParamsView({"k": 1.0, "gap": 0.2}) + assert view["k"] == 1.0 + with pytest.raises(KeyError) as excinfo: + view["butt_gap_tol"] # pylint: disable=pointless-statement + msg = str(excinfo.value) + assert "butt_gap_tol" in msg + assert "gap, k" in msg + assert "ParamSpec" in msg + assert "before any parameter fit" not in msg diff --git a/tests/agent_sdk/test_solve_prompt_strategy.py b/tests/agent_sdk/test_solve_prompt_strategy.py index 9e853303e..e0522b00e 100644 --- a/tests/agent_sdk/test_solve_prompt_strategy.py +++ b/tests/agent_sdk/test_solve_prompt_strategy.py @@ -157,6 +157,20 @@ def test_explore_mode_early_stop_note_credits_exploration_plans() -> None: assert "truncated just after that step" not in prompt +def test_explore_mode_states_the_cycle_data_contract() -> None: + """Every cycle must attempt the full goal at least once and run the + ledger's top experiment as specified, and first-cycle coverage must carry + each interaction to its consequence (run_20260829: four cycle-0 episodes + staged glue and neighbours without ever consummating a bond, so no learner + had the event to model).""" + utils.reset_config({"seed": 0}) + prompt = _render_explore(_make_task(None)) + assert "attempt at the FULL goal" in prompt + assert "top-ranked open question's experiment executed as it is " \ + "specified" in prompt + assert "Carry each interaction to its CONSEQUENCE" in prompt + + def test_domain_strategy_block_is_advisory() -> None: """strategy.md content renders as an advisory section; absent without.""" utils.reset_config({"seed": 0}) diff --git a/tests/approaches/test_agent_sim_prompt_formatting.py b/tests/approaches/test_agent_sim_prompt_formatting.py index bc02a015e..28f8cba88 100644 --- a/tests/approaches/test_agent_sim_prompt_formatting.py +++ b/tests/approaches/test_agent_sim_prompt_formatting.py @@ -387,3 +387,27 @@ def _task(evaluator=None): assert "## Task objective (env ground-truth reward)" in out assert "each blue costs 0.05" in out assert "evaluate_trajectory" in out + + +def test_learn_message_ships_goal_required_mechanisms_as_hypotheses(): + """The learn message distinguishes a hypothesis the goal can do without + (record, do not ship) from one the goal REQUIRES (ship as a labelled + hypothesis with declared ParamSpecs and a first-ranked confirming + experiment). + + The message is composed from live session state, so the guard reads + the template source: in run_20260829 both plan-arm learners followed + the older "never ship a never-observed mechanism" rule, the belief + then had no bond, and the test session proved the goal unreachable. + """ + import inspect + + from predicators.approaches.agent_sim_learning_approach import \ + AgentSimLearningApproach + src = inspect.getsource( + AgentSimLearningApproach._build_synthesis_learn_message) + assert "When the goal REQUIRES it" in src + assert "LABELLED HYPOTHESIS" in src + assert "FIRST entry of open_questions.md" in src + assert "A GO that rests on a" in src + assert "instead of shipping a speculative rule" not in src diff --git a/tests/approaches/test_published_fit_reuse.py b/tests/approaches/test_published_fit_reuse.py index bc27d4a00..340418e5b 100644 --- a/tests/approaches/test_published_fit_reuse.py +++ b/tests/approaches/test_published_fit_reuse.py @@ -6,14 +6,16 @@ otherwise. """ # pylint: disable=protected-access +from types import SimpleNamespace from typing import Any import numpy as np +from predicators import utils from predicators.agent_sdk.tools import ToolContext from predicators.approaches.agent_sim_learning_approach import \ AgentSimLearningApproach -from predicators.code_sim_learning.fit_space import FitResult +from predicators.code_sim_learning.fit_space import FitResult, ParamSpec def _fit(names: Any, values: Any) -> FitResult: @@ -69,6 +71,66 @@ def test_published_fit_is_reused_for_the_fitted_file(tmp_path: Any) -> None: is None +def test_reused_physics_fit_restores_the_margin_gate_state( + tmp_path: Any, monkeypatch: Any) -> None: + """Deploying a published physics fit re-applies its physical values and + restores the cycle-applied snapshot and the physics-margin sigma points + (applying resets them), so the capture gate's margin sweep survives the + skip of the harness refit.""" + utils.reset_config({ + "agent_sim_learn_oracle_sim_params": False, + "agent_explorer_info_seeking": False, + "code_sim_learning_num_mcmc_steps": 0, + }) + sim_file = tmp_path / "simulator.py" + sim_file.write_text("RESIDUAL_RULES = []\n", encoding="utf-8") + approach = _approach() + approach._physical_param_specs = [ + ParamSpec("mu", 0.5, lo=0.0, hi=1.0), + ] + approach._param_ensemble = [] + approach._param_specs = [] + # setattr: a literal None assignment would narrow the attribute type + # and make the identity assert below unreachable for mypy. + setattr(approach, "_last_fit_result", None) + approach._fit_sse = float("inf") + approach._cycle_applied_physical = {} + approach._identified_physical_sigma_points = [] + approach._rng = np.random.default_rng(0) + applied_calls = [] + + def _fake_apply(identified): + applied_calls.append(dict(identified)) + # The real method resets the sigma points on every application. + approach._identified_physical_sigma_points = [] + + monkeypatch.setattr(approach, "_apply_identified_physical_params", + _fake_apply) + + def _paths() -> Any: + return SimpleNamespace(simulator_file=str(sim_file)) + + monkeypatch.setattr(approach, "_resolve_synthesis_paths", _paths) + fit = _fit(["mu", "k"], [0.7, 1.5]) + sigma = [{"mu": 0.65}, {"mu": 0.75}] + approach._publish_probe_fit({"k": 1.5}, + "cycle_002_vers_003", + str(sim_file), + fit_result=fit, + sse=0.5, + applied_physical={"mu": 0.7}, + sigma_points=sigma) + specs = [ParamSpec("k", 1.0, lo=0.0, hi=5.0)] + # Non-empty so the fit branch is reached; never read on the reuse path. + triples: Any = [(None, None, None)] + approach._fit_params_after_synthesis([], specs, triples, {}) + assert approach._last_fit_result is fit + assert approach._fit_sse == 0.5 + assert applied_calls == [{"mu": 0.7}] + assert approach._cycle_applied_physical == {"mu": 0.7} + assert approach._identified_physical_sigma_points == sigma + + def test_publish_without_a_fit_result_never_deploys(tmp_path: Any) -> None: """Legacy publishes (values only) deploy to the probe but cannot stand in for the cycle's fit.""" diff --git a/tests/explorers/test_agent_bilevel_explorer.py b/tests/explorers/test_agent_bilevel_explorer.py index 60fe7f1ac..e637d081b 100644 --- a/tests/explorers/test_agent_bilevel_explorer.py +++ b/tests/explorers/test_agent_bilevel_explorer.py @@ -384,6 +384,7 @@ def test_experiment_guidance_injects_open_questions_ledger(tmp_path): guidance = explorer._build_experiment_guidance() # pylint: disable=protected-access assert ledger in guidance assert "OPEN QUESTIONS" in guidance + assert "The TOP entry is mandatory" in guidance # Info-seeking on: both the ledger and the boundary-probing note. _reset_config(agent_explorer_info_seeking=True) guidance = explorer._build_experiment_guidance() # pylint: disable=protected-access diff --git a/tests/test_skill_factories.py b/tests/test_skill_factories.py index eaf9e2ce9..7ee2825b3 100644 --- a/tests/test_skill_factories.py +++ b/tests/test_skill_factories.py @@ -373,6 +373,29 @@ def test_initiable_sets_phase_idx_zero(self, robot_scene): assert grounded.initiable(state) assert grounded.memory["phase_idx"] == 0 + def test_initiable_starts_every_execution_clean(self, robot_scene): + """Re-running a grounded option must not inherit the previous + execution's memory (BiRRT waypoint cache, aim offset, retry and stall + counters). + + The explorer's certified-plan replay re-executes the very + objects the first episode ran; with stale memory the skills + popped cached waypoints and exhausted retries, and 10 of 11 + replays of a plan that had just solved the task failed. + """ + _, robot = robot_scene + skill, _robot_obj, _ = self._make_single_ik_skill(robot, _EE_HOME) + opt = skill.build() + grounded = opt.ground([_make_robot_obj()], np.zeros(0)) + state = _build_state(_make_robot_obj(), robot, *_EE_HOME) + assert grounded.initiable(state) + grounded.memory["phase_idx"] = 3 + grounded.memory["aim_offset"] = (0.01, -0.02) + grounded.memory["birrt_traj_123"] = [np.zeros(7)] + grounded.memory["phase_retries_123"] = 2 + assert grounded.initiable(state) + assert grounded.memory == {"phase_idx": 0} + def test_change_fingers_terminal_when_at_target(self, robot_scene): """Test change fingers terminal when at target.""" _, robot = robot_scene