diff --git a/docs/uncertainty/articulated-replay.md b/docs/uncertainty/articulated-replay.md new file mode 100644 index 000000000..de05602f5 --- /dev/null +++ b/docs/uncertainty/articulated-replay.md @@ -0,0 +1,46 @@ +# Articulated state in offline replay + +September 12, 2026. +This fixes a missing state component in the [simplification replay contract](simplification-proposal.md#1-establish-the-state-and-replay-contract). +The production fitter and acting agent are unchanged. + +## Reproduction and correction + +The original `ReplayState` preserved every robot joint but omitted nonrobot joint states. +Replaying a candidate could therefore replace an uncertain slider position with its controller endpoint and reset a rotor's position and velocity to fresh-world defaults. +Repeated runs could agree perfectly while both discarded the same candidate uncertainty. + +The [pre-fix native reproduction](../../logs/uncertainty_articulated_replay_repro_20260912/pilot-22639384_1.json) exercises `capture_replay_state` followed by `replay_candidate` with the frozen learned Fan simulator and reconstructed public initial frame. +It supplies nonendpoint positions and nonzero velocities to the four sliders and twenty rotors. +All 24 differ after restoration, consistently across two fresh worlds. +For example, one slider changes from position 7.4 mm and velocity 20 mm/s to zero position and velocity. +This is a diagnostic candidate, not a complete physical initial-state prior or an agent result. + +[ReplayState](../../predicators/code_sim_learning/inference_replay.py) now carries an `ArticulatedBody` record for every nonrobot native body with joints, including bodies omitted from public object keys. +Each record includes native body ID, body names, joint names/types/link names, and every joint's position and velocity in native order. +Restoration verifies the complete record sequence against the fresh world's layout after domain initialization, then restores the supplied joint states after the controller reconciliation performed by `_set_state`. +Missing or duplicate bodies, differing layouts, incomplete joint arrays and nonfinite joint values raise an explicit error. +These values come from the simulated candidate or an evaluator-only mechanical audit, never from an added agent observation channel. + +The native IDs require the same body-allocation protocol as the source candidate. +Names and joint topology detect mismatches; they are not a semantic remapping algorithm or a proof that interchangeable identical assets were allocated in the same order. +The existing runtime-identity and same-layout requirements therefore remain necessary. +This change does not capture solver warm starts, arbitrary motor-controller changes or every other form of engine history. +Use full-prefix reconstruction for continued contact histories and continue to distinguish repeatable candidate replay from agreement with evaluator physics. + +## Validation + +The [fixed native audit](../../logs/uncertainty_articulated_replay_fixed_20260912/pilot-22639468_1.json) restores all 24 supplied joint states exactly. +It also repeats the first 64 recorded actions from that candidate in two fresh worlds. +All 65 boundaries agree exactly in robot joint states, nonrobot joint states and projected public observations. +This validates the tested candidate and runtime, not an arbitrary portable mid-contact checkpoint or a calibrated Fan posterior. + +Compute job `22639455` passes 22 functional replay tests plus focused mypy, pylint and pinned formatting checks. +The regression includes a supplementary physical fan without a public object, a moving clip slider, repeated continuations and five malformed-state controls. +Existing tests continue to cover robot motion, model memory, original weld frames, queued commands and full-prefix replay. + +The remaining Fan work is to compose supported scene geometry and articulated priors with the complete recording likelihood, then establish numerical and predictive adequacy against the incumbent fitter. + +The [whole-scene follow-up](fan-initial-scene.md) has since supplied a declared geometric root law and exposed later exact-event failures over the full recording. +Runtime addenda for [the reproduction](../../logs/uncertainty_articulated_replay_repro_20260912/runtime-audit.json) and [the fixed audit](../../logs/uncertainty_articulated_replay_fixed_20260912/runtime-audit.json) correct inherited node1412 labels: those jobs actually ran on node1408 and node1926 respectively. +The measured within-job repeatability claims remain unchanged; the raw artifacts are preserved. diff --git a/docs/uncertainty/assembly-prior.md b/docs/uncertainty/assembly-prior.md new file mode 100644 index 000000000..7f7a5b75d --- /dev/null +++ b/docs/uncertainty/assembly-prior.md @@ -0,0 +1,84 @@ +# Rigid-assembly initial-state component + +September 12, 2026. +This is an offline component of Stage A in the [uncertainty simplification plan](simplification-proposal.md), implemented in [inference_assembly.py](../../predicators/code_sim_learning/inference_assembly.py). +It provides normalized candidate distributions for a fixed rigid geometry, including a planar-contact case. +It is not the historical balloons task prior, a full scene prior, or a deployed agent estimator. + +## Why bodies cannot be initialized independently + +A weld constrains relative pose and motion. +Giving attached bodies independent positions can create an inconsistent constraint immediately. +Copying the parent's linear velocity to an offset child is also incorrect when the assembly rotates. +For root position `x`, orientation `R`, linear velocity `v`, angular velocity `omega`, and child offset `r` in the root frame, initialize: + +``` +child_position = x + R r +child_linear_velocity = v + omega cross (R r) +child_angular_velocity = omega +``` + +Child orientation composes the root rotation with the declared local rotation. +The original parent weld frame is that same local pose, and the child weld frame is identity. +These quantities describe one correlated candidate; they are not independent noisy readings or separately fitted body states. +During simulation, finite-force engine constraints can deflect, so the exact initial construction does not assert exact rigidity at every later step. + +## Explicit geometry and support assumptions + +Each `AssemblyBody` declares a name, a fixed pose relative to the root, and a radius enclosing its collision geometry. +The first body is the root and has identity local pose. +The caller must establish these geometric facts from its declared model and permitted inputs. +The API cannot verify arbitrary mesh geometry or infer a free region from noisy observations. +Privileged weld metadata and evaluator reset states do not supply these declarations. + +The caller also declares an axis-aligned obstacle-free cell. +Pairwise enclosing spheres must not overlap, which conservatively ensures that distinct bodies do not interpenetrate. +This rejects some physically valid tightly packed assemblies; rejection is an explicit limitation of this component's support. +The assembly radius about the root is the largest local-center distance plus that body's radius. +Eroding the cell by that radius supplies normalized uniform bounds for the root center, without candidate-dependent clipping or rejection normalization. +A cell too small to contain the declared assembly is rejected before sampling. +This guarantee covers the declared cell and collision envelopes, not undeclared obstacles, robot links, or articulated bodies. + +## Three distinct component priors + +| Component | Free coordinates | Normalized distribution | Derived quantities | +| --- | ---: | --- | --- | +| Free assembly at instantaneous rest | 6 | Uniform root xyz in the eroded cell; uniform orientation on SO(3) | All body poses; zero linear/angular motion; original weld frames | +| Free moving assembly | 12 | The same pose distribution plus independent uniform root linear and angular velocity components with declared positive half-widths | Correlated child velocities through the shared rigid twist | +| Assembly on a horizontal support face | 3 | Uniform root xy in the eroded footprint; uniform yaw | Root height, zero roll/pitch, zero twist, body poses and weld frames | + +Uniform SO(3) orientation uses a uniform-quaternion construction from three unit-interval coordinates. +It does not use independent uniform Euler angles. +`coordinates` returns a normalized `BoxPrior`, and `lift` maps those coordinates into physical body states. +The generative probability measure is defined on these coordinates and pushed forward through the map. +There is no extra product of densities over derived child poses or velocities. +Geometry and prior support are part of the component digest. + +For the supported component, `support_depth` declares an actual lowest horizontal root face at local `z = -depth`. +The root height is exactly the cell floor plus this depth, and only yaw is sampled. +The root envelope must fit below the cell ceiling, and attached-body enclosing spheres must remain above the support plane. +The caller must verify the root face against its collision geometry; a bounding radius alone does not establish that face. +This is geometric contact at instantaneous rest, not a proof of static balance or a guarantee that the assembly will remain upright. + +These components do not assign probabilities to one another. +A complete scene prior must declare their case masses and any dependence on parameters, layout, attachments, and robot state. +In particular, the supported component is not obtained by projecting a sample from the free component onto the table. +It is a separately declared distribution on a lower-dimensional contact case. +The rest components likewise declare atoms in velocity; they do not infer zero motion from missing observations. + +## Validation and scope + +The tests compare generated states against actual PyBullet collision geometry, compose weld frames independently through Bullet, and compare the velocity map with a finite difference of rigid motion. +They also check rotational isotropy, explicit dimensions, identity changes, rejected geometry/support, and exact face contact up to engine distance roundoff. +That geometric comparison tolerance is not a likelihood or added sensor variance. + +The separate physical reference generates a box and an attached sphere entirely from this declared model, then simulates gravity and plane contact in fresh worlds. +Each trial compares an uninterrupted 240-step trajectory with a fresh repeat and a full-prefix reconstruction returning steps 120 through 240. +It records initial penetrations, contacts, weld deflection, and every nonzero replay difference. +The references are generated mechanical cases, not historical-domain fits or agent solve-rate seeds. +See [the experiment record](experiments-20260912.md#rigid-assembly-prior-and-planar-contact-reference). + +Before applying this to recorded tasks, define and justify root attachment cases, uncertain relative geometry, valid scene regions, and robot-state priors. +An exact tied flag does not identify a weld frame, and a noisy pose cannot silently become a geometric prior bound. +Subsequent exact robot/contact observations still require a valid conditional representation. +This component supplies one physical building block without bypassing those requirements. diff --git a/docs/uncertainty/balloons-composed-inference.md b/docs/uncertainty/balloons-composed-inference.md new file mode 100644 index 000000000..e12f555ad --- /dev/null +++ b/docs/uncertainty/balloons-composed-inference.md @@ -0,0 +1,244 @@ +# Balloons transition and output composition + +September 12, 2026. +This joins the previously tested [conditional velocity transition](transition-discrepancy.md) and [coupled robot-output model](orientation-discrepancy.md) for the original non-hatch Balloons development recording. +It is an explicit stochastic model extension beside the deterministic reference and the production agent. +It changes neither the sensor variances nor the exact tie, pop and clip observations. + +## Accounting for each observation + +After each native action, the existing transition diagnostic conditions Gaussian joint-position corrections on the nine exact current-joint readings. +It conditions the rest/Gaussian box-velocity correction on exact speed, retaining the radial density and sampling the conditional direction. +Both transition factors remain in the composed path factor. +The observation uses the native predicted cached robot pose before correction, as established by the cache-phase audit. + +The remaining robot xyz readings receive an explicit AR(1) output discrepancy with persistence 0.9. +The robot Euler triple receives the previously tested quaternion-output discrepancy with scale 0.001. +All other continuous measurements retain their original sensor law. +The checked finger readout remains a deterministic reduction from its measured joint coordinate. +Every remaining exact event still has an indicator likelihood. + +The speed field is partitioned into its conditional transition factor; it is not relabeled as an external input or given a tolerance-band likelihood. +The exact joint readbacks are verified, and their transition densities are retained once. +A blank initial observation starts the output-error process at zero error without scoring the analytically conditioned root observation again. +Its placeholder prediction is unobserved and supplies no data term. +The initial-root proposal and its normalizing factors remain separate from these conditional path scores. + +For each 32-action path, the accounting covers 1,824 remaining measured scalars and 32 conditionally represented speed readings, totaling all 1,856 public readings. +Floating-point reconstruction bounds for speed remain diagnostics of the conditional sphere construction, not likelihood tolerances. + +## Saved-path composition check + +Job `22643404_0` completed 48 scoring cases using all twelve archived conditional paths. +It evaluated no robot-output discrepancy and xyz innovation scales 0.001, 0.005 and 0.02, retaining the three previously declared velocity scales and two direction draws for each root. +The zero setting preserves the earlier exact-output reference; the positive settings include the quaternion factor. + +| Robot-output setting | Finite paths from root 0 | Finite paths from root 1 | +| --- | --- | --- | +| Original exact outputs | 0/6 | 0/6 | +| XYZ scale 0.001 and quaternion scale 0.001 | 6/6 | 0/6 | +| XYZ scale 0.005 and quaternion scale 0.001 | 6/6 | 0/6 | +| XYZ scale 0.02 and quaternion scale 0.001 | 6/6 | 0/6 | + +Root 1 still predicts balloon 0 tied and clip 0 on one action early, at action 22. +The additional continuous discrepancy does not erase these two event disagreements. +The [report](../../logs/uncertainty_balloons_composed_output_20260912/pilot-22643404_0.json) retains every model identity, transition factor, remaining likelihood and field count. +These are conditional path-support results, not model evidence, calibrated uncertainty or posterior samples. + +## Complete first training episode + +Array `22643477` extends the same conditional transition protocol through all 235 actions of the first training reset episode. +It uses robot xyz innovation scale 0.001 and quaternion scale 0.001 while retaining all three velocity scales, both direction draws and both root-seed cases. +The initial-scene law and fixed learned program remain those of the earlier free-pose reference. +Future observations are used throughout this full-recording support diagnostic, so none of it is a held-out forecast. + +Both array tasks completed successfully. +All twelve complete trajectories repeat exactly in fresh worlds. +All six paths from root seed 0 have finite complete conditional path factors; all six from root seed 1 retain the same two action-22 event disagreements. +Each path accounts for all 13,630 public measured scalars: 13,395 remaining measurements and 235 speed readings represented by the transition law. + +The reports save each sampled physical root, joint state, body motion case and complete path rather than relying only on RNG seeds to reproduce a candidate. +They capture actual node1380 and node1381 runtimes and do not assume identical RNG-to-physical mappings across CPUs. +See the [root 0 report](../../logs/uncertainty_balloons_full_transition_20260912/pilot-22643477_0.json) and [root 1 report](../../logs/uncertainty_balloons_full_transition_20260912/pilot-22643477_1.json). + +This establishes complete-recording support for the declared stochastic extension, not for the deterministic sensor-only model. +The path factors vary substantially across velocity scales and direction draws. +Simply normalizing these few selected paths would not establish an adequate posterior over parameters, initial states and intermediate velocity directions. +The next inference construction must include the original parameter prior, root conditioning factors, conditional direction proposal and every retained observation factor, followed by numerical and predictive checks. +Joint corrections can also introduce physical inconsistencies; the transition model's limitations and previously measured forecast errors remain relevant to acceptance. + +## Fixed parameter-prior provenance + +The latest saved program has ten parameters and several data-narrowed optimizer bounds. +The version history retains the original [0.05, 8] support for four lift coefficients, but narrows lift height from [0.9, 1.4] and three mass supports from [0.02, 0.4]. +It also replaces native `air_drag` with an explicit force-based `drag_rate`, then adds a multiplicative `lift_scale` calibration coefficient. +These last two roles cannot silently inherit the old native-damping law or data-derived narrow bounds as an independent prior. + +The next fixed-program experiment declares independent uniform laws on [0.05, 8] for the lift coefficients and [0.9, 1.4] for lift height, with log-uniform laws on [0.02, 0.4] for the three color-indexed mass coefficients. +It separately declares `drag_rate ~ Uniform(0, 40)` and `lift_scale ~ LogUniform(0.5, 2)`. +These are explicit engineering priors for development, not recovered historical probability distributions or a claim of prior specification before observing any data. +The optimizer's coordinate scale alone does not establish a probability law. +Program selection from training data and unchanged-role support provenance remain visible in the comparison. + +The [prior audit manifest](../../logs/uncertainty_balloons_parameter_prior_20260912/plan.json) hashes all five saved declarations, the proposed laws and the fixed program. +Its compute probe checks whether all thirty lower/middle/upper overrides are actually returned unchanged by the program, including values outside its latest optimizer bounds. +Job `22644425_0` completed with all thirty probes passing and all other parameter values unchanged. +The [readback report](../../logs/uncertainty_balloons_parameter_prior_20260912/pilot-22644425_0.json) records the actual runtime and every requested value. +This interface check does not establish parameter identifiability or favorable physical trajectories across the prior. + +With these ten parameters, the complete stochastic target has 522-553 active continuous dimensions across the sixteen initial motion cases. +This comprises ten parameters, the existing 42-73 initial-state coordinates and two conditional velocity-direction coordinates for each of the 235 positive observed speeds. +Conditioned joint-position corrections contribute transition densities but no remaining free position coordinates. +The Gaussian and quaternion output discrepancies are integrated in their observation factors rather than added as sampled coordinates. +The fixed-parameter paths from root seed 0 have a resting robot and three moving balloons; that root component contributes 60 initial coordinates, or 540 total when parameters and all directions are inferred. +This high-dimensional construction needs an explicit proposal and numerical validation; the few support paths do not justify choosing a particle budget by themselves. + +## Initial balloon-orientation check + +Job `22646175_0` holds the saved root, world-frame velocities, parameters, actions and conditional direction draws fixed while changing initial balloon orientations. +It tests the original orientations, an independent rotation of each of the three balloons, and independent rotations of all three together. +All five 235-action paths repeat exactly in fresh worlds. +Each rotated case also has exactly the same complete path, public readings and composed likelihood factor as the baseline. +The [report](../../logs/uncertainty_balloons_orientation_gauge_20260912/pilot-22646175_0.json) retains the actual intervened physical roots. + +This supports investigating whether the nine initial balloon-orientation coordinates can be integrated out as rotational gauges under this fixed program. +The program resets a released balloon's orientation and motion before attaching it, but any reduction must also account for the pre-release contact dynamics and native inertia. +The test is specific to the sampled root and declared transition model; it does not establish invariance for arbitrary future simulator programs. +No initial-state dimensions or prior factors have been removed by this diagnostic. + +### Native justification and quotient representation + +The follow-up `22647466` checks both saved roots, with the original world-frame angular velocities and a separate larger-angular-velocity control. +For each combination it compares original, identity and random initial balloon rotations over all 235 actions, repeating every trajectory in a fresh world. +All twelve trajectories repeat exactly, and all eight orientation comparisons have exactly equal public paths and composed likelihood factors. +Changing angular velocity changes root 0's trajectory and score, with a maximum public-coordinate difference of approximately `4.03e-5`; root 1's tested spin change has no effect. +Angular velocity therefore remains in the initial-state model. + +The native readbacks establish that each balloon has a centered sphere collision shape of radius 0.03 m, mass 0.005 kg and isotropic inertia `diag(1.8e-6, 1.8e-6, 1.8e-6)`. +The collision and inertial origins coincide, and there are no articulated joints. +The frozen constructor sets scalar lateral, rolling and spinning friction and no anisotropic friction. +The learned program does not observe the initial balloon rotations and resets each rotation to identity, with zero motion, before creating an attachment. +Thus rotating an unattached sphere's body frame changes neither its geometry nor its world inertia or scalar contact law, and the rotation is discarded before it could define an attachment frame. +Initial collision-feasibility tests and public readings are also independent of this rotation. + +Under this fixed program and prior, write the initial balloon rotation as `R` and all retained variables as `z`. +The original law factorizes as `p(z) dHaar(R)`, independently for each balloon, and the likelihood and feasibility indicator depend only on `z`. +Integrating each normalized Haar measure contributes exactly one. +The new coordinate map therefore uses identity rotations as representatives of these equivalence classes, without treating the initial rotations as known or narrowing their original priors. +Their posterior marginals remain the independent original Haar laws if full states need to be reconstructed later. +This reduction is tied to the audited program and runtime; it is not applied to arbitrary future simulator programs. +The quotient uses the physical rotational symmetry; the reported native tests do not establish bitwise equivalence for every possible floating-point state. + +The resulting initial-state target has 33-64 active continuous coordinates across the same sixteen motion cases. +Including ten parameters and 470 conditional velocity directions gives 513-544 active coordinates; the previously studied root 0 component has 531. +The original unreduced counts above remain the provenance of the earlier experiments. + +The [root 0 native report](../../logs/uncertainty_balloons_gauge_native_v2_20260912/pilot-22647466_0.json) and [root 1 native report](../../logs/uncertainty_balloons_gauge_native_v2_20260912/pilot-22647466_1.json) preserve the body properties, source definitions, physical roots and complete paths. +The first audit attempt, `22647401`, failed while reporting source for a dynamically defined class, before evaluating any trajectory. +The corrected audit extracts the definitions from the frozen source files; the earlier setup failure remains archived and is not a model failure. + +### Joint coordinate preflight + +The next [coordinate-map manifest](../../logs/uncertainty_balloons_joint_map_20260912/plan.json) combines the fixed parameter priors, reduced scene law and every conditional transition direction in one deterministic unit chart. +It retains all sixteen robot/balloon rest-motion alternatives, all world-frame velocities, full box rotation, uncertain fixture placements and the unobserved robot joints. +The chart uses 548 unit coordinates: ten parameter coordinates, 68 scene slots and 470 direction coordinates. +The scene slots include four mixture selectors and auxiliary coordinates unused in rest components; integrating those unused uniform coordinates contributes one. +Uniform and log-uniform parameter quantiles preserve the declared original laws, and the initial Gaussian/truncated-Gaussian scene transforms preserve their first-observation conditioning factors. + +Array `22647822` checks the mapped saved roots, all sixteen motion cases and parameter perturbations on the negative-support root. +It verifies initial exact observations, geometric feasibility and repeated complete trajectories, while retaining subsequent event contradictions. +The coordinate inverse is a numerical proposal initializer: a tiny floating-point change in the reconstructed root is not claimed to reproduce the earlier saved path exactly. +Each mapped point must instead pass its own repeated native replay. +This preflight is not a posterior sampler or a numerical-adequacy certificate. + +Both tasks completed successfully. +All 24 mapped cases pass initial geometry, preserve every exact initial observation and repeat their complete trajectories exactly. +The mapped positive root and all sixteen motion alternatives have finite complete path factors. +The mapped negative root and its six parameter perturbations retain later event contradictions. +Reconstructing the positive root through quantile coordinates changes its public features by at most `1.05e-17` and joint coordinates by at most `6.67e-16`. +Its path factor changes slightly from the earlier physical-root experiment, as expected for a numerically reconstructed proposal point; its own repeated runs match exactly. + +### A proposal that accounts for table clearance + +The first broad/local mixture preflight, `22648086`, found only two geometrically valid and finite points among 24 draws. +The diagnostic `22648389` reproduces the saved points on another checked runtime and identifies the rejected contacts. +Most local rejections are box/table penetration: independently perturbing height and full box orientation can lower a corner through the table. +Other rejected points put a balloon below the table surface. +These failures are retained as geometry rejections, rather than being repaired after sampling. + +The revised proposal conditions the local height distribution on the proposed body's vertical support clearing the visible table surface. +For the box, the lower height bound uses its half-width times the sum of absolute entries in the world-vertical row of its proposed rotation matrix. +For a balloon, it uses the sphere radius. +The additional `1e-6 m` clearance belongs only to this proposal component, not to the prior support, collision criterion or observation likelihood. +Each height is drawn from a normalized truncated Gaussian in the original root's unit coordinate; its normalizer depends on the proposed box orientation and remains in the proposal density. +The entire candidate still passes the native full-scene collision check. + +The mixture retains weights `0.1 / 0.45 / 0.45` for broad, local and wider-local components. +The broad component retains the complete original unit support, including feasible configurations omitted by the local clearance restriction. +The importance correction uses the full mixture density, not just the selected component's density. +All motion selectors, conditional direction coordinates and unused rest-case auxiliary coordinates remain uniform in every component. +Thus this is a change in how candidates are proposed, not a new parameter or physical-state prior. + +The revised preflight `22648872` completed all 24 draws. +The broad component retains eight geometry rejections; all sixteen local draws now pass geometry and repeat their full trajectories exactly. +Four of eight local and all eight wider-local points have finite full-recording likelihoods, while four local points retain exact-event contradictions. +An independent coupled-height quadrature reference, `22648893`, verifies normalization of each of the three proposal densities to `1e-7` and checks 300 transported points. +These checks establish proposal accounting and supported candidates, not posterior coverage or an agent advantage. + +### Full joint sampler pilots + +Array `22649168` submits two full-recording inference pilots with numerical seeds 300 and 301. +Each uses 128 particles, 32 cubic-spaced temperatures and eight Metropolis moves per stage, with a maximum of 32,896 target evaluations. +Proposal blocks cover the mixture selector, individual parameters, coupled scene groups and sixteen-action direction windows. +The base factor retains the initial conditioning terms, inverse mixture density, exact joint/speed transition densities and full geometry/event support. +The remaining sensor and marginalized output-discrepancy likelihood is tempered to its complete value. +The original parameter priors remain fixed. + +Each worker checks the mapped support witness twice before fitting, freezes its actual runtime identity, and saves complete [sampler continuation records](sampler-checkpoints.md) on shared storage. +Both are restricted to node1391's Intel Xeon Gold 6230 runtime so resumed fits and replica comparisons do not silently change CPU-dependent numerical paths. +The eight-hour jobs use `mit_preemptable`; unfinished stages can be repeated from the last completed checkpoint. +Attempt telemetry separately counts reconstructed primitive actions, including the startup replay checks. + +The [pilot manifest](../../logs/uncertainty_balloons_joint_pilot_20260912/plan.json) identifies the full target, proposal, source, runtime controls and validation inputs. +The original geometry normalizer and fixed initial robot-output factors cancel from these posterior comparisons; the pilots do not estimate model evidence. +Their numerical availability remains unevaluated until the separate assessment is complete. +No resulting parameter distribution is routed to the acting agent by this experiment. + +## Unconditional future-generation audit + +Compute job `22652531` validates the generation half of the stochastic forecast using the same mapped support witness and frozen learned program as the joint sampler preflight. +It first reproduces the complete 235-action conditional path factor exactly as `-33314.92152710342`. +It then holds the first 64 conditional actions and their direction coordinates fixed and generates eight different 32-action physical futures. +The witness was previously selected using the full training recording; this is a mechanical integration check, not a prefix-fitted posterior or an independent held-out estimator comparison. + +During future generation, the nine joint-position corrections are unconditional Gaussian draws with standard deviation 0.001. +Box linear velocity is drawn through `VelocityDiscrepancy.sample` with rest probability 0.1 and moving scale 0.01, retaining angular velocity. +The predicted link-cache observation phase, attachments, discrete events and simulator memory retain the existing transition protocol. +The generator's observation lookup table contains only the initial frame and 64 prefix readings, so attempting to retrieve a future reading would fail. +Recorded future speeds and joints never determine the future corrections. + +All eight physical paths repeat exactly in fresh worlds, and all eight retain exactly the same conditional prefix as the full-recording reference. +Their future paths differ across process-noise seeds. +For two seeds, the complete generated paths also match the earlier inline velocity-draw implementation exactly. +The audit performs 1,963 native actions, including the full reference, repeated paths and inline-draw comparisons. +It runs on `node1381`, with the same Intel Xeon Gold 6230 CPU model as the original `node1391` preflight, and records its actual runtime. + +Each physical path then generates a complete 32-frame observation future with all 58 public fields. +Robot xyz output errors use only the fitted prefix; Euler and sensor draws follow their declared laws, and the finger readout follows its sampled source coordinate. +Exact fields without an assigned output-discrepancy factor retain their generated physical values. +The observation draws repeat with the same output seed and never feed back into physical simulation. +Prefix speed is omitted only from the output-error conditioning input because its density is already represented by the physical transition factor; generated future speed remains present. + +Across the 256 generated future transitions, the rest branch occurs 25 times. +The 2,304 joint-position innovations have empirical mean `1.0623e-6` and standard deviation `0.0010270`. +These are descriptive generation diagnostics, not posterior predictive calibration or task outcomes. +The separate component tests validate the velocity mixture's moments, rest probability and speed CDF against independent probability references. + +The original audit `22652483` reproduced the full conditional score but failed an assertion that incorrectly required robot xyz output-discrepancy draws to equal their native values. +The corrected assertion exempts all assigned output factors; the transition law and sampled paths were unchanged. +That failed diagnostic remains archived. +The successful report, source hashes, candidate coordinates and per-seed physical/observation artifacts are in `logs/uncertainty_balloons_future_generation_v2_20260912`. + +This audit supplies unconditional physical and observation generation, not the complete stochastic posterior forecast interface. +Numerically assessed joint posterior weights and integration over future transitions remain required. +In particular, future-density evaluation must use Gaussian joint and radial speed factors with conditional-history integration, rather than treating finitely many unconditional paths as exact-output equality components. diff --git a/docs/uncertainty/balloons-initial-scene.md b/docs/uncertainty/balloons-initial-scene.md new file mode 100644 index 000000000..a87b10220 --- /dev/null +++ b/docs/uncertainty/balloons-initial-scene.md @@ -0,0 +1,107 @@ +# Balloons initial-scene conditioning reference + +September 12, 2026. +This is a declared development prior for the first original non-hatch Balloons training recording in the [initial-state inventory](initial-state-inventory.md). +It connects public observations, a complete candidate scene, collision checks, and fresh-world replay of the frozen learned simulator. +It is not a dynamics-parameter fit, a full-trajectory posterior, or a deployed agent change. + +## Information and probability model + +The evidence is only the first observation returned by the corrected recording projection, including its exact public robot positions and exact box speed zero. +The model uses the historical `b09217bb3` runtime and frozen `cycle_000_vers_005_simulator.py`, with optional parameter sidecars absent in an isolated working directory. +Object handles and geometry come from fresh visible model worlds. +No evaluator poses, velocities, attachment frames, or hidden memory initialize candidates. +Later recorded actions and observations are used only for replay diagnostics after sampling. + +The prior is an engineering assumption, not a recovered task-generation law or a claim that the interface guarantees each reset value. +Its original distribution is identified separately from the first-observation conditioning target. + +| Quantity | Declared prior or input | Initial conditioning | +| --- | --- | --- | +| Box and balloon translations; clip translations; band xy | Independent Gaussians with standard deviation 0.15 m, centered on public base layout constants | Gaussian sensor conditioning for each noisy coordinate. | +| Box and balloon orientations | Independent uniform rotations on SO(3) | Retain all three coordinates per body; poses have no rotation sensor in this recording. | +| Clip orientation | Zero roll and pitch; uniform yaw on `[-pi, pi]` | Truncated Gaussian yaw distribution under the actual unwrapped observation channel. | +| Band z and orientation | Height is the midpoint of the supplied lo/hi descriptors; identity orientation | Derive the height; treat the descriptors as fixed context. | +| Robot positions | The previously declared Gaussian reset law, including unobserved movable joints | Condition on exact public arm/gripper positions and retain their original density. | +| Robot motion | Half mass at complete rest; half in independent uniform movable-joint velocities on `[-0.25, 0.25]` | No velocity reading; retain both cases. | +| Each dynamic body's motion | Half mass at zero linear/angular velocity; half in independent Gaussian velocity coordinates with standard deviation 0.25 | Exact box speed zero selects its rest component and retains mass 0.5; balloon motion remains unknown. | +| Static clip and band motion | Fixed zero velocity | Part of the anchored-fixture model. | +| Initial mechanisms | Intact untied balloons, closed clips at their canonical stop, no grasp, command weld, or pending command | This reset component rejects other root mechanisms as unsupported. | +| Labels and band limits | Supplied exact scene descriptors | Fixed inputs, not extra noisy likelihood terms. | + +After the initial conditioning, the representation has `42 + 13*m + 6*k` continuous dimensions, where `m` is the robot moving-case indicator and `k` is the number of moving balloons. +The range is therefore 42 through 73 dimensions across 16 motion cases. +The 42 common dimensions comprise 23 translations, three clip yaws, twelve omitted body-orientation coordinates, and four unobserved robot positions. +This count applies only to the declared free-pose reference; adding supported-contact cases changes the representation and its original-prior identity. + +The new `condition_gaussian_coordinate` function derives each scalar Gaussian proposal from its fixed original prior and one observation. +It returns the marginal observation density as well as the conditional mean and standard deviation. +For an exact coordinate, it returns zero remaining variance and retains the original prior density at the measured value. +Tests verify `p0(x) * p(y given x) / q(x given y)` against that returned factor, including repeated fitting and genuinely new independent observations. +The same reading must not be scored again after this analytic conditioning. + +The geometry policy rejects a whole candidate on dynamic-body collisions or robot collisions, respecting the visible chute's wall-box-only collision rule. +It permits static-static overlap as part of the declared anchored-fixture support and the source-established wheel/plane fixture contact. +It does not certify every sampled static-fixture arrangement as physically mountable. +This is an explicit limitation of this prior, not an unrecorded repair of rejected bodies. +Rejection from the analytically conditioned distribution samples its globally geometry-conditioned initial-state law. +The acceptance fraction is not reported as the original scene normalizer or model evidence. + +## Why the reset protocol matters + +The earlier generated-component audit checked geometry directly after constructing the robot and resetting its joints. +The historical `_set_state` path additionally calls `robot.reset_state`, which places the base center of mass at its configured pose. +The URDF base inertial origin is `(-0.0036, 0, 0.0014)` relative to the base link. +Consequently, the model constructor has base COM `(0.75, 0.6464, 0.0014)`, while the complete reset has COM `(0.75, 0.65, 0)`. +The expected wheel/plane signed distance changes from -9.675 mm to -11.075 mm. +The source-derived formula after reset is `0.055325 - 0.0014 - 0.065` meters. +The new check uses that declared reset geometry and retains the earlier constructor-only result separately. +It does not change the robot reset implementation, geometry, contact tolerance, or historical agent behavior. + +## Current evidence and remaining constraint + +Final root reference `22631706` sampled eight accepted scenes for each of two seeds, requiring 1,242 and 1,715 complete draws respectively. +All sixteen samples reproduce every exact initial observation. +Each sample's 16-action replay is identical across two fresh model worlds for all projected public outputs. +These are conditional root samples and replay checks, not agent solve-rate seeds. + +Every sample nevertheless disagrees with the first later exact observation. +Moving robot roots change the exact post-action joints; roots with resting robots first disagree on box speed. +Initial conditioning and reproducible replay therefore do not complete the trajectory-constraint problem. + +A separate controlled support diagnostic fixes the box upright on the known table face instead of using the free-pose sample. +At the public nominal xy and support height, its first speed is within `2.63e-11 m/s` of the recorded value for one tested native-parameter setting, and its first public joints match exactly. +This is a substantial reduction from the free-pose discrepancies, but it is not exact equality, a fitted posterior, or permission to add a tolerance-band likelihood. +The native damping scan motivates an explicit parameter-elimination diagnostic, while the mixture of supported and free initial states still needs a complete conditional construction. +Negative-height controls that intersect the table remain inadmissible under the tested support policy. + +Constraint diagnostic `22631837` then scans the visible native `air_drag` range `[0.01, 40]` at three fixed box masses. +Near damping 2.2, the first-speed residual falls to approximately `3.5e-16` through `4.6e-16 m/s` in this restricted supported component. +This remains a numerical candidate, not an exact conditional representation for all scene states or a whole-recording fit. +The local derivative estimates vary by approximately 2.5% through 5.9% over the tested finite-difference step sizes. +Two further sign-changing brackets near damping 12.82 and 14.19 do not converge to equality: they retain residuals around `3.46e-9` and `-8.89e-9 m/s`, while their finite-difference slopes grow by roughly 100 times when the step shrinks by 100 times. +They are discontinuity candidates, not valid smooth roots, and their illustrative inverse-derivative factors must not become posterior weights. +The three masses also retain later speed discrepancies of approximately `1e-4 m/s` within the sixteen-action prefix, although public joints match throughout that restricted replay. +No damping value was published or adopted by an agent. +This experiment establishes why a root solver's convergence flag alone is insufficient for the remaining nonlinear contact-conditioning work. + +Artifacts: [declared root law and source snapshot](../../logs/uncertainty_balloons_root_v4_20260912/plan.json), [all root samples and replay diagnostics](../../logs/uncertainty_balloons_root_v4_20260912/reference-22631706.json), [controlled support diagnostic](../../logs/uncertainty_balloons_support_20260912/reference-22631750.json). +The [parameter-constraint diagnostic](../../logs/uncertainty_balloons_constraint_20260912/reference-22631837.json) preserves every scan point, root candidate, residual, derivative estimate, and later prediction error. + +The follow-up `22632107` varies supported-box xy and yaw instead of fixing the canonical pose. +It obtains four admissible supported configurations per seed in 90 and 57 draws, then evaluates 21 damping values for each configuration. +None of those eight scans finds a sign-changing bracket for the first-speed constraint. +Their same-sign speed errors range in magnitude from approximately `3.57e-8` to `2.64e-6 m/s`. +This finite scan is not proof that no solution exists, but it does not support extending the canonical damping-elimination chart across uncertain initial geometry. +The [assessment](../../logs/uncertainty_balloons_supported_family_20260912/assessment.json) records the supported-component identity and corrects two inherited free-case metadata fields that were not used as weights in these diagnostics. + +Completed diagnostic `22632307` varies box yaw as the eliminated coordinate while retaining uncertain xy and testing three native damping settings. +It evaluated twelve scene/damping contexts with 2,297 one-action model predictions. +All four contexts at damping 2.2 include a zero-residual yaw candidate, including the canonical zero yaw. +At zero yaw, the three tested central-difference slopes are approximately `-7.868e-10`, with much smaller variation than the discontinuity candidates. +The smallest residuals at damping 0.04 and 10 are approximately `1.58e-8` and `7.69e-10 m/s` respectively; those solver outputs do not satisfy the exact constraint. +The raw list contains repeated roots at shared bracket boundaries, so its fifteen zero-residual entries are not fifteen independent solutions or samples. +Neither the scan nor the finite-difference checks establish all branches, their conditional weights, or whole-trajectory support. +This provides positive first-output witnesses in the supported component but does not resolve the later discrepancies already measured above. +The [completed assessment](../../logs/uncertainty_balloons_yaw_constraint_20260912/assessment.json) records counts, hashes, and a correction to an inherited metadata label: the eliminated yaw law is uniform on `[-pi, pi]`, while the old `constraint.prior` description refers to damping. +No prior weights were used, and these diagnostics are not posterior or agent results. diff --git a/docs/uncertainty/boil-incomplete-control.md b/docs/uncertainty/boil-incomplete-control.md new file mode 100644 index 000000000..dbac1fb00 --- /dev/null +++ b/docs/uncertainty/boil-incomplete-control.md @@ -0,0 +1,86 @@ +# Boil: incomplete-model control + +September 12, 2026. +This is a fixed-program diagnostic within the [simplification plan](simplification-proposal.md), not a new agent run or a completed physical-state posterior. + +## What the archived program can predict + +The selected first training level contains 264 actions and 265 public observation frames. +The fixed `cycle_000_vers_001_simulator.py` artifact has SHA256 `aeeb0f7cb16a5062e561ed5771f27a0b607c0bc1f1ebe435174a7054615e4ce5`. +It declares no parameters or model memory and overrides `_domain_specific_step` with an optional geometry dump followed by `return None`. +The diagnostic explicitly disables that dump with `BOIL_DUMP_GEOM=0`. + +The source review establishes a specific limitation of uninterrupted replay with this program: + +- Water volume is read from a liquid body's visual shape dimensions, or as zero when no liquid body exists. + The omitted filling hook is what recreates that shape as water rises. + Rigid-body motion alone does not change the dimensions. +- Bubbling is derived from the per-environment heat dictionary. + The omitted heating hook is what updates heat during stepping. + The observation-only initialization has no privileged heat and initializes it to zero. +- Spillage is read from a stored faucet attribute. + Initialization sets its observable value to zero, and the omitted faucet hook is what increments it. + +Consequently these three scalar predictions remain constant within a replay episode. +Their initialization may limit which constants are physically achievable, but allowing any real constant gives a more favorable fit than those restrictions can. +This review concerns the frozen model without an external executor, repeated state injection, program edits or resets inside the episode. +The relevant environment, base-simulator and observation-noise sources are unchanged between the comparison runtime `b09217bb3` and this review. +Their hashes are retained in the diagnostic report. + +## Error that initial-state uncertainty cannot remove + +All three readings use the declared additive, unclipped Gaussian sensor standard deviation of 0.07. +For each channel, minimizing squared error over all possible constants gives the observed mean. +The resulting scalar likelihood is an upper bound for every constant prediction under that sensor channel, including a mixture over uncertain constant initial values. +This bound concerns the scalar factor only, not the complete observation likelihood or model evidence. + +| Reading | Best constant using all 265 frames | Minimum RMSE | Minimum RMSE / sensor sigma | Future RMSE using the first 65 frames' mean | +| --- | --- | --- | --- | --- | +| Spilled level | 0.00294 | 0.07084 | 1.01 | 0.07202 | +| Bubbling level | 0.17956 | 0.38548 | 5.51 | 0.48986 | +| Water volume | 0.63501 | 0.41490 | 5.93 | 0.86762 | + +The future column fits a constant from the initial frame and first 64 actions, then evaluates the remaining 200 observations without refitting. +This is a causal split for this scalar calculation; it is not a claim that the suffix was unseen during historical program synthesis. +The all-frame optimum is deliberately optimistic and is not used to forecast the suffix. + +The constant-mean Gaussian reference gives centered chi-squared statistics of 271.42, 8,036.15 and 9,309.70 respectively, with 264 degrees of freedom. +Spill variation is close to the declared noise scale, whereas filling and bubbling require dynamics absent from the frozen program. +The reference statistics are diagnostics, not posterior-predictive checks or new agent decision thresholds. +Gaussian readings retain positive likelihood even at large residuals, so this is predictive inadequacy, not the exact model inconsistency established for the [Bridge glue control](experiments-20260912.md#a-structural-exact-output-contradiction-in-the-frozen-bridge-model). + +## Verification and next comparison + +Compute job `22650461` completed successfully on `mit_preemptable`. +It verified the recording and program hashes, reconstructed the declared public noise, and checked the constant optimum against an independent linear least-squares solution and shifted-constant likelihoods. +A separate 10,000-dataset stationary Gaussian reference had mean centered chi-squared statistic 263.694 against expectation 264 and coverage 0.9906 at the nominal 0.99 reference quantile. +Those generated scalar datasets validate the diagnostic calculation; they are not Boil tasks or agent seeds. +The report is [job-22650461.json](../../logs/uncertainty_boil_scalar_control_20260912/job-22650461.json), with source, configuration and scripts beside it. + +The original array `22650411` was cancelled while all four tasks were still pending. +Its worker incorrectly bypassed the public fit dispatch and called the optimizer with an empty parameter schema. +The frozen agent's `sim.fit()` explicitly returns without fitting or publication for these parameter-free subclasses. +This was an experiment-harness error; the production agent already handles the case correctly. + +Replacement array `22651160` exercises the real `BeliefProbe.fit()` and frozen synthesis backend, verifies that return, and then saves uninterrupted legacy predictions from the public noisy initial frame at the program's declared dynamics. +It checks the native parameter registry and declared schema before interpreting the result. +The first-64-action and complete-recording arms only partition prediction diagnostics; neither estimates parameters. +The tasks use one CPU each because the optimizer and its parallel validation workers are never invoked. +The frozen runtime, public observations, program and CPU model remain the same. +Artifacts are in `logs/uncertainty_incomplete_legacy_v2_20260912`; the original bundle remains preserved. +All four replacement tasks completed with exit code zero: each Boil task replayed 264 actions and each Bridge task replayed 1,186 actions. +Both domain pairs produced exactly identical complete predictions, despite their different diagnostic prefix lengths, and reported zero fitting likelihood evaluations. +The [verification report](../../logs/uncertainty_incomplete_legacy_v2_20260912/verification.json) records the public fit response, empty native registry, paired prediction equality and feature errors. + +| Boil scalar | Legacy suffix RMSE, 200 actions | +| --- | ---: | +| Spilled level | 0.06909 | +| Bubbling level | 0.48782 | +| Water volume | 0.87211 | + +These are actual legacy predictions initialized from the public noisy frame, distinct from the optimistic constant-model calculation above. +The Bridge suffix retains mismatches in four exact glue attributes: `span0.glue_end_a`, `span0.glue_end_b`, `span1.glue_end_b`, and `span2.glue_end_a`. +Neither result is an agent solve-rate trial or an assessed replacement posterior. + +These controls preserve failed predictions in the comparison instead of attempting to repair missing dynamics with wider initial-state uncertainty or larger sensor variance. +They do not supply an adequate posterior, finish the five-domain comparison, or authorize live use of the replacement estimator. diff --git a/docs/uncertainty/conditional-forecasts.md b/docs/uncertainty/conditional-forecasts.md new file mode 100644 index 000000000..0494ecdd1 --- /dev/null +++ b/docs/uncertainty/conditional-forecasts.md @@ -0,0 +1,180 @@ +# Conditional forecasts from the fitted output model + +September 12, 2026. +The [simplification plan](simplification-proposal.md) requires prediction checks on future interactions, not only likelihoods of fitted recordings. +`OutputObservationModel.sample_future` now generates observation histories from the same output model used in those likelihoods. +It does not change the acting agent or provide a numerically assessed parameter posterior. + +## Information boundary + +The caller supplies a complete native prediction history, the observed fitting prefix, and an explicit local random-number generator. +The method has no argument for recorded future observations. +Returned observations begin immediately after the prefix and retain their primitive-step indices. + +The physical prediction history must itself be generated without future readings. +For the current deterministic Domino/Fan physical models, replay the candidate's initial state and all recorded actions uninterrupted. +For an explicit stochastic transition extension such as Balloons, condition transitions only within the fitted prefix and draw unconditioned transitions afterward. +Conditioning a future transition on its recorded speed or joints would invalidate the forecast even if output-error sampling used the correct prefix. + +Declared external inputs are copied from the future prediction frames and receive no stochastic density. +They must be genuinely given inputs under the fixed sensor schema. +Unknown future outputs cannot be relabeled as inputs to improve predictions. +Missing required predictions or inputs are rejected. + +Offline posterior prediction must replay complete joint parameter/initial-state rows with their posterior weights. +The parameter-only projection used for later planning does not retain the initial-state dependence required by this comparison. +The new output sampler supplies conditional observation draws for a given physical history; it neither creates those joint rows nor certifies their weights. + +## Same discrepancy law in fitting and forecasting + +Each scalar error process is filtered using only the prefix. +The sampler draws one error value at the prefix boundary from its conditional distribution, then propagates the declared AR(1) process through the suffix. +This retains cross-time dependence instead of drawing every forecast marginal independently. +Declared sensor noise is drawn separately at each future reading. +An empty prefix uses the original initial-error distribution, with no extra transition at frame zero. + +Coupled Euler outputs draw from the same antipodal four-dimensional Gaussian mixture used by the likelihood, then apply the native Euler readout to the raw quaternion components. +The components are deliberately not normalized; normalization would change the pole probabilities and native yaw branches. +The current sampling path supports the native pole threshold of 0.99999 and explicitly rejects other declared thresholds. +Checked display features are computed from their sampled source readings, preserving the same reduction used during likelihood evaluation. +Unassigned fields retain their original sensor channel, including exact discrete events. + +These draws are observations under the declared discrepancy model, not corrected physical simulator states. +They must not be fed back into physical replay as if they were true states. +A zero-likelihood fitting prefix is rejected because it supplies no conditional forecast for that physical history. +Numerical failures remain separate from such an exact contradiction. + +## Causal future likelihood + +`OutputObservationModel.log_future_likelihood` scores a complete future observation history conditional on the supplied fitting prefix and fixed native predictions. +The caller supplies prefix and future readings separately, preserving their original step indices. +Future readings enter only the score calculation; they must not influence the physical prediction history or the fitted particle weights. + +The score is the log of the joint conditional density of the future readings, including temporal dependence in scalar output errors. +Scalar factors filter the prefix, then accumulate only future conditional observation factors. +Later future factors condition on preceding future readings through the probability chain rule; this evaluates the joint forecast and does not revise the forecast supplied for evaluation. +Independent sensor and coupled Euler factors contribute only their future terms, and checked readouts preserve their source relationship. +The implementation sums future factors directly instead of subtracting two large full-history log likelihoods. + +An impossible fitting prefix raises an unsupported-conditioning error because it defines no conditional forecast for that physical candidate. +An impossible future returns negative infinity, retaining the prediction failure. +An empty future has log likelihood zero when the prefix is supported; an empty prefix scores the original unconditional history. +Missing scalar readings still advance the error process by their recorded primitive steps. + +For a weighted posterior forecast, the complete-history densities must be mixed using the prefix-fitted joint particle weights. +Averaging log densities or independently mixing each time step would evaluate a different distribution. +This component scorer does not construct or assess that posterior mixture; `JointForecast`, below, supplies the deterministic physical-history mixture. +For a stochastic physical extension, it also does not replace integration over future physical transitions under the declared transition law. + +These density scores are diagnostics under the declared output model, not replacements for the common feature, event and action metrics in the legacy comparison. +The incumbent robust fitting objective is not a normalized predictive likelihood and must not be compared numerically with this log density. +Density comparisons across changed observation laws additionally require a common observation representation and reference measure. + +## Forecasts from the assessed joint posterior + +`JointForecast.replay` connects an assessed joint posterior to the output forecast and likelihood methods. +It requires the exact fitting ledger and output-model identity from that posterior and a fitted reset episode whose history will be continued. +The requested future actions are separate from the fitting observations. +Data, model, episode and action validation occurs before invoking replay. +The parameter-summary consumer and this forecast path share `validated_posterior`, which rechecks identity, numerical protocol and sample structure without suppressing predictive failures. + +Replay receives each complete positive-weight row, including all uncertain episode-state coordinates, as an owned dictionary. +It also receives the fitted reset episode and the future actions; there is no future-observation argument. +The caller remains responsible for implementing the frozen physical model, reconstructing that candidate's initial state and memory, replaying the complete prefix and disposing each world. +Interface checks cannot prove that an arbitrary callback implements the declared physics. + +One resulting history corresponds to each positive-weight source particle, in its original order. +Zero-weight particles are not simulated. +An exception aborts construction, and missing histories or an unsupported fitted prefix raise errors instead of deleting particles and renormalizing the remainder. +Predictive diagnostics, including failures, remain attached through the original assessment. +The fixed output model is identical across these components; inferred output-model hyperparameters need an explicit extension to this contract. + +The forecast scores a future history using a stable log-sum-exp of its complete conditional densities and the original particle weights. +An impossible future under every represented particle retains zero probability. +Observation draws select one complete source particle for an entire history, then draw the output errors under that history's supported prefix. +Returned source indices retain provenance, and explicit seeds make the draws reproducible. +Parameter and initial-state coordinates are never sampled from separate marginals, and particles are not switched between time steps. + +This adapter currently covers deterministic physical continuation under joint parameter/initial-state rows. +It does not integrate unobserved future physical transitions for the Balloons stochastic extension. +Passing one stochastic rollout per particle through this adapter would omit that additional integration and must not be presented as the complete forecast law. +The separate [conditional-path integration diagnostic](stochastic-future-integration.md) implements the required density accounting but currently fails its native numerical checks. +The existing execution estimator and all acting-agent behavior remain unchanged. + +## Numerical and native checks + +The component tests compare empirical forecast means and full cross-time covariance with a dense Gaussian conditional reference, including exact and noisy sensor channels. +They also check the original initial-error variance, exact events, derived displays, reproducibility, missing inputs, invalid prefixes, and the unnormalized Euler mixture's pole and yaw branches. +The existing likelihood tests remain in the check suite. +Final compute job `22651009` passed all 31 functional tests, two-file mypy and pylint, and pinned formatter checks. +The checked source hashes and outputs are in `logs/uncertainty_forecast_checks_v3_20260912`. +The preceding attempts corrected an invalid test fixture that declared a noisy conditioned input and two test-style lint findings; the forecast implementation was unchanged across those attempts. + +Native compute job `22650998` completed a Domino integration check using the two fixed candidates from the original sampler preflight. +Both candidates have feasible initial geometry and each complete 161-action history repeats exactly in a fresh world, totaling 644 native steps. +The first candidate retains its zero-likelihood fitting prefix and the forecast API rejects it. +The second reproduces the original 64-action prefix log likelihood `8141.576094195281` exactly. + +For that supported candidate, 32 draws each contain the full 97-action suffix with all 70 output readings per frame. +The physical trajectories were generated before consulting future observations, and output errors used only the initial frame and first 64 actions. +Repeated draws with the same seed match exactly; changing the seed changes the draws. +Four sampled complete histories were checked against the full likelihood and all had finite scores. +The native-prefix/suffix histories and all 32 observation draws are retained in the hashed forecast artifact. + +The candidate's moving-object Cartesian RMSE on 1,746 future scalar readings is 0.05555 m for native predictions and 0.05426 m for the sampled observation mean. +These are descriptive diagnostics for one selected support witness, not a comparison of estimators, calibrated coverage, or evidence of improved agent performance. +Neither candidate is an assessed posterior sample. +The report and frozen runtime are in `logs/uncertainty_domino_forecast_preflight_20260912`. + +This establishes a tested route from a fixed supported physical history to complete conditional observation forecasts. +Numerically adequate joint posterior rows, matched legacy comparisons and the live-agent gates remain required. + +The future-density reference tests additionally compare the scored joint suffix with a dense Gaussian conditional distribution across four persistence values and exact/noisy sensor channels. +They include missing prefix readings, deterministic contradictions, exact event/readout failures, coupled Euler poles, empty prefixes and futures, and a large-prefix cancellation case. +All 43 functional tests, including the existing output-model tests, pass on a compute node. +Job `22651367` also passed focused type checking; its sole lint failure was an overlong docstring. +After pinned formatting, job `22651945` passed two-file pylint, isort, yapf and docformatter checks. +The formatted source has an identical executable syntax tree to the functionally tested source, with documentation strings excluded from that comparison. +Source hashes and the parity record are retained in `logs/uncertainty_future_score_format_checks_20260912`. + +Compute job `22651837` evaluates the archived native Domino history and all 32 archived forecast draws with both the previous and new likelihood implementations. +All 33 complete-history scores match exactly, and the original prefix log likelihood remains exactly `8141.576094195281`. +Every generated future has a finite conditional score, extending the earlier check of only four generated histories to all 32. +The recorded 97-action future also has finite conditional log likelihood `10459.790527256999` for this candidate. +These values use the model's declared mixed observation representation and are not success probabilities or a calibrated model-selection threshold. + +This check reuses the hashed native predictions generated on `node1412`; it performs zero physical steps and no initial-state transforms. +Likelihood scoring ran on an Intel Xeon Gold 6230 host (`node1376`), with exact compatibility checked there against the old implementation and archived prefix score. +The originally submitted node-specific scoring job `22651798` was cancelled while pending because that node was fully allocated. +Reports and frozen sources are in `logs/uncertainty_domino_future_scores_v2_20260912`. + +Compute job `22652185` passed 47 functional tests and focused four-file mypy, pylint, isort, yapf and docformatter checks for the joint forecast adapter and shared assessment validation. +The tests include an enumerated anticorrelated joint measure, unequal particle masses, impossible mixed event sequences, stable mixture densities, reproducible whole-history draws, missing observations, provenance rejection, and failed-replay handling. +The existing parameter-projection and assessment tests also pass after sharing their validation logic. +Artifacts are in `logs/uncertainty_joint_forecast_checks_20260912`. + +### End-to-end numerical reference + +Compute job `22652221` completed four independent fits of a noisy constant-velocity model with uncertain initial position. +Each fit uses the same original uniform box prior, two prefix readings with sensor standard deviation 0.3, and no future observations in fitting or replay construction. +The two inferred coordinates are strongly anticorrelated: the independent Gaussian posterior reference has mean `(0.2, 0.3)` and covariance `[[0.09, -0.09], [-0.09, 0.18]]`. +The probability mass excluded by the box bounds is at most `8.03e-29`, bounding the reference's truncation approximation. + +The worker passes sampled joint rows through assessment, deterministic replay, weighted future scoring and 5,000 observation draws per fit. +The analytic two-step future has mean `(0.8, 1.1)` and covariance `[[0.54, 0.72], [0.72, 1.26]]`, including the original sensor noise. +The full off-diagonal covariance matters: independently drawing initial position and velocity, or switching particles between future steps, would change this reference. + +| Particles | Numerical seed | Maximum parameter mean error | Future joint log-density error | All declared reference checks | +| --- | --- | ---: | ---: | --- | +| 256 | 62 | 0.01003 | 0.11927 | Fail | +| 256 | 63 | 0.01637 | 0.09974 | Pass | +| 2,048 | 62 | 0.00316 | 0.02685 | Pass | +| 2,048 | 63 | 0.00350 | 0.00402 | Pass | + +The criteria were frozen before running: parameter mean error below 0.06, parameter covariance error below 0.03, future log-density error below 0.1, sampled future mean error below 0.1 and sampled future covariance error below 0.12. +The preliminary assessment used to exercise the forecast adapter checks parameter moments only, and all four fits pass that preliminary check. +The complete reference also checks future predictions; the 256-particle seed 62 fails its density criterion and is retained as a failed numerical reference. +This discrepancy is sampling approximation error in a known model, not evidence of missing physical dynamics. +It demonstrates why the real-domain numerical protocol needs prediction stability as well as parameter summaries. +The larger budget passes 2/2 numerical seeds, and the smaller passes 1/2; these are synthetic reference trials, not task or agent seeds, calibration across domains, or a production acceptance result. +The frozen plan, output, and explicit reference assessment are in `logs/uncertainty_joint_forecast_reference_20260912`. diff --git a/docs/uncertainty/domino-joint-inference.md b/docs/uncertainty/domino-joint-inference.md new file mode 100644 index 000000000..344fd2b6f --- /dev/null +++ b/docs/uncertainty/domino-joint-inference.md @@ -0,0 +1,129 @@ +# Domino joint-inference integration + +September 12, 2026. + +This experiment connects the offline sampler to a complete candidate physical scene and the [composed observation likelihood](orientation-discrepancy.md). +It is an integration pilot for the simplification plan, not a validated replacement fitter or an agent solve-rate experiment. +Production parameter fitting and execution estimation remain on the incumbent implementation. + +## Target and initialization + +The frozen program is Domino training seed 0's `cycle_000_vers_002_simulator.py`, running against historical source `b09217bb3` with the saved offline-module overlays. +The fit uses the initial observation and the next 64 recorded actions and observations from L01. +It conditions on the observed unheld initial case and immutable task descriptors. +It does not claim a distribution over other initial attachment cases or use recorded private velocities. + +The five parameter declarations have identical bounds and scales in both archived program versions. +Changed fitted initial values are not new prior centers. +The check establishes declaration stability across these versions, not that the program was specified independently of its training observations. + +| Parameter | Fixed prior | +| --- | --- | +| Lateral friction | Log-uniform [0.01, 2] | +| Restitution | Uniform [0, 0.9] | +| Rolling friction | Uniform [0, 0.1] | +| Spinning friction | Log-uniform [0.01, 2] | +| Mass | Log-uniform [0.005, 1] | + +Before geometric conditioning, a declared mixture assigns probability 0.8 to all six dominoes resting upright on the support plane and 0.2 to free orientations and motion. +This shared rest/moving case is an engineering prior, not a guarantee inferred from missing recording fields. +Body placement cells come from the visible component workspace, eroded by enclosing body radii. +The rest component uses uniform horizontal position and yaw, with fixed support height and zero motion. +The moving component uses uniform position, Haar orientation, independent linear velocity coordinates in [-0.1, 0.1] m/s and angular coordinates in [-0.2, 0.2] rad/s. + +Movable robot joint positions have zero-centered Gaussian priors with standard deviation pi radians for angular joints and 0.1 m for prismatic joints. +Nine exact initial controlled positions are conditioned with their original density retained; four other movable positions remain uncertain. +All movable joint velocities are zero in the rest case and uniform within plus or minus 0.1 in the moving case. +Including the five parameters, the active continuous dimensions are 27 at rest and 94 when moving, plus the discrete case. + +Geometric conditioning rejects the entire scene for prohibited queried penetrations below -1e-7 m. +The only explicit fixture exceptions are the two fixed wheel/plane contacts at -0.011075 m under this full-reset initializer. +This check does not add robot self-collision to the engine model. +One whole-scene support normalizer applies across both cases; feasibility is not normalized separately within each case. +The five contact/mass parameters do not alter this geometry, so that common normalizer cancels within the parameter/state posterior. +No model-evidence estimate is claimed without evaluating the normalizer. + +The proposal concentrates horizontal positions and supported yaw around the first noisy readings using truncated Gaussian distributions. +The original physical priors remain those above, and the prior/proposal density ratios are retained. +The complete initial observation is scored once, with the conditioned exact joint factor retained once. +The remaining observation factors use the explicitly declared scalar and coupled-orientation output discrepancies; this is not a sensor-only posterior. + +Each candidate initializes a fresh world using its sampled poses and joints before semantic state restoration, then installs full body orientations, velocities and joint motion. +Refreshing `get_observation()` updates the simulator's backing observation before uninterrupted action replay. +This avoids allowing noisy pose values to determine initializer side effects before replacing them with sampled poses. + +## Sampler implementation + +`SamplerConfig.proposal_blocks` optionally partitions the proposal coordinates into disjoint groups. +Each move selects a group uniformly and applies a symmetric Gaussian proposal within it, retaining the existing bounds rejection and conditional density correction. +State-independent selection preserves the same tempered target. +An empty block declaration keeps the original full-vector algorithm and random stream. +Blocks do not increase the evaluation budget or establish that a high-dimensional target has been explored adequately. + +Validation passed 17 functional tests, including conditional and unconditional correlated-grid references, uninformed marginals, malformed partitions and budget exhaustion. +Two-file type checking and lint passed in job `22636679`; its only remaining failure was docstring wrapping, subsequently corrected. +Job `22636896` passed final pinned formatting and exact old/new default-result comparisons across eight RNG seeds, excluding only the added empty configuration field. +The final executable syntax trees match the functional/type/lint snapshot after removing docstrings. + +The pilot uses 95 unit proposal coordinates with auxiliary unused coordinates in the rest case. +Those auxiliary uniforms integrate to one; they are not additional uncertain physical quantities. +Separate blocks update the five parameters, the mixture selector, robot state, and each domino's state. +Each of the two runs uses 32 particles, eight temperatures, four moves, proposal scale 0.05, and at most 1,056 candidate evaluations. +These intentionally small budgets test integration before larger numerical comparisons. + +## Validation and artifacts + +The corrected scene preflight, job `22636219`, accepted eight of eight proposed scenes and reproduced every 64-action trajectory exactly in a fresh second world. +It covered six rest and two moving candidates. +Two candidates had finite complete-output likelihood; the other six contradicted exact outputs. +Those candidate rejections are neither infrastructure failures nor proofs that the whole model is inconsistent. +The earlier `22636183` setup attempt failed by assigning the read-only `_current_state` property; its artifact is retained separately. + +Artifacts are in `logs/uncertainty_domino_joint_scene_v3_20260912` and `logs/uncertainty_domino_joint_fit_20260912`. +The latter freezes source, overlays, program/data hashes, prior policy, worker and compute configuration. +Pilot jobs `22636393_100` and `22636393_101` completed on `mit_preemptable` and wrote individual final reports. +Completion, final particle ESS, or finite likelihood alone cannot establish numerical adequacy. +Independent-run agreement, budget sensitivity and predictive assessment remain required before posterior use. + +Both pilots completed the temperature schedule but collapsed to one surviving initial ancestor. +Their parameter estimates disagree substantially: the lateral-friction medians are approximately 0.112 and 0.0206, and the mass medians are 0.212 and 0.0343. +These are diagnostic outputs of inadequate small-budget approximations, not reportable parameter estimates or evidence of identification. +The subsequent [process reproducibility audit](sampling-reproducibility.md) also found uncontrolled initialization order and small cross-node differences in generated poses. +Their disagreement therefore cannot be attributed solely to sampler RNG variation under an identical numerical runtime. + +| Sampler RNG seed | Candidate evaluations | Finite initial particles / 32 | First-stage ESS | Surviving ancestors | Distinct final parameter vectors | Worker seconds | +| --- | --- | --- | --- | --- | --- | --- | +| 100 | 612 | 11 | 1.00002 | 1 | 1 | 561.2 | +| 101 | 769 | 15 | 1.00000 | 1 | 3 | 651.4 | + +The total accepted-move counts, 371 and 420, include updates to initial state and auxiliary coordinates and therefore do not demonstrate exploration of the parameters. +The next sampling comparison must address concentration at the first temperature and poor physical-parameter movement, retaining the same target and reporting independent-run agreement. +Increasing the budget or changing proposal groups is a numerical experiment, not permission to publish the current collapsed samples. + +The runtime identity is still a development identity rather than complete capture of installed native dependencies and assets. +The program was synthesized from historical training experience, so later recording suffixes are not established as unseen during program synthesis. +At that pilot stage, the full legacy-fitter comparison, all-five-domain validation and matched planning experiments remained open. +The later [legacy comparison](offline-fitter-comparison.md) supplies completed incumbent fits and predictions; matched replacement-posterior and planning comparisons still remain open. +Fan also initially lacked an explicit original prior because its latest program narrowed bounds using the fitting data. +The subsequent [Fan joint-inference experiment](fan-joint-inference.md) addresses that prior declaration without adopting the data-narrowed bounds as independent prior information. + +## Conditioned-base continuation and compute recovery + +The subsequent [reproducible initialization experiment](sampling-reproducibility.md) uses 64 particles, 32 cubic-spaced temperatures and eight moves per stage, retaining the initial-observation factor in the conditional base. +It fits the same 64-action development prefix under fixed priors and an identified CPU/runtime. +Run `22637359_100` reached its four-hour Slurm limit before producing a completed sampler result. +Its last saved progress report recorded 9,536 target calls; the scheduler reports `TIMEOUT`, not an agent outcome or a completed posterior. +No sampler checkpoint existed in that frozen worker, so its best candidate cannot serve as a continuation state. +The other numerical seed, `22637359_101`, remains a separate running fit with an eight-hour allocation. + +Replacement `22649657_100` restarts numerical seed 100 from the original prior with the same data, priors, temperature schedule and evaluation budget. +It adds the tested scalar likelihood optimization, [stage checkpoints](sampler-checkpoints.md) and an eight-hour allocation on the same AMD EPYC 7542 worker node. +The runtime identity changes to identify those source changes; the statistical model and sampler configuration do not change. +Its startup checks compare complete old/new likelihoods on two replayed candidates before fitting. +One pair retains zero support, and the finite pair matches exactly at `8141.576094195281` on this AMD runtime. +The retry is running and has saved its initialized population checkpoint after 64 evaluations. +All sixty recorded finite-initial-base entries also match the timed-out run's entries exactly. + +The [retry manifest](../../logs/uncertainty_domino_conditioned_checkpoint_20260912/plan.json) retains the timeout reason and hashes the old likelihood source used for the paired check. +Attempt files retain per-attempt counters; the sampler result and checkpoint retain the cumulative numerical evaluation count. +This recovery does not establish posterior adequacy, convergence or an improvement in agent performance. diff --git a/docs/uncertainty/experiments-20260912.md b/docs/uncertainty/experiments-20260912.md new file mode 100644 index 000000000..355c3c64c --- /dev/null +++ b/docs/uncertainty/experiments-20260912.md @@ -0,0 +1,750 @@ +# Uncertainty simplification experiments, September 12 + +The production MB agent still uses the incumbent estimator. +These experiments validate the offline replacement in stages; they are not new agent solve-rate seeds. +The separately resumed MF comparison keeps its historical frozen runtime and original checkpoints. + +## Frozen implementation checks + +Job `22625595` completed successfully on `mit_preemptable`. +The snapshot reconstructs commit `6179fe1e7` plus a captured patch and content-hashed overlays. +It passed 39 functional tests, dependency-following mypy over 16 files, 16 configured lint checks, and pinned formatter checks. +The earlier successful 79-test foundation checks remain separate evidence and are not added to this count as unique tests. +These are scoped checks, not full-repository CI. + +Source identity and reports: [validation bundle](../../logs/uncertainty_stage_a_20260912/README.md), [job status](../../logs/uncertainty_stage_a_20260912/job-22625595/status.json). + +## Independent numerical references + +Job `22625659` completed 48 reference runs. +The scenarios, seeds 100 through 107, budgets, and tolerances were frozen before launch in [the experiment plan](../../logs/uncertainty_reference_sweep_20260912/plan.json) and its hashed script. +Both budgets use 32 temperatures and four Metropolis moves per temperature. +The likelihood-evaluation cap is `129 * particles`. +The smaller budget is a stress test; advancement requires every larger-budget reference run to pass. + +| Reference distribution | 256 particles | 1,024 particles | +| --- | ---: | ---: | +| Stationary Gaussian | 8/8 passed | 8/8 passed | +| Correlated initial position and velocity, with an uninformed parameter | 7/8 passed | 8/8 passed | +| Symmetric two-mode posterior | 8/8 passed | 8/8 passed | +| Total | 23/24 passed | 24/24 passed | + +The 256-particle correlated-reference run at seed 102 returned an uninformed-parameter mean of 0.143, outside the declared absolute tolerance of 0.12. +Its informed means and covariance met their criteria. +That failure remains in the results; the tolerance was not relaxed and the seed was not replaced. +Passing the larger-budget runs supports this sampler on the tested reference distributions, but does not establish calibration over repeated datasets, adequate physical initial-state support, or robustness to incomplete simulators. + +Each run preserves its weighted samples, numerical diagnostics, reference moments, observed errors, and elapsed time. +Results: [all reference summaries](../../logs/uncertainty_reference_sweep_20260912/job-22625659/summary.json). + +## Real recording integrity + +The corrected audit, job `22625932`, validated the first training level from historical MB seed 0 in each domain. +Initial job `22625681` checked file integrity but incorrectly treated stored states as agent observations; its statistical data projection is superseded. +These recordings are explicitly designated development data. +No test level was loaded. +The audit checks every primitive action against its reset log, retains public joint observations, and stores the original file bytes in content-addressed bundles. +Continual `episodes.pkl` stores sanitized simulator truth for replay. +The corrected reader regenerates the exact observation channel with run seed, level index, reset episode, and primitive step before building the inference ledger. +It includes those coordinates and the noise implementation source in the provenance bundle. +Already-noisy agent exports must not be passed through this reader a second time. +It verifies that an exact-output contradiction receives zero likelihood. +It does not simulate, fit a parameter, or assess the learned model's accuracy. + +| Domain | Recorded actions | Observed scalar fields | Exact fields | Result | +| --- | ---: | ---: | ---: | --- | +| Bridge | 1,186 | 107 | 67 | Passed | +| Fan | 132 | 108 | 49 | Passed | +| Domino | 161 | 70 | 40 | Passed | +| Boil | 264 | 46 | 24 | Passed | +| Original balloons | 235 | 58 | 32 | Passed | + +Body velocities and command-weld metadata are explicitly excluded from this feature-likelihood audit. +They are not used as privileged initial-state measurements. +Physical candidate velocities, attachment consistency, and missing model memory still need valid priors or reconstruction. +The runtime report records installed distribution versions and loaded-module hashes; it does not certify complete simulator asset or dependency capture. + +Results: [recording report](../../logs/uncertainty_recording_audit_v2_20260912/job-22625932/report.json), [selection and projection policy](../../logs/uncertainty_recording_audit_20260912/plan.json). + +## Fixed-program prediction preflight + +The next diagnostic holds the early and late training-program snapshots fixed and replays nominal predictions from the beginning and midpoint of each selected training recording. +Each window contains up to 64 recorded actions and is replayed twice in fresh worlds. +Declared initial parameter values are used without fitting. +The initialization intentionally measures the legacy replay's observed-state, zero-velocity assumption; it is not a posterior candidate with a validated physical-state prior. +The report separates exact-output contradictions, noise-standardized feature error, repeated-replay differences, and setup failures. +A nominal candidate's zero likelihood is not proof that every possible initial state and parameter has zero support. + +Optional balloons `model_params.json` overrides are explicitly absent in an isolated working directory. +This defines a reproducible development candidate; it does not reconstruct historical fitted parameter deployment. +Historical sidecar provenance must be resolved before any comparison claims that it reproduces the deployed model. + +Initial job `22625747` failed before simulation because a CLI `log` option was passed to `reset_config`, whose attribute is `log_file`. +Job `22625774` corrected that alias but exposed further setup problems: prediction states needed public-field sanitization, and the current branch lacks the historical balloons scene controls. +Job `22625841` therefore restored historical runtime `b09217bb3` plus isolated offline modules and used sanitized predicted outputs. +Its raw-state comparison exposed the recording-channel bug above, so its statistical prediction results are superseded. +These setup outcomes are not agent failures. + +Final corrected job `22625933` uses the historical runtime and reconstructed noisy observations for both initialization and scoring. +It completed 14 executable nominal cases, and repeated replay was identical in every case. +All 14 violated at least one exact output and therefore had zero likelihood under the strict sensor-only model. +The first violations included robot joint errors ranging from approximately 7e-14 to 2.3e-4 radians and balloons speed error of approximately 0.0079 m/s. +Later discrepancies also include substantive changes in observable events and geometry. +Two early-Fan cases did not execute because the frozen program declares a log-scaled `fan_speed` with lower bound zero. +That invalid program is preserved rather than repaired for the comparison. + +| Domain | Executable nominal cases | Exact-output contradictions | Repeated replay | +| --- | ---: | ---: | --- | +| Bridge | 2 | 2 | Identical | +| Fan | 2, plus 2 invalid-program cases | 2 | Identical | +| Domino | 4 | 4 | Identical | +| Boil | 2 | 2 | Identical | +| Original balloons | 4 | 4 | Identical | + +This finding does not establish that every parameter and feasible initial state has zero support. +It does establish that the current nominal initialization cannot be fed directly to a strict posterior likelihood and expected to work. +A tolerance was not added to the likelihood to hide these failures. +Before posterior fitting, separate missing initial motion and constraint state from incomplete learned dynamics and deterministic replay approximation. + +Results: [corrected prediction report](../../logs/uncertainty_prediction_preflight_v4_20260912/job-22625933/report.json), [frozen plan](../../logs/uncertainty_prediction_preflight_v4_20260912/plan.json). + +## Recording-channel regression and correction + +Compute reproduction `22625874` exercised the actual continual session, oracle actions, recording writer, and offline reader. +It failed because the reader returned the stored true state instead of the exact noisy observation seen by the acting agent. +The reader now requires explicit seed and level coordinates, reconstructs the seeded channel once, and rejects a directory/index mismatch. +Its fixture now writes truth, matching the real recorder. +The actual session test compares reconstructed observations exactly against the agent's first two observed frames. + +Validation `22625919` passed all 29 functional tests and mypy, but lint rejected a `type(...)` check. +The implementation now uses `isinstance` and explicitly rejects booleans as seed/index values. +Follow-up validation `22626126` completed successfully: 29 functional tests, mypy, all three lint checks, and pinned formatter checks passed. + +## Contact-rich restoration follow-up + +Job `22626183` reuses recorded training actions as mechanical stress inputs in a recreated evaluator world. +It compares explicit full-state replay and legacy zero-velocity replay from the same source trajectory, at the beginning and midpoint, with up to 256 actions per domain. +Evaluator state is used only to isolate reconstruction error and never becomes inference data or an agent observation. +This is not an agent experiment or a fitted-model comparison. +The [frozen plan](../../logs/uncertainty_contact_replay_20260912/plan.json) and [completed report](../../logs/uncertainty_contact_replay_20260912/job-22626183/report.json) preserve that distinction. +All five domains completed the mechanical audit. +Fresh replay was repeatable, but did not reproduce the source trajectory exactly. +For the midpoint continuation, the largest non-robot position-coordinate errors were: + +| Domain | Explicit motion restoration | Legacy zero-velocity restoration | +| --- | ---: | ---: | +| Bridge | 5.06 mm | 5.06 mm | +| Fan | 0 mm | 0 mm | +| Domino | 2.31 mm | 2.36 mm | +| Boil | 6.41 mm | 6.75 mm | +| Original balloons | 106.17 mm | 86.78 mm | + +These are mechanical discrepancies, not noise-adjusted prediction errors or agent costs. +Fan still differed in robot joints despite matching its non-robot positions. +Balloons additionally changed attachment topology, so preserving velocities alone did not repair its continuation. +The previously documented missing constraint frames, engine state, and domain-private state now require targeted diagnosis. +An equality check on captured model memory does not establish that unrepresented evaluator-private memory was restored. +These results prevent treating the current portable state as a validated complete latent-state representation for contact-rich inference. + +## In-process checkpoint diagnostic + +Job `22626292` tested the balloons midpoint continuation with PyBullet `saveState` plus a deep copy of the environment's Python-side state. +It completed without a setup exception but did not reproduce the source trajectory. +The two restore attempts had maximum non-robot position differences of 703.07 mm and 406.31 mm, compared with 106.17 mm for portable replay. +The recorded attachment names matched in these in-process attempts, but that does not certify that engine constraint frames or identifiers were restored correctly. +This is a failed checkpoint method, not evidence that an arbitrarily large discrepancy variance should enter the likelihood. +The next restoration diagnosis should compare actual engine constraint frames and lifecycle state, not only the portable attachment-name list. + +Report: [checkpoint diagnostic](../../logs/uncertainty_checkpoint_diagnostic_20260912/job-22626292/report.json). + +## Remaining acceptance gates + +The full Stage A gate still requires physical initial-state priors with feasible geometry and attachments, exact-output support handling, and complete runtime identity. +Stage B must then compare posterior and legacy fitting on common frozen programs and data, including held-out causal suffix predictions and incomplete programs. +Shadow planning and live agent comparisons follow predictive acceptance. +No historical MF run or current MB run is silently switched to the prototype estimator. + +## Resolving physical continuation failures + +Constraint diagnostic `22626945` reproduced the original balloons error using the same training-action continuation. +At the midpoint, two attachment commands and two lift forces were queued for the next action. +The old offline snapshot dropped that queue, causing the next step to remove existing welds and omit the lift. +Preserving the queue reduced the largest non-robot position-coordinate discrepancy from 106.17 mm to 4.00 mm. + +The public balloons feature vector contains positions but not box or balloon orientations. +A physical continuation nevertheless needs those orientations and the original weld frames, rather than frames recomputed from the deflected current poses. +Follow-up `22626966` restored all three together: pending commands, complete body poses, and original command-weld frames. +Its maximum non-robot position-coordinate discrepancy was 9.73e-14 m over the 118-action continuation. +The attachment sequence matched. +Robot joint differences remained as large as 1.97e-4 radians, so this is not an exact complete-state checkpoint. + +The same diagnostic identified a separate error in the previous in-process checkpoint experiment. +PyBullet restore left the third balloon's later-created constraint in the world when rewinding to a boundary that had only two constraints. +A second restore accumulated another extra constraint. +The corrected diagnostic removes constraints created after the saved boundary and verifies that every original constraint still exists before restoring. +This reduced both checkpoint attempts' non-robot position error to zero; robot joint error remained 7.52e-7 radians. +This narrow diagnostic does not implement general restoration after an original constraint has been removed or modified. +The old failed attempts remain recorded and must not be interpreted as simulator stochasticity or sensor variance. + +The offline replay implementation now represents full physical body poses, next-step commands, and original command-weld frames explicitly. +These are candidate quantities or evaluator-only diagnostic quantities, never additional agent observations. +Unknown command targets, incomplete physical poses, and missing weld frames are rejected. +The production fitter, observation channel, and acting MB/MF runtime are unchanged. + +A new `prefix` argument reconstructs a candidate's action history in the same fresh world before producing a requested continuation. +It retains accumulated engine state, native attachments, and model memory without a mid-trajectory restore. +Every call pays for the full prefix; a parameter change must replay that prefix under the changed parameters. +The initial candidate still needs a valid physical prior and canonical initialization. +Exact agreement with an uninterrupted candidate is a different claim from agreement with the historical evaluator or a learned program's predictive accuracy. + +Reports: [constraint lifecycle](../../logs/uncertainty_constraint_diagnostic_20260912/job-22626945/report.json), [full poses and checkpoint cleanup](../../logs/uncertainty_constraint_diagnostic_v2_20260912/job-22626966/report.json). + +Corrected five-domain contact audit `22627021` uses the same recorded-action stress inputs as the earlier audit. +Its `prefix` continuations exactly match uninterrupted portable-root candidate rollouts in all five domains at both tested boundaries, including every observed feature, all robot joint positions and velocities, body velocities, attachment sequences, and captured model memory. +The ten comparisons span up to 256 actions per domain, with 1,040 source actions total. +Fresh portable replays also remain exactly repeatable. +This establishes candidate continuation consistency, not equality with the source evaluator initialized through its original reset lifecycle. +Residual midpoint restoration errors relative to that source remain: + +| Domain | Largest non-robot position-coordinate error | Largest robot joint-position error | +| --- | ---: | ---: | +| Bridge | 4.51 mm | 0.00975 rad | +| Fan | 0 mm | 0.000651 rad | +| Domino | 3.40 mm | 0.00298 rad | +| Boil | 6.75 mm | 0.00968 rad | +| Original balloons | 9.73e-11 mm | 0.000197 rad | + +Root diagnostic `22627069` compares a repeated evaluator reset with physical-state reconstruction. +Bridge, Fan, Boil, and Balloons repeated their source reset trajectories exactly. +Domino did not; that reset-protocol inconsistency must be kept separate from the bit-identical portable-candidate prefix comparisons. +The inspected base-body poses, velocities, dynamics settings, and engine settings matched between fresh reset and physical restore, except one balloons quaternion component differing below 1e-42. +This inspection does not include contact solver caches or certify complete robot controller state. + +The explicit `replay_initialized_candidate` API permits a declared initialization protocol to run before the prefix. +It does not select evaluator tasks by default. +This supports evaluator-only reset references and, separately, a future generative candidate initialization based on the declared prior. +Its initializer, inputs, and runtime must be included in artifact identity. +The initializer and every prefix action run under the requested candidate parameters, and initializer failures release the temporary world. + +Reports: [corrected five-domain contact replay](../../logs/uncertainty_contact_replay_v2_20260912/job-22627021/report.json), [root protocol diagnostic](../../logs/uncertainty_root_diagnostic_20260912/job-22627069/report.json). + +Explicit-initializer audit `22627125` reproduced Bridge, Fan, Boil, and Balloons exactly from their evaluator reset protocols through both tested boundaries. +Domino still differed even when the diagnostic replaced time-limited IK with a fixed attempt bound, so that hypothesis did not resolve its reset inconsistency. +Inspection found that its disk task cache retains feature values but discards the exact initial robot joint configuration, then invokes inverse kinematics again on load. +The cache boundary is being tested independently; the evaluator-only initializer must not silently mix generated and reconstructed task roots. +The diagnostic IK setting is not a change to an acting experiment. + +Focused implementation validation `22627013` passed 24 functional tests and mypy but identified two test-only lint errors. +Those errors were corrected and validation `22627081` passed all 14 replay tests, type checking, lint, and pinned formatting. +After adding explicit initialization and parameter/history ownership tests, final validation `22627126` passed 26 focused functional tests, mypy for both changed files, both lint checks, and pinned formatting. +This is focused validation, not the full repository CI required before a PR. + +Report: [explicit initialization audit](../../logs/uncertainty_initialized_replay_20260912/job-22627125/report.json). + +## Domino task-cache reconstruction + +End-to-end reproduction `22627163` generated a real Domino task, moved the physical robot to a valid alternative configuration, saved the task, and loaded it into a fresh simulator through the production cache reader. +The loader changed the first joint from -0.1533237473 to 0.4424211360 radians because its feature-only record forced another IK solution. +The missing joint configuration explains why matching object features is insufficient for a faithful task-cache round trip. +Changing the IK time limit had not addressed this information loss. + +The cache writer now includes portable simulator metadata, including exact initial joints, and the reader supplies that metadata to the existing task-restoration path. +Goal semantics and task sampling rules are unchanged. +Old feature-only cache files remain readable through the legacy reconstruction path. +The existing source digest changes the cache key for future runs, so newly generated caches preserve the complete stored robot configuration. +The frozen MF sweep checkout and its running jobs are not modified by this fix. +This harness correction is separate from the offline replay implementation and does not change MB prompts or its uncertainty estimator. + +Cache-fix audit `22627211` removed Domino's multi-radian initial-configuration error but retained a smaller contact discrepancy. +The remaining diagnostic mismatch came from comparing different initialization lifecycles: the source performed task-generation simulations in its execution world, while subsequent worlds loaded the cached tasks. +`ContinualRun._begin_level` already uses a fresh execution world sharing previously generated tasks when `test_fresh_env_per_episode` is enabled, as it is in this sweep. +The faithful mechanical reference must use that lifecycle for both source and replay. + +Final audit `22627245` matches the continual lifecycle: generate tasks in a template, create fresh instances sharing those tasks, reset, and execute the recorded training actions. +The source and candidate use identical historical configuration values, with the task-cache correction and offline replay modules recorded as overlays. +All ten comparisons across five domains passed exact equality of the measured features, every robot joint position and velocity, body velocities, attachment sequences, and captured model memory. +No extra sensor noise or likelihood tolerance was introduced. + +| Domain | Source actions | Continuation boundaries | Largest measured feature error | Largest robot joint-position error | +| --- | ---: | --- | ---: | ---: | +| Bridge | 256 | 0, 128 | 0 | 0 | +| Fan | 132 | 0, 66 | 0 | 0 | +| Domino | 161 | 0, 80 | 0 | 0 | +| Boil | 256 | 0, 128 | 0 | 0 | +| Original balloons | 235 | 0, 117 | 0 | 0 | + +This establishes a validated reconstruction path from explicit initialization plus action history on the tested development prefixes. +It does not certify arbitrary portable checkpoints, every possible trajectory, historical solve-rate replication, or posterior quality. +The replay obstacle can be bypassed without changing the current MB estimator: use the declared initializer and reconstruct prefixes, paying their simulator cost. +Physical initial-state priors, exact-observation support, runtime closure, and posterior-versus-legacy predictive comparisons remain outstanding. + +Reports: [cache fix with mismatched lifecycle](../../logs/uncertainty_initialized_replay_v2_20260912/job-22627211/report.json), [continual-lifecycle replay validation](../../logs/uncertainty_continual_replay_20260912/job-22627245/report.json). + +Cache validation `22627201` passed both end-to-end cases but exposed a type annotation mismatch after extracting the existing float64 state dictionary into a local variable. +The annotation was corrected without changing numeric precision. +Validation `22627228` then passed both cases and type checking but flagged an overlong test import. +Final validation `22627254` passed both end-to-end cases, mypy, both lint checks, and pinned formatting after that import was shortened. +The two cases cover exact joint preservation through fresh-world actions and compatibility with older feature-only cache files. +Together with the 26 focused replay/legacy tests, these are 28 distinct passing functional tests across the two implementation chunks. + +## Exact affine conditioning reference + +Job `22627437` tested a conditional-coordinate construction before any physical posterior proposal was introduced. +For the declared equation `y = A(u) z + b(u)`, a square nonsingular `A(u)` determines the eliminated coordinates `z`. +The implementation retains the original joint box prior and the density factor `1 / abs(det(A(u)))` relative to the free-coordinate proposal. +It distinguishes a singular unsupported chart, a particular solution outside the original prior, and a numerical linear-algebra failure. +The returned residual and backward-error bound describe floating-point solution accuracy; they are not an observation-noise floor or an epsilon-band likelihood. +The bound is not a guarantee of small forward error in an ill-conditioned system or adequate posterior approximation. + +The reference equation is `observed = theta * start`, with independent original uniforms `theta ~ U(1,2)`, `start ~ U(0,1)`, and an unused coordinate `U(-1,1)`. +The exact observation is `0.5`. +Independent continuous draws miss this equality, while simply setting `start = 0.5 / theta` leaves an incorrectly uniform parameter marginal. +The correct conditional parameter density is proportional to `1 / theta`, giving mean `1 / log(2)`, approximately 1.442695, rather than 1.5. +This explicit change of variables is a restricted reference; the [smooth constrained-inference literature](https://proceedings.mlr.press/v54/graham17a.html) does not establish a solver for this repository's contact dynamics. + +An importance-sampling audit used eight independent seeds at each of two predeclared budgets. +Acceptance required absolute parameter-mean error at most 0.04, unused-coordinate mean error at most 0.08, and maximum error at 41 predeclared parameter-CDF checkpoints at most 0.04. + +| Particles | Passing seeds | Largest mean error | Largest unused-coordinate mean error | Largest checked CDF error | +| ---: | ---: | ---: | ---: | ---: | +| 512 | 5/8 | 0.031334 | 0.042561 | 0.072825 | +| 8,192 | 8/8 | 0.002930 | 0.009718 | 0.009938 | + +The largest affine residual was 5.55e-17 at either budget. +The three smaller-budget failures remain in the report; no seeds were rerun or excluded to obtain a passing aggregate. +This is a numerical importance-sampling reference, not SMC deployment, a physical-domain fit, or an agent solve-rate experiment. +The existing offline SMC tests also ran unchanged. +All 15 functional tests, two-file mypy and lint checks, and pinned formatting checks passed. +The new cases cover initial-coordinate conditioning, parameter-dependent density corrections, coordinate ordering, complete elimination, output-unit changes, offsets, and support/error distinctions. + +Artifacts: [plan](../../logs/uncertainty_conditioning_20260912/plan.json), [reference report](../../logs/uncertainty_conditioning_20260912/reference-22627437.json), [test report](../../logs/uncertainty_conditioning_20260912/checks-22627437.xml). + +## Five-domain initial-state inventory + +The [inventory](initial-state-inventory.md) identifies the public measurements, missing quantities, proposed representation requirements, and unresolved priors separately for all five frozen first training levels. +Job `22627492` reconstructs their seeded public observation ledgers and inspects each visible model without generating evaluator tasks. +All five cases use fixed-base Fetch with 24 URDF joints: nine observed movable joints, four unobserved movable joints, and eleven fixed joints. +The two wheel joints and head pan/tilt are movable but absent from the controlled-joint observation. +Before justified reductions, their positions/velocities plus nine controlled velocities give 17 possible continuous robot-state coordinates per episode root. +Known URDF fixed joints add no physical degrees of freedom. + +The first public balloon-box speed is exactly zero. +An inference construction supporting only positive-speed spheres would therefore miss this development root. +A candidate rest component must have declared prior mass and conditioning semantics; a missing velocity field is not a reset guarantee. +The inventory also distinguishes the frozen Bridge and Boil no-op programs from later learned artifacts and identifies optional parameter sidecars and data-narrowed bounds that require prior/runtime provenance. +Final physical dimensions and normalized support remain unresolved rather than being reported as an independent box over noisy feature values. + +The first inventory attempt, `22627481`, used a nonexistent public physics-client attribute in the audit script and produced setup errors. +The corrected audit uses the visible model's actual client handle; the failed attempt is preserved and is not an agent outcome. + +Artifacts: [inventory plan](../../logs/uncertainty_state_inventory_v2_20260912/plan.json), [verified inventory report](../../logs/uncertainty_state_inventory_v2_20260912/job-22627492.json). + +## Explicit velocity prior and exact rest + +The initial balloon box has exact observed speed zero, so an unconstrained continuous velocity proposal or a positive-speed sphere alone misses the required construction. +`RestOrGaussianVelocityPrior` now defines a normalized mixture: mass `rho` at velocity zero, otherwise a three-dimensional isotropic Gaussian with per-axis standard deviation `sigma`. +These are explicit modeling assumptions, not hidden evaluator values or reset guarantees. +A full initial-state prior still has to specify their values and dependencies on scene geometry and attachments. + +At speed zero, the conditional velocity is exactly zero and the observation contributes mass `rho`. +At positive speed `r`, two uniform coordinates generate an isotropic direction, while the speed observation contributes `(1-rho) * sqrt(2/pi) * r^2 / sigma^3 * exp(-r^2/(2*sigma^2))`. +Both factors use the declared measure consisting of a point mass at zero plus Lebesgue measure on positive speeds. +The speed-squared factor is required; dropping it or treating the direction proposal as evidence would change the model. +When `rho=0`, conditioning on the zero-density boundary is explicitly unsupported rather than assigned an arbitrary posterior. +When `rho=1`, a positive speed is outside the prior's support. +An unrepresentable log density raises a numerical error instead of masquerading as exact zero support. + +Compute job `22628133` passed 19 functional tests, two-file mypy and lint, and pinned formatting. +The four new tests check unit total mass, isotropic positive-speed directions, retained information about rest probability, and the distinction between zero speed and an arbitrarily small positive speed. +For a uniform prior on `rho`, one rest observation produces conditional density `2*rho` and mean `2/3`, while an independent uninformed moving-scale parameter retains its prior. +The tests verify that the rest factor is independent of that scale; they do not establish a fitted physical posterior for the balloon domain. + +Artifacts: [plan](../../logs/uncertainty_velocity_prior_20260912/plan.json), [validation report](../../logs/uncertainty_velocity_prior_20260912/checks-22628133.xml). + +## External runtime inputs affect physical predictions + +The frozen balloon model reads `./model_params.json` and gives its entries precedence over `agent_param`. +Mechanical reproduction `22628126` held program bytes, declared candidate parameters, initial recorded state, and all 235 actions fixed while changing only that optional file. +Replacing the four lift coefficients through the sidecar changed predicted box height by up to 0.496114686 m. +The override values were within the declared coefficient ranges. +This is a runtime-dependency reproduction, not an agent seed, physical-prior fit, or model-quality comparison. +It demonstrates why program and parameter hashes alone do not identify a simulator. + +The new offline `RuntimeInputs` snapshot records present file bytes, explicitly absent optional paths, and a complete child environment. +It materializes a fresh worker directory and verifies its declared inputs before a result is accepted. +Source edits cannot change previously captured bytes; added, removed, changed, or symlinked worker inputs invalidate verification. +Its artifact identity includes absence and environment settings, so a sidecar-free hypothesis cannot collide with the sidecar-backed model. +Callers must use separate worker processes with that complete environment, rather than temporarily changing the working directory or environment in concurrent sampler threads. +The production model loader and acting agent were not changed. + +Fresh-process replay validation `22628194` ran each file condition twice under this contract. + +| Frozen file condition | Declared candidate parameters | Largest repeated feature difference | Runtime-input identity prefix | +| --- | --- | ---: | --- | +| `model_params.json` absent | Same in both conditions | 0 | `f0788c84e53d` | +| Override file present | Same in both conditions | 0 | `1c592739083a` | + +The two conditions still differ by 0.496114686 m in predicted box height and now have distinct runtime-input identities. +The identity prefixes describe this audit's explicit environment and inputs; they are not universal program identifiers. +Validation `22628197` passed 13 functional tests, two-file mypy and lint, and pinned formatting. +An earlier validation failed because the test fixture wrote an invalid JSON number; that fixture was corrected in the retained second snapshot. + +This closes the explicit working-directory input gap, not all runtime dependencies. +Interpreter binaries, package imports, native libraries, assets, configuration, and invocation still need complete runtime capture. +Post-run file verification is not a filesystem sandbox and does not detect transient writes or arbitrary external reads. +The attempted system-call trace in job `22627944` was rejected by the compute node's `ptrace` policy; the follow-up does not claim native dependency discovery. +That setup failure is preserved separately from the successful replay reproduction. + +Artifacts: [reproduction](../../logs/uncertainty_runtime_inputs_v2_20260912/job-22628126.json), [frozen replay validation](../../logs/uncertainty_runtime_replay_20260912/job-22628194.json), [contract validation](../../logs/uncertainty_runtime_contract_v2_20260912/checks-22628197.xml). + +## A structural exact-output contradiction in the frozen Bridge model + +The final archived Bridge program is byte-identical to the frozen cycle-000 program, SHA256 `4ef259f6c0de775971e7ecd92b1a854743471bafaa28fb5a9376623d96b1f6bf`. +Its residual hook returns `None`, it declares no model memory, and it has no declared residual parameters. +Source inspection establishes an invariant for this program under uninterrupted replay: every `glue_*` attribute retains its initial value. +The generic step advances robot control, rigid-body physics, and grasps; the glue observation reads the stored attribute. +The glue-changing process and its latch calls are in the domain-specific hook that this program replaces with a no-op. +Changing initial poses, velocities, or native physical parameters cannot add that missing hook. + +Compute audit `22628262` checked the reviewed invariant against the actual public observation ledger and replayed all 1,186 training actions through the frozen model. +All 15 predicted glue attributes stayed exactly at their initial values. +Four exact observed attributes changed: + +| Exact feature | Step 0 value | First differing step | Observed value at that step | +| --- | ---: | ---: | ---: | +| `span0.glue_end_a` | 0 | 58 | 0.2 | +| `span0.glue_end_b` | 0 | 156 | 0.2 | +| `span1.glue_end_b` | 0 | 122 | 0.2 | +| `span2.glue_end_a` | 0 | 200 | 0.2 | + +The source invariant and either pair of differing exact observations establish inconsistent full-recording support for this frozen program under the declared deterministic sensor-only target. +The rollout confirms the inspected code path; finite candidate sampling alone would not prove inconsistency. +This is stronger than the earlier nominal-replay failures: no broader initial-state prior or larger sampler budget can make a constant output equal both observed values. +This conclusion is specific to the frozen no-op program, its runtime, and the full recorded target, not to all Bridge models or the acting agent's solve rate. +The agent still solved the recorded task; that does not imply that its simulator explains every observed mechanism. + +The comparison must retain this as an explicit unavailable-posterior/model-inconsistency control. +It must not inflate sensor noise, replace later predicted glue values with observations, or select only the pre-glue prefix to claim the full target passed. +A revised simulator or an explicitly evaluated dynamics-discrepancy model is required to explain those transitions. +The legacy agent remains the deployment baseline while that separate model-adequacy problem is addressed. + +The first audit, `22628239`, completed its rollout but incorrectly sent predicted states through the recording-only sanitizer. +The corrected audit reads predicted object features through `Observation.from_state`; the public observation ledger and its noise model are unchanged. +Both attempts remain in the experiment record, with the first classified as an audit setup error. + +Artifact: [invariant and exact-observation report](../../logs/uncertainty_bridge_invariant_v2_20260912/job-22628262.json). + +## Integrated conditional-base sampling and support assessment + +The offline SMC implementation now accepts `ConditionedPrior`, which identifies the original generative prior, exact-conditioning map, and normalized uniform proposal in free coordinates. +The map returns full joint coordinates and the conditional-base/proposal log-density ratio. +Initialization retains that ratio, and every Metropolis move retains its untempered difference while tempering only the remaining likelihood. +Results include eliminated initial-state coordinates so parameter and state marginals and future predictions use the same weighted joint samples. +The caller must justify the map's coverage and density; a declaration alone does not prove them. +The implementation still requires at least one free continuous proposal coordinate and does not provide a general contact-constraint chart. + +The integrated numerical model is `x(t) = start * theta**t`, with exact observation `x(1) = 0.5`. +The original prior is uniform in `theta`, `start`, and an unused coordinate; eliminating `start` contributes `1/theta` to the conditional base density. +One reference uses `theta` between 1 and 8 with no remaining noisy evidence, testing that repeated moves preserve this nonuniform base. +The other uses `theta` between 1 and 2 with noisy observations at times 0, 2, and 3, comparing the fitted joint distribution and prediction at held-out time 4 against independent midpoint integration. +The unused coordinate should retain its original marginal. + +Compute job `22628442` ran the predeclared two problems, two budgets, and four seeds per combination. +All runs used 24 temperatures and three moves per temperature. + +| Reference | 512 particles | 2,048 particles | +| --- | ---: | ---: | +| Conditional base with no remaining likelihood | 2/4 passed | 4/4 passed | +| Noisy dynamics and held-out prediction | 4/4 passed | 4/4 passed | +| Total | 6/8 passed | 8/8 passed | + +The smaller-budget base trials at seeds 1 and 2 missed the predeclared mean tolerance of 0.08, with errors 0.1187 and 0.1440. +Their other acceptance metrics passed; both failures remain recorded, without relaxing thresholds or replacing seeds. +The largest mean error for the larger-budget base reference was 0.0612. +For the larger-budget noisy reference, maximum parameter-mean error was 0.00456 and maximum held-out predictive-mean error was 0.0156. +These are numerical reference trials, not agent seeds or a posterior-calibration study across independently generated datasets. + +The first focused regression job, `22628424`, missed the broader-base mean tolerance of 0.07 at 2,000 particles and seed 4, with error 0.1109. +The revised fixed regression increases its particles to 8,192 while keeping that seed, tolerance, temperature schedule, and proposal scale unchanged. +The original finite-budget failure is preserved alongside the independent multi-seed budget comparison above. +Job `22628478` passed all 26 functional tests and four-file mypy, then found one overlong source line in lint. +Job `22628640` also passed all 26 functional tests and four-file mypy, but splitting the line still left its assignment overlong. +The final static-check snapshot shortens only that local temporary's name, without changing arithmetic or control flow. +Job `22628728` passed four-file mypy, all four configured lint checks, and pinned isort, yapf, and docformatter checks. +Together with the 26 passing functional tests, these are scoped compute-node checks, not full-repository CI. + +The tests additionally cover a conditional component with log mass -1000 that an exact discrete observation selects. +Its mass must remain in log space until the first likelihood update, rather than disappearing through premature underflow. +Invalid map outputs raise errors; exhausted budgets and finite searches with no supported particles return no posterior samples. +Original Box-prior sampler parity job `22628479` compares eight old/new cases in separate processes and obtains byte-identical serialized results, including samples, weights, and diagnostics. + +The separate `audit_constant_outputs` API takes a reviewed declaration tied to program bytes, runtime identity, and a review artifact. +It checks every supplied episode's exact predicted observations, respecting reset boundaries, and returns the first conflicting pair for each declared feature. +Noisy measurements and exogenous conditioned inputs cannot establish this exact contradiction. +`model_inconsistent` means witnesses contradict the supplied invariant; `not_disproved` means only that this check found no contradiction. +The API neither proves arbitrary Python semantics nor infers invariants from finite rollout samples. + +The same reference job reloaded the full frozen Bridge public ledger, verified its data and sensor identities, and applied the earlier reviewed glue invariant. +It found the four recorded changes at steps 58, 122, 156, and 200, with zero sampler evaluations and zero simulation steps for this check. +That assessment remains specific to the frozen no-op program and reviewed runtime. +It is separate from sampler `no_particle_support`, unsupported conditional charts, predictive disagreement under nonzero likelihood, and agent solve outcomes. + +The production agent continues to use legacy uncertainty handling. +Full physical initial-state priors, remaining exact robot/contact constraints, and complete runtime capture still gate real-domain posterior comparisons. + +Artifacts: [reference plan](../../logs/uncertainty_conditional_reference_20260912/plan.json), [all reference trials and Bridge witnesses](../../logs/uncertainty_conditional_reference_20260912/job-22628442.json), [original sampler parity](../../logs/uncertainty_sampler_parity_20260912/job-22628479.json), [functional checks](../../logs/uncertainty_conditional_batch_v3_20260912/checks-22628640.xml), [final static-check plan](../../logs/uncertainty_conditional_batch_v4_20260912/plan.json). + +## Rigid-assembly prior and planar-contact reference + +The offline `RigidAssemblyPrior` maps normalized free coordinates to one coherent assembly, including original weld frames and correlated body motion. +Its geometry consists of fixed local body poses and enclosing collision radii inside a declared obstacle-free cell. +A full scene prior must justify those inputs and specify uncertain geometry, attachment alternatives, robot state, and component probabilities. +The implementation does not infer them from hidden recording metadata or plug noisy observed poses into prior bounds. +See [the component contract](assembly-prior.md). + +| Component | Continuous coordinates | Explicit construction | +| --- | ---: | --- | +| Free/rest | 6 | Uniform root xyz and uniform SO(3) orientation; zero twist | +| Free/moving | 12 | Free/rest pose distribution plus bounded root linear and angular motion | +| Planar support/rest | 3 | Uniform xy and yaw; exact height from a declared lowest support face; zero twist | + +The components are separately normalized distributions, not a mixture with unspecified implicit weights. +Root placement bounds conservatively contain every orientation permitted by the component, and nonoverlapping enclosing spheres guarantee internal separation under the declared geometry. +The tabletop case is a separate lower-dimensional contact distribution, not the result of projecting unconstrained position samples onto a table. +Child velocities include `omega cross offset`, which is needed for instantaneous compatibility with the weld. +The mechanical reference found up to 0.03851 m/s of attachment-velocity disagreement if one instead copied the parent's linear velocity to the child. + +Job `22628947` used a generated 6 cm cube and an attached 2 cm-radius sphere at a fixed 12 cm root-frame offset. +It loaded no evaluator task or historical hidden state. +For each component and seeds 0 through 3, it simulated 240 gravity steps in a new PyBullet world, repeated the trajectory in another fresh world, then reconstructed the full 120-step prefix before returning the suffix in a third world. +The declared checks required no initial penetration beyond 1e-12 m engine geometry roundoff, actual plane contacts, finite states, and repeat/prefix differences no greater than 1e-12. +The geometry roundoff threshold is an audit tolerance, not a softened observation likelihood. +The twelve trials used 8,640 simulator steps. + +| Component | Mechanical trials passing | Largest repeat/prefix feature difference | Largest weld position deflection during simulation | +| --- | ---: | ---: | ---: | +| Free/rest | 4/4 | 0 | 2.696 mm | +| Free/moving | 4/4 | 0 | 3.414 mm | +| Planar support/rest | 4/4 | 0 | 0.001027 mm | + +The finite-force weld deflections are retained as simulation behavior, not treated as failed reconstruction or added sensor noise. +The prior establishes compatible geometry and velocity at initialization; it does not turn the engine's weld solver into a perfectly rigid constraint. +The initial supported face also does not certify static balance for arbitrary masses or geometry. +These generated trials validate a physical component and repeatability, not the full historical balloons task, a posterior fit, or agent solve performance. + +Final validation `22628938` passed 21 functional tests, two-file mypy and configured lint, and pinned isort, yapf, and docformatter checks. +The tests include actual engine separation and contact distances, independently composed weld frames, a finite-difference rigid-motion check, rotational isotropy, declared support rejection, and the existing replay suite. +Initial validation `22628904` passed its 20 functional tests but found a tuple annotation too narrow for both coordinate dimensions. +Static follow-up `22628919` passed after annotating the variable-length tuple and explicitly discarding the validation property's return value. +The subsequent supported component adds one functional test and is covered by the final 21-test job. +The earlier eight free-assembly reference trials in `22628914` also passed and remain separate, overlapping evidence rather than eight additional final cases. +All validation and simulation ran on `mit_preemptable` compute nodes. + +Artifacts: [final source plan](../../logs/uncertainty_assembly_v3_20260912/plan.json), [predeclared mechanical checks](../../logs/uncertainty_assembly_v3_20260912/reference-plan.json), [per-trial physical results](../../logs/uncertainty_assembly_v3_20260912/reference-22628947.json), [functional checks](../../logs/uncertainty_assembly_v3_20260912/checks-22628938.xml). + +## Robot-prior conditioning and hidden-joint geometry + +`JointStatePrior` requires explicit position support and velocity distributions for every movable joint, plus a mechanically fixed designation for joints with no freedom. +Exact initial-position conditioning retains the original uniform density of each eliminated coordinate and does not condition away unobserved velocities. +A fully determined conditional has no artificial free interval and still exposes its observation factor. +Out-of-support exact readings raise a distinct prior-support contradiction instead of being clipped, wrapped, or softened. +See [the robot-state contract](robot-state-prior.md). + +The reference uses the five previously verified public initial joint readings and checks the matching Fetch URDF hash in each current visible simulator. +The declared trial position priors use URDF intervals for limited joints and `[-4*pi, 4*pi]` for continuous joints. +The latter is an explicit finite winding prior, not a mechanical limit. +The rest component fixes all initial velocities to zero; the moving component uses independent uniform velocities with half-width 0.25 in each joint's coordinate units per second. +These are modeling assumptions for this component audit, not calibrated task priors or measured reset velocities. +The two components have no implicit mixture probabilities. + +Final reference job `22629290` reports: + +| Domain | Initial-position support | Rest / moving free dimensions | Sampled joint states restored | Largest joint restoration difference | +| --- | --- | --- | ---: | ---: | +| Bridge | Compatible | 4 / 17 | 64 | 0 | +| Fan | Compatible | 4 / 17 | 64 | 0 | +| Domino | Compatible | 4 / 17 | 64 | 0 | +| Boil | Compatible | 4 / 17 | 64 | 0 | +| Original balloons | Incompatible with this bounded prior | No conditional samples | 0 | Not evaluated | + +Each compatible component used 32 generated joint states, totaling 256 across the four domains. +Every exact controlled initial position and every generated position/velocity was restored exactly by the engine. +Changing the unobserved initial joint positions and velocities left the instantaneous public robot features unchanged in these checks. +That statement concerns initial kinematics only, not subsequent dynamics or likelihood invariance. + +The balloons shoulder-lift reading is `-1.5119263197144368` rad while its URDF interval is `[-1.221, 1.518]` rad. +Both trial components therefore assign zero support to that initial reading, independently of sampling budget. +The initial reference `22629166` stopped on this exception after capturing the four compatible domains. +The final report catches this specific outcome and continues the geometry audit with the original bounds unchanged. +It does not convert arbitrary setup exceptions into statistical failures or manufacture a balloons conditional. +A justified initialization law still needs to address this recorded reset state. + +The separate geometry check keeps observed arm/gripper positions fixed and compares head pan/tilt settings `(-1.2, -0.5)` and `(1.2, 1.0)` rad. +It searches a predeclared 9-by-9-by-9 grid over the union of their head bounding boxes using a 1 cm-radius sphere. +All five visible models produce identical public robot features but a collision witness: the probe is about 5.62 mm inside the first head collision geometry and 210.05 mm separated from the second. +The probe is intentionally a signed-distance query, not an admissible penetrating scene used for inference or evidence of contact in the historical recordings. +This rules out using arm forward kinematics alone to justify discarding unobserved head state. +A complete reduction would also have to establish that the entire declared scene/action support and the learned program cannot depend on those joints. + +Validation `22629135` passed 25 functional tests and two-file mypy, then reported four missing test docstrings in lint. +Final static job `22629186` passed mypy, both configured lint checks, and pinned formatting after adding those docstrings without changing logic. +The functional suite includes four new joint-prior tests and the existing exact-conditioning and physical-replay suites. +All validation and visible-engine audits ran on `mit_preemptable` compute nodes. +These are initial-state component checks, not agent seeds, posterior fits, or full-scene feasibility certificates. + +Artifacts: [source and validation plan](../../logs/uncertainty_joints_v2_20260912/plan.json), [reference assumptions](../../logs/uncertainty_joints_v2_20260912/reference-plan.json), [per-domain component and geometry report](../../logs/uncertainty_joints_v2_20260912/reference-22629290.json), [functional checks](../../logs/uncertainty_joints_20260912/checks-22629135.xml). + +## Reset law and composed scene support + +This increment adds `GaussianJointPosition` and generalizes the offline joint prior's field to `position_priors` with a new schema identity. +Finite uniform bounds remain supported, and their prior-specific rejection of the original balloons start remains visible. +The Gaussian alternative declares zero mean and standard deviation pi radians for revolute coordinates or 0.1 m for prismatic coordinates. +Those settings are engineering assumptions for this development reference, not estimates fitted to individual readings or independent evidence of calibration. +Exact joint measurements retain their original Gaussian density; neither angles nor observations are clipped or wrapped. +The actual robot wrapper reproduces the public initial joint vector exactly in all five domains, including the balloons shoulder outside its URDF interval. +The wrapper and vanilla IK source show why ideal joint-limit support is not a guarantee of every simulator reset. + +`draw_feasible` samples a complete normalized base candidate, including its mixture case, and rejects the whole draw when its support predicate fails. +The resulting prior is proportional to the base prior times the constraint indicator. +It returns an explicit exhausted-budget outcome rather than a partial batch presented as complete or a claim of impossible support. +It does not supply an exact normalizer or model evidence; parameter-dependent normalization remains the caller's responsibility. +The numerical references check a triangular joint constraint and feasibility-induced changes in mixture case probabilities. + +The physical reference combines the five recorded initial robot joint vectors with generated rigid cube/sphere assemblies in a declared cell, using equally weighted rest and moving joint/assembly cases. +It does not load the historical object layout or fit any dynamics. +The first geometry audit, `22629679`, checked sampled bodies against the robot and all existing geometry, and completed all ten requested batches. +That policy did not check robot/background intersections and is retained as a narrower reference. + +The stricter audit `22629703` added robot/background checks and accepted zero candidates in all ten trials, each exhausting its 128-draw budget. +Finite rejection alone would not establish empty support. +Contact diagnostic `22629779` identified the same two wheel/plane intersections in all five visible models. +Source inspection supplies the geometric explanation: each spherical wheel collision shape has radius 0.065 m and center height 0.055325 m under the fixed base. +Its floor signed distance is therefore -0.009675 m for every wheel angle. +This makes the strict no-overlap predicate incompatible with the supplied anchored fixture geometry. +The failure was not addressed by increasing the sample count, changing geometry, or softening sensor observations. + +The final declared predicate permits only those two named wheel/plane fixture contacts at their source-established signed distance, checked within the 1e-9 m geometric roundoff policy. +Any changed fixture distance raises an error; every other queried robot/background or sampled-body intersection remains a rejection. +The support identity includes that explicit contact rule and the reviewed source hashes. +The policy is conservative about queried collision geometry and does not add robot self-collision to the existing model. + +Final reference `22629815` uses the standard-library Gaussian inverse CDF and runs two fixed seeds per domain, each requesting eight accepted generated scenes with a maximum of 128 complete draws. + +| Domain | Exact wrapper-reset error | Complete draws | Accepted generated scenes | Collision rejections | +| --- | ---: | ---: | ---: | ---: | +| Bridge | 0 | 16 | 16 | 0 | +| Fan | 0 | 17 | 16 | 1 | +| Domino | 0 | 16 | 16 | 0 | +| Boil | 0 | 16 | 16 | 0 | +| Original balloons | 0 | 17 | 16 | 1 | +| Total | 0 | 82 | 80 | 2 | + +Every accepted sample passed the same geometry predicate when checked again in reverse batch order. +This is same-world geometry rechecking, not a new claim of complete runtime closure or long-trajectory replay. +The joint components retain 4 free position coordinates at declared rest or 17 position/velocity coordinates in the moving case; the associated assembly contributes 6 or 12 coordinates respectively. +These counts describe the generated reference and its stated shared motion case, not a finalized historical task prior. +No posterior fit, agent solve-rate seed, or historical full-scene comparison was produced by this audit. + +Final validation `22629786` passed 35 functional tests, four-file mypy and configured lint, and pinned isort, yapf, and docformatter checks. +The functional set covers six joint-prior tests, three global-rejection tests, five assembly tests, five affine-conditioning tests, and sixteen physical-replay tests. +Initial validation `22629663` passed the same functional set but found a missing generic list annotation. +Static follow-up `22629702` passed mypy but its lint could not resolve SciPy's dynamically exposed inverse CDF. +The final implementation uses the standard-library equivalent and reruns the functional checks with that implementation. +All tests and engine audits ran on `mit_preemptable` compute nodes. + +Artifacts: [final source plan](../../logs/uncertainty_scene_prior_v3_20260912/plan.json), [declared reference and fixture policy](../../logs/uncertainty_scene_prior_v3_20260912/reference-plan.json), [final per-domain results](../../logs/uncertainty_scene_prior_v3_20260912/reference-22629815.json), [strict-policy rejection report](../../logs/uncertainty_scene_prior_v2_20260912/reference-22629703.json), [contact witnesses](../../logs/uncertainty_scene_prior_v2_20260912/contacts-22629779.json), [functional checks](../../logs/uncertainty_scene_prior_v3_20260912/checks-22629786.xml). + +## Feasible scene weights in batch inference + +`FeasibleConditioning` connects a declared support predicate to the existing exact-conditioning and tempered-sampling interfaces. +It distinguishes a globally constrained joint prior from a state prior normalized separately for each parameter value. +The latter retains the intended parameter marginal by including the original support probability `Z(theta)` in every base weight. +Exact-observation and proposal-density corrections remain in that weight, and the remaining noisy likelihood enters once afterward. +The distinction and analytic derivation are documented in [scene-prior composition](scene-prior-composition.md#connecting-feasible-scenes-to-parameter-inference). + +Numerical reference `22630419` evaluates both laws on the same support and exact observation, using eight seeds from 100 through 107 at each of two particle counts. +Before submission, the plan fixed 12 temperatures, three moves per temperature, a maximum of 100,000 target evaluations per trial, and accuracy thresholds of 0.06 for the parameter mean, 0.09 for the uninformed coordinate mean, and 0.13 for each parameter quantile at probabilities 0.05, 0.5, and 0.95. +Every completed fit must also preserve the exact constraint to floating-point residual at most 1e-15. +These criteria check this known numerical reference; they are not general posterior-calibration or deployment thresholds. + +| Prior law | Particles | Trials meeting all criteria | Largest parameter-mean error | +| --- | ---: | ---: | ---: | +| Global joint conditioning | 512 | 7/8 | 0.07232 | +| Global joint conditioning | 2,048 | 8/8 | 0.02107 | +| Conditional state normalization | 512 | 8/8 | 0.05651 | +| Conditional state normalization | 2,048 | 8/8 | 0.02851 | + +All 32 numerical fits reached their final temperature, but reaching that temperature alone does not satisfy the accuracy criteria. +The smaller global-joint trial with seed 107 returned mean 2.23636 against the analytic mean 2.16404. +Its minimum effective sample size was approximately 441 out of 512, and all 512 original ancestors survived, so those diagnostics did not expose the mean and median error by themselves. +The failed trial remains part of the report, without a replacement seed or adjusted threshold. +The two larger-budget groups pass all sixteen trials. +The experiment used 1,392,329 target evaluations in total; these were algebraic reference evaluations, not simulator steps or agent actions. + +The functional checks also include an exact observation that excludes part of the parameter interval, combined with a noisy reading and an independent quadrature reference. +They verify that invalid normalizers and callback failures propagate, rejected candidates cannot become posterior samples, and support callbacks cannot mutate shared candidate arrays. +Initial validation `22630373` passed 22 functional tests and then found two test callbacks without types that mypy could infer. +The corrected test snapshot adds explicit callback annotations and pinned formatting; its implementation module is identical to the completed numerical reference. +Final validation `22630449` passed all 22 functional tests, two-file dependency-following mypy, two configured lint checks, and pinned isort, yapf, and docformatter checks. +Both final validation and the numerical experiment completed with exit status zero on `mit_preemptable` compute nodes. +This is focused validation, not a full repository CI run. + +These results establish the support-weight composition on the stated reference problems. +They do not provide historical scene layouts, attachment-case probabilities, unknown parameter-dependent normalizers, or a conditional representation for exact contact trajectories. +The production estimator and historical experiment runtime remain unchanged. + +Artifacts: [predeclared reference plan](../../logs/uncertainty_feasible_batch_v2_20260912/plan.json), [all numerical trials](../../logs/uncertainty_feasible_batch_v2_20260912/reference-22630419.json), [final check snapshot](../../logs/uncertainty_feasible_batch_v3_20260912/plan.json), [functional checks](../../logs/uncertainty_feasible_batch_v3_20260912/checks-22630449.xml). + +## Recorded scene initialization and Balloons contact constraints + +The public candidate-map audit `22631317` uses only projected public observations, fresh visible-model body handles, and the full reset path. +All five domains reproduce every exact initial field. +It probes all 177 noisy initial coordinates in both directions, totaling 354 perturbations. +The results expose sixteen fixed Fan pose outputs, two reset/derived Boil scalar outputs, and coupled canonical Euler outputs near Bridge's pitch pole. +Those are properties of the visible initialization map; they do not by themselves identify a learned program's complete prior or establish geometric feasibility. +Initial setup `22630947` imported `PyBulletState` from the wrong module and performed no audit. +Follow-up `22630961` used an unnecessary float32 cast and missed Domino's component-owned handles; `22631317` corrects both without changing production or recorded data. + +The new Gaussian-coordinate conditioning helper supplies exact Gaussian initial-position proposals, together with their original marginal observation density. +It supports exact coordinates by elimination, retains evidence under sequential independent readings, and rejects numerical overflow rather than manufacturing zero support or a small sensor variance. +Validation `22631716` passed seventeen functional tests, two-file dependency-following mypy, two configured lint checks, and pinned formatting. +The initial check job `22631701` named a nonexistent test file and ran no tests; that setup failure is separate from the completed validation. + +The [Balloons initial-scene reference](balloons-initial-scene.md) supplies a declared original free-pose scene law with 42 through 73 continuous dimensions across sixteen motion cases after initial conditioning. +It conditions translations through the Gaussian helper, handles clip yaw with its truncated angular likelihood, preserves omitted orientations, and conditions exact initial joints and box speed. +Its support policy is explicit about permitted static-fixture overlaps and the visible wall-box-only chute rule. +All body handles and hidden candidate quantities come from the model and prior, not evaluator recording metadata. + +The first attempt `22631523` called the visible rack-placement helper by the wrong name and sampled no scenes. +The next attempt `22631543` correctly refused the constructor-only wheel-contact assumption after the full robot reset changed the base frame. +The URDF inertial offset gives the corrected, source-derived reset contact distance of -11.075 mm, compared with -9.675 mm immediately after construction. +The previous generated-component reference remains valid for its own constructor-only protocol; it does not certify the full-reset geometry. +Reference `22631584` verifies the corrected geometry with inline conjugate conditioning, and final `22631706` repeats the same experiment using the checked Gaussian helper. + +| Sampling seed | Complete candidate draws | Accepted roots | Exact initial observations reproduced | Repeated 16-action replay identical | +| --- | ---: | ---: | ---: | ---: | +| 0 | 1,242 | 8 | 8/8 | 8/8 | +| 1 | 1,715 | 8 | 8/8 | 8/8 | + +These are conditional initial-state samples, not agent seeds or dynamics-parameter posteriors. +Each accepted root was replayed twice in fresh worlds, totaling 512 model action steps in the final reference. +Every root still contradicts at least one later exact output at action one, starting with box speed or moving robot joints. +Those failures remain visible instead of being discarded to obtain a successful full-target fit. + +The separate support diagnostic `22631750` evaluates 84 one-action predictions over seven box-height offsets, three masses, and four native damping values. +The upright box at nominal public xy and table support height is admissible and reduces the first-speed discrepancy to less than `2.63e-11 m/s` for one tested setting, with exact first-step public joints. +Below-table controls remain explicitly inadmissible. +This motivates a supported prior component, not a change to the original free-pose law or an observation tolerance. + +Constraint diagnostic `22631837` scans native damping over its visible `[0.01, 40]` range at three fixed masses. +The restricted supported component has a near-equality candidate around 2.2, but two other sign-changing brackets terminate at discontinuities and fail equality. +The latter's finite-difference slopes scale inversely with the step size, so their displayed inverse-slope factors are diagnostic arithmetic, not valid conditional weights. +Even the low-damping candidate retains later exact-speed discrepancies of approximately `1e-4 m/s` within the sixteen-action prefix. +A complete conditional representation must account for supported/free cases, uncertain geometry, all relevant branches, numerical conditioning, and later observations before a physical posterior comparison is available. +None of these numerical candidates was published to an acting agent. + +Every audit, model replay, and functional/static validation ran on `mit_preemptable` compute nodes. +The completed reference and check jobs exited with status zero, with their statistical and model failures retained in their reports. +Full repository CI and live estimator comparisons were not run in this increment. + +Artifacts: [five-domain map audit](../../logs/uncertainty_scene_map_v3_20260912/reference-22631317.json), [Gaussian source snapshot](../../logs/uncertainty_gaussian_coordinate_v2_20260912/plan.json), [Gaussian functional checks](../../logs/uncertainty_gaussian_coordinate_v2_20260912/checks-22631716.xml), [final declared root law](../../logs/uncertainty_balloons_root_v4_20260912/plan.json), [root samples and replay](../../logs/uncertainty_balloons_root_v4_20260912/reference-22631706.json), [supported-box control](../../logs/uncertainty_balloons_support_20260912/reference-22631750.json), [scalar constraint diagnostic](../../logs/uncertainty_balloons_constraint_20260912/reference-22631837.json). diff --git a/docs/uncertainty/fan-articulated-prior.md b/docs/uncertainty/fan-articulated-prior.md new file mode 100644 index 000000000..393318e56 --- /dev/null +++ b/docs/uncertainty/fan-articulated-prior.md @@ -0,0 +1,78 @@ +# Fan articulated initial-state prior + +September 12, 2026. +This offline component advances the Fan row of the [initial-state inventory](initial-state-inventory.md). +It does not establish a complete scene prior or replace production inference. + +## Switch state from a Boolean reading + +The visible Fan simulator has four prismatic switches. +Their positions are distances, not angles. +An exact `is_on` reading constrains which side of a threshold the slider occupies; it does not reveal its position or velocity. + +The audited runtime reports nominal URDF travel from 0 to 296 mm through `getJointInfo`. +The [travel-cap helper](../../predicators/pybullet_helpers/objects.py) enforces an upper stop at 29.6 mm without changing those reported limits. +The public controller's off/on poses are 0 and 29.6 mm, and its on threshold is 14.8 mm. +The proposed component uses the enforced 0 to 29.6 mm interval as declared initial support. +This is an initialization assumption; arbitrary `resetJointState` calls can bypass mechanical limits, so the nominal metadata or cap alone is not proof that every archived initialization lies in this interval. + +[RestingJointPrior](../../predicators/code_sim_learning/inference_joints.py) specifies a normalized distribution with total mass rho divided equally between two declared controller rest poses, each with zero velocity. +With probability 1-rho, position is uniform on the declared travel interval and velocity is independently uniform on a declared symmetric interval. +The generic component also permits controller rest poses inside the travel interval. +Neither a Boolean flag nor missing velocity metadata determines rho or the velocity width. + +For a reading y indicating q > c, conditioning retains the compatible rest atoms and restricts the continuous interval to the compatible side of c. +The observation probability is the sum of compatible atom mass and the moving mass multiplied by the compatible fraction of travel. +`log_observation_factor` retains this probability in the joint inference target, including when c depends on an unknown parameter. +The conditional mixture weights are divided by that same observation probability. +No observed position is fabricated, and no later trajectory output is overwritten. + +Each resting case has zero physical continuous dimensions; each moving case has two, position and velocity. +Two unit sampling coordinates represent the mixture, with unused auxiliary coordinates in resting cases. +For the four switches with endpoint rest poses and interior cuts, this gives 16 rest/moving combinations and 0 to 8 physical continuous coordinates. +These counts exclude scene placement, robot state, ball motion and rotor state. + +## Native validation + +The [corrected audit report](../../logs/uncertainty_fan_joint_prior_v2_20260912/pilot-22639208_1.json) records 2,048 sampled switch states: four switches, both flags and 256 draws per case. +All native Boolean readings and restored position/velocity pairs match the conditional samples exactly. +This development audit declares rho = 0.8 and moving velocities uniform on [-0.1, 0.1] m/s; those values are engineering choices, not fitted or calibrated conclusions. + +An isolated control drives the same switch asset and scale toward 236.8 mm for 1,200 native simulation steps. +Without the cap, the slider reaches 236.8 mm; with the cap, it stops at 29.6 mm. +Both worlds continue to report the same nominal URDF range. +The 0.01 mm settling tolerance tests the mechanical control; it is not an observation-noise or likelihood tolerance. + +The earlier audit `22639121_1` completed its 2,048 readback checks but failed its isolated control assertion because that control used the object helper's default scale of 0.2 instead of Fan's scale of 1.0. +Its report is retained as a diagnostic setup failure. +The corrected audit uses the environment scale explicitly and checks the uncapped target without a scale-dependent offset. +Neither audit is an agent performance result. + +Compute job `22638962` passed 15 functional tests, focused mypy and pylint, and pinned formatting checks for the new component and its integration references. +The tests compare conditional moments and a parameter-dependent threshold posterior against analytic answers, including pure-rest, pure-motion, incompatible and interior-rest cases. +The checked source hashes match the native audit overlays. + +## Rotor component and remaining scene state + +The [native inventory](../../logs/uncertainty_fan_articulation_20260912/pilot-22638602_1.json) distinguishes four public fan-bank objects from twenty physical fan bodies, five per bank. +Each physical fan has a continuous rotor joint. +The visible placement routine resets fan bases but does not reset those rotor joints. +The rotor link in the supplied URDF has ten collision elements, so it cannot be eliminated merely by calling it decorative. + +The [replay follow-up](articulated-replay.md) found that the offline snapshot omitted all nonrobot joints, losing the supplied states of all twenty rotors and four switches even in repeatable fresh-world replays. +The corrected snapshot includes these articulated states and verifies their native layout before restoring them. + +The [rotor component audit](../../logs/uncertainty_fan_rotor_prior_20260912/pilot-22639509_1.json) declares independent uniform initial rotor positions on [-pi, pi] radians and velocities on [-2pi, 2pi] radians per second using the existing `JointStatePrior` implementation. +This is a finite-winding engineering initialization law, not a mechanical limit, an angle-wrapping equivalence or a calibrated distribution. +The twenty movable joints contribute forty continuous coordinates; fixed joints contribute none. +Together with the four conditional switches, the articulated portion has 40 to 48 continuous coordinates across sixteen rest/moving switch combinations. + +Eight sampled articulated candidates preserve every supplied joint state and the original public initial switch flags after restoration. +Each repeats sixteen recorded actions in two fresh worlds with exact equality of the native joint states and projected public observations at all boundaries. +The sampled cases have 40, 42 or 44 continuous articulated coordinates; the experiment does not claim to enumerate all sixteen combinations. +The four symmetric switch readings contribute a retained log probability of -2.7725887222397807 in each trial, rather than being silently treated as exogenous inputs. + +These tests hold the remaining scene and robot root at controlled public-frame inputs; they do not turn those noisy inputs into a valid full-scene prior. +The [whole-scene follow-up](fan-initial-scene.md) now declares fixture placement, box orientation cases, robot/ball components and an explicit geometric support policy, with 82 to 112 continuous initial-state coordinates. +It retains these articulated components and validates repeated whole-scene candidate trajectories. +Sensitivity assessment, calibrated scene assumptions and a usable complete-recording posterior remain unresolved. diff --git a/docs/uncertainty/fan-initial-scene.md b/docs/uncertainty/fan-initial-scene.md new file mode 100644 index 000000000..6c6004981 --- /dev/null +++ b/docs/uncertainty/fan-initial-scene.md @@ -0,0 +1,132 @@ +# Fan initial scene and complete-recording preflight + +September 12, 2026. +This development experiment composes the Fan components into a declared root-state law for the [uncertainty simplification plan](simplification-proposal.md). +It is an offline model and support investigation, not a fitted posterior or a production replacement. + +## Inputs and original law + +The experiment uses the previously frozen first Fan training level: 132 actions, 133 public noisy observations and the latest saved cycle-000 simulator program. +The program and recording hashes remain in the [experiment manifest](../../logs/uncertainty_fan_full_scene_v2_20260912/plan.json). +No task generator, privileged recording velocities or evaluator reset state initializes a candidate. +The unchanged dynamics program has one parameter, `fan_speed`, with the explicitly declared uniform original prior on [0, 1] described in the [provenance audit](offline-fitter-comparison.md#fan-prior-provenance). +The scene law below is independent of that airflow parameter. + +| Quantity | Declared original distribution or input | Continuous dimensions after initial conditioning | +| --- | --- | ---: | +| Robot positions | Independent zero-mean Gaussian reset coordinates, standard deviation pi for revolute joints and 0.1 m for prismatic joints; condition nine controlled positions on their exact readings and retain their density | 4 | +| Robot motion | Probability 0.8 on all joints resting; otherwise independent uniform velocities on [-0.1, 0.1] in each joint's units per second | 0 or 13 | +| Ball supported component | Probability 0.8; uniform workspace xy eroded by radius, uniform yaw, height at table plus radius, zero twist | 3 | +| Ball free component | Probability 0.2; uniform eroded workspace xyz, Haar orientation, independent uniform linear velocities on [-0.1, 0.1] m/s and angular velocities on [-0.2, 0.2] rad/s | 12 | +| Ten fixed fixture positions | Independent uniform xyz, with x/y bounds equal to the public workspace expanded by 0.3 m and z in [0, z_ub]; zero base velocity under the fixed mounting law | 30 | +| Four switch bases and target yaw | Independent uniform angles on [-pi, pi] | 5 | +| Four boundaries and one obstacle yaw | Independent uniform mass on four quarter turns; boundary body dimensions are derived from their exact world extents and the selected rotation | 0, with 4^5 discrete cases | +| Fan-bank bases | Derived from the visible placement routine, with noisy fan-pose observations retained in the likelihood | 0 | +| Twenty rotor joints | Independent uniform finite-winding positions on [-pi, pi] and velocities on [-2pi, 2pi] rad/s | 40 | +| Four switch sliders | The [conditioned rest/motion law](fan-articulated-prior.md), with rest mass 0.8 and moving velocity half-width 0.1 m/s; retain the probability of each exact initial switch flag | 0 to 8 | +| Attachments and model memory | No initial attachment or queued command under this declared root protocol; the frozen program has no declared memory; later commands evolve in replay | 0 | + +This defines 82 to 112 continuous initial-state coordinates, or 83 to 113 jointly with `fan_speed`. +The two robot-motion cases, two ball cases, sixteen conditional switch-motion cases and 1,024 fixture-orientation cases give 65,536 combinations before geometric support and other observation constraints. +These are representation counts, not a claim that every case retains positive posterior mass or that the sampler has explored them. +The ball's unobserved orientation is deliberately retained here; no unverified spherical-symmetry reduction is used. +Hyperparameters and the independent fixture law are engineering assumptions, not estimates of the task generator's calibrated distribution. + +## Geometry and conditioning + +Quarter turns preserve the visible box reconstruction's relation between body-frame dimensions and exact world-axis-aligned extents. +The obstacle wall has its source-defined physical dimensions, while boundary dimensions come from the supplied exact descriptors. +The original noisy positions and rotations are likelihood evidence, not fixed geometry. + +The mounting model permits overlapping mass-zero fixtures to form a fixed compound geometry. +It does not treat them as free rigid bodies that should separate under contact forces. +The feasibility gate rejects a whole candidate if the robot or ball penetrates any other native body beyond the declared 1e-7 m geometry roundoff. +The supplied Fetch wheel/floor fixture intersections are the sole pair-specific exception and must equal the previously audited -0.011075 m distance under this initialization protocol. +This is the stated simulator support policy, not certification of arbitrary physical mounting arrangements or every possible robot self-contact model. + +Airflow speed does not affect these initial collision shapes or the placement protocol. +Consequently the whole-scene support normalizer is independent of that parameter and cancels within this fixed posterior target. +The gate rejects the entire draw, including its motion and orientation cases; it does not normalize each case separately. +Acceptance rates from observation-informed proposals are not estimates of the original prior's support normalizer. + +The proposal draws continuous positions and non-quarter fixture yaw from truncated Gaussians around the initial noisy readings, restricted to the declared original support. +Every such coordinate retains `log p0 - log q`. +Quarter-turn proposals mix 98% of the angle-likelihood-weighted categorical distribution with 2% of the original uniform distribution. +This keeps all four cases reachable even when exponentiating a very small likelihood would otherwise underflow to zero. +Their original masses and proposal probabilities receive the same correction. +The first scene prototype lacked that categorical mixture and remains a geometric diagnostic, not a validated posterior sampler. +No initial measurement is removed from the complete likelihood to compensate for proposal use. + +The candidate is initialized from sampled values, source-derived fan placements and exact descriptors before action replay. +All native robot, slider and rotor positions and velocities are explicitly installed, and full ball orientation and motion are preserved. +Recorded actions then run uninterrupted in the candidate simulator. +The likelihood uses the previously declared [complete output-discrepancy composition](orientation-discrepancy.md), including its retained exact event constraints; it does not add Boolean sensor noise to force acceptance. + +## Native results and limits + +The corrected scene preflight `22640582_0` accepts eight scenes from 23 proposals, rejecting fifteen for the declared geometry policy. +Every accepted scene exactly repeats its 32-action continuation in a fresh second world, including robot joints, articulated joints and projected observations at all 33 boundaries. +Seven candidates have finite complete-output likelihood; one violates a later exact event. +These eight cases do not establish prior calibration or usable posterior uncertainty. + +The earlier eight saved physical candidates were also replayed for all 132 training actions in job `22640308_0`. +All initial predictions and first-32-action likelihoods match their saved counterparts, and both full continuations repeat exactly. +However, none has finite full-recording likelihood: seven first disagree on switch timing and one first disagrees on target-hit timing. +This is why a positive short-prefix result cannot close the recorded-prediction gate. + +| Saved draw | First exact-event disagreement | Step | +| --- | --- | ---: | +| 1 | Fan 2 stays on after the observed off transition | 70 | +| 4 | Fan 2 is off when the observation is on | 17 | +| 6 | Fan 2 stays on after the observed off transition | 70 | +| 8 | Fan 2 stays on after the observed off transition | 70 | +| 10 | Fan 0 is off when the observation is on | 98 | +| 13 | Target-hit prediction becomes true too early | 108 | +| 19 | Fan 0 is off when the observation is on | 98 | +| 22 | Fan 2 stays on after the observed off transition | 70 | + +The follow-up [parameter support scan](../../logs/uncertainty_fan_event_support_20260912/plan.json) holds saved scene 13 fixed and probes airflow speed over the original [0, 1] support, including historical fitted values as numerical probes. +It uses the full training recording to search for event-compatible parameters; it is neither a parameter estimate nor a held-out prediction comparison. +A failed finite scan would not prove that the joint initial-state/parameter target has empty support. + +An interim scan at speed 0.09 moves the first disagreement to the final switch-off event at step 132, with two mismatched readings from the switch and its linked fan. +Thus the candidate's earlier target-first failure did not establish that every switch transition matched. +The [fixture-placement support probe](../../logs/uncertainty_fan_switch_support_20260912/plan.json) fixes speed at 0.09 and varies switch 0's initial xy by up to 2.5 mm within the unchanged uniform placement prior. +Its [scope addendum](../../logs/uncertainty_fan_switch_support_20260912/scope-audit.json) distinguishes the actual placement scan from an inherited description of the preceding speed scan. +It checks the complete 132-action history for each point and does not reuse the source proposal weight after modifying the scene. +These directed probes use the full training data and remain separate from held-out predictive evaluation and posterior sampling. + +Both scans finished without a finite full-recording likelihood: 104 speed probes and 121 xy probes over the original small placement window. +Every point in that placement grid retained the same two final switch/fan flag disagreements. +The subsequent [contact-margin diagnostic](../../logs/uncertainty_fan_switch_contact_20260912/pilot-22641898_0.json) varied individual pose coordinates more widely and inspected simulated slider positions as well as public event predictions. +At speed 0.09, the original candidate finishes with the slider at 23.3343 mm, above the 14.8 mm off/on threshold. +Moving its sampled switch base by +0.01 m in y places the final slider at 14.4729 mm and gives finite likelihood for all 133 observations. +The modified position remains inside the original placement support; neither readings, event likelihoods nor prior bounds were changed. +One of the seventeen contact probes supplies this positive witness. + +The [independent full-replay audit](../../logs/uncertainty_fan_full_support_witness_20260912/pilot-22642017_0.json) repeats the saved positive candidate and four small perturbations in fresh worlds. +Every pair of 132-action trajectories and likelihood values repeats exactly. + +| Perturbation from the positive witness | Full-recording log likelihood | Outcome | +| --- | ---: | --- | +| None; speed 0.09 | 34241.556154 | Finite | +| Switch y minus 0.0001 m | 34241.349688 | Finite | +| Switch y plus 0.0001 m | 34241.676276 | Finite | +| Speed minus 0.0001 | 34241.565911 | Finite | +| Speed plus 0.0001 | No finite likelihood | Exact-output disagreement | + +This establishes sampled full-recording support under the declared model and shows local sensitivity to both the physical parameter and initial placement. +It does not establish posterior weights, a credible interval, sufficient exploration of other cases or predictive adequacy on unused data. +These selected points must not be passed off as a posterior ensemble. +The next inference experiment must preserve the original law and account for any proposal informed by these support searches. + +## Runtime provenance + +The first scene preflight ran on node3504, not node1412 as incorrectly stated by inherited launcher metadata. +Its [runtime addendum](../../logs/uncertainty_fan_full_scene_20260912/runtime-audit.json) records the authoritative allocation without changing the raw worker or result artifacts. +Related earlier articulated diagnostics have corresponding addenda, linked from their own experiment directories. +The full-history audit records its actual node1390 Intel Xeon Gold 6230 runtime, and the corrected scene preflight records node1622 AMD EPYC 9654. +Both record Python, NumPy, SciPy, PyBullet API and hash-seed information at worker startup. +Within-job exact repeats remain measured evidence; identical random seeds across different CPUs are not claimed to generate identical physical candidates. + +Artifacts: [corrected scene report](../../logs/uncertainty_fan_full_scene_v2_20260912/pilot-22640582_0.json), [saved-scene full histories](../../logs/uncertainty_fan_full_history_20260912/pilot-22640308_0.json), and [initial scene prototype](../../logs/uncertainty_fan_full_scene_20260912/pilot-22640065_1.json). diff --git a/docs/uncertainty/fan-joint-inference.md b/docs/uncertainty/fan-joint-inference.md new file mode 100644 index 000000000..127b85909 --- /dev/null +++ b/docs/uncertainty/fan-joint-inference.md @@ -0,0 +1,143 @@ +# Fan joint-inference integration + +September 12, 2026. +This extends the [full Fan scene law](fan-initial-scene.md) into the offline batch sampler required by the [simplification proposal](simplification-proposal.md). +The production agent remains on the incumbent fitter. +These are fixed-program development experiments on the original 132-action training recording, not agent runs or held-out predictions. + +## A fixed coordinate map for every initial case + +The initializer now accepts a 120-dimensional unit vector instead of consuming a variable-length random stream. +Its physical prior, observation-informed proposal, initial observation factors and whole-scene geometric support policy remain the declared Fan model. +Each coordinate has a fixed role across rest/moving cases: + +| Slots, inclusive | Role | +| --- | --- | +| 0 | Airflow speed under the original Uniform[0, 1] prior | +| 1 | Robot rest/motion case | +| 2-18 | Free robot positions and potentially moving velocities | +| 19-58 | Ten fixtures, four coordinates each: xyz and yaw/categorical orientation | +| 59 | Ball supported-rest/free-motion case | +| 60-71 | Ball pose and potentially moving twist | +| 72-79 | Four switch conditional position-mixture and velocity pairs | +| 80-119 | Twenty rotor positions and velocities | + +Coordinates unused in a particular case remain normalized auxiliary uniforms. +The box dimension is therefore not the number of physical degrees of freedom in each scene. +The existing 83-113 active continuous dimensions, including airflow speed, and 65,536 discrete cases remain represented. +Exact controlled initial positions are still conditioned with their density retained. +No later observation is assigned directly into a simulator state. + +The inverse helper only constructs a proposal center from the previously found supported-rest-ball witness. +It does not restrict the forward map to that case or claim an inverse for every free-ball quaternion chart. + +Compute job `22643080_0` completed the native map audit. +The saved physical witness and its mapped reconstruction each have full-recording log likelihood 34241.55615373634. +Both repeat exactly in fresh worlds, including public observations, robot joints and nonrobot articulated state. +Four fresh unit draws also repeat exactly; three fail geometry and the fourth fails the full observation target. +Those outcomes are retained as failures to reach support, not evidence that the full target is impossible. +The [report](../../logs/uncertainty_fan_coordinate_map_20260912/pilot-22643080_0.json) records the complete coordinates, physical candidates and actual node1377 runtime. + +## Informed proposals without changing the prior + +Let `u` denote the original unit chart and `r0(u)` its existing original-prior/proposal correction, including exact initial joint and switch evidence. +The new proposal draws `u` from a mixture with weight 0.1 on the entire original unit cube and weights 0.45 each on nearby and wider components around the support witness. +The wider component has ten times the nearby standard deviations. +These proposal choices use the complete training recording; they are not new prior information or held-out evidence. + +Each local component uses normalized truncated Gaussian laws on 57 active chart coordinates. +Matching discrete-case selectors use uniform laws over their corresponding chart intervals. +Unused auxiliaries, supported-ball yaw and all rotor states retain uniform proposal draws. +The broad component retains support for every original continuous region and discrete case, including other motion cases and fixture orientations. +It does not guarantee that a finite run actually visits those alternatives. + +The mixture density `q(u)` is evaluated with log-sum-exp across all three components, including components other than the one that generated the draw. +The corrected factor is `log r0(u) - log q(u)`. +Using only the selected component's density would define different weights and is not done here. +The original parameter and scene prior remains fixed. +The whole-scene support normalizer is still common and independent of airflow speed; no model-evidence estimate is claimed. + +Compute job `22643174_0` completed a stratified proposal-overlap audit with eight draws per component. +The component sample counts are diagnostic allocations, not mixture samples to pool as an unweighted posterior. + +| Component | Geometry-feasible draws | Finite complete targets | +| --- | --- | --- | +| Broad unit cube | 2/8 | 0/8 | +| Nearby | 8/8 | 5/8 | +| Wider | 6/8 | 3/8 | + +All three repeated first-draw trajectories match exactly, including the failing broad case. +Checks cover truncated-law CDF/quantile round trips, positive interval lengths and the broad component's density lower bound. +The largest CDF round-trip error is below 2.2e-13. +The [report](../../logs/uncertainty_fan_joint_proposal_20260912/pilot-22643174_0.json) retains every candidate and its full mixture-density correction. +This establishes usable proposal overlap for an integration experiment; it does not establish posterior adequacy. + +## Submitted full-recording inference + +Array `22643258` runs independent seeds 200 and 201 on `mit_preemptable`, pinned to node1412 with the same CPU and Python hash seed. +Each uses 64 particles, 32 cubic temperatures, eight Metropolis moves per temperature and a maximum of 16,448 target evaluations. +The eight-hour wall limit is an external compute cap, not a declaration of convergence. +Fresh-world 132-action replay occurs for each candidate, including candidates that change initial geometry or motion. + +The sampler's proposal space has one additional mixture-selector coordinate. +Its returned joint rows contain airflow speed and the original 119 scene-chart coordinates, so downstream parameter projection uses `theta.fan_speed` explicitly. +Symmetric scalar-coordinate moves act in the 121-dimensional proposal space and include the complete density correction in their acceptance ratio. + +At temperature zero the target includes the original-prior/proposal correction, initial-output likelihood, geometric support and all complete-recording exact constraints. +The tempered term is the complete-output log likelihood minus the initial-output log likelihood. +At temperature one their sum is the declared complete target, with every observation entering once. +No exact event is softened by tempering or by a tolerance window. + +The [frozen manifest](../../logs/uncertainty_fan_joint_pilot_20260912/plan.json) identifies the historical runtime, offline overlays, program, source scripts, proposal, full training data and hashed prerequisite report. +Each worker first verifies a finite, exactly repeated mapped witness on its own runtime. +Sampler completion leaves numerical availability unevaluated. +Independent agreement, weight and ancestor concentration, parameter movement, budget/proposal sensitivity and predictive investigations remain necessary before returning a usable physical-domain posterior to an acting agent. + +## Rollout cost audit + +Both sampler tasks subsequently started on the declared node1412 and passed their own finite, exactly repeated witness checks. +Compute audit `22644679_0` compared full replay snapshots against direct public-observation collection on five saved physical candidates, including both finite and event-incompatible histories. +Every public prediction and complete likelihood matched exactly. +The [timing report](../../logs/uncertainty_fan_rollout_cost_20260912/pilot-22644679_0.json) alternates method order and clears the quaternion cache before each measurement. + +Snapshot collection took 1.13-1.70 seconds per 132-action history, versus 0.95-1.14 seconds for direct public observations. +Initialization took approximately 0.60 seconds, while cold likelihood evaluation took 3.62-3.71 seconds in either method. +Removing full snapshots therefore addresses only a small part of this measured cost; likelihood evaluation is the larger target for profiling. +These are cold-cache diagnostic measurements, not the amortized cost of the active samplers, which reuse their observation-factor cache. +The running frozen experiments were not modified by this audit. + +## Checkpoint recovery preparation + +After roughly two hours, each original worker had completed approximately 3,500 target evaluations. +That measured throughput projects beyond the eight-hour allocation for its fixed 16,448-evaluation budget. +An attempt to extend each running allocation to twelve hours was denied by Slurm, and both original jobs remain running with their original limits. +The request, denial and verified unchanged job state are recorded in `logs/uncertainty_fan_joint_pilot_20260912/scheduler-budget-extension.json`. +No sampler result or failure is inferred from that projection. + +A recovery worker is prepared in `logs/uncertainty_fan_checkpoint_recovery_20260912` with the existing checked stage-checkpoint implementation and the exact-density optimization. +It retains the original prior, data, numerical seeds, proposal, temperature schedule and evaluation budget. +Its future scheduler allocation is twelve hours; that larger external allowance must be reported separately from the original eight-hour attempts. +The frozen old workers cannot acquire checkpoints retroactively, so a recovery starts from the original prior unless a compatible new-worker checkpoint exists. +It never substitutes an old best candidate for the initial particle population. + +Array `22650616` checks the new worker's complete 64-particle initialization against each original run, including proposal component, geometry, full likelihood and conditional-base factor. +It also requires the full mapped-witness likelihood to match the original exactly and to repeat on the worker. +The checks save the complete initialized sampler state with its RNG, weights, counters and identity, then stop before tempering. +The worker identity includes the frozen source plus actual Python, NumPy, SciPy, PyBullet binary, CPU features, hash seed and node, so an incompatible checkpoint is rejected. +Both check tasks completed successfully. +Each matched all 64 original initial-particle records exactly, reproduced the original full witness likelihood `34241.55615373634`, and saved a stage-zero checkpoint after 64 evaluations. +The measured worker times were 158.62 and 158.50 seconds. +This establishes initialization parity; it does not establish posterior adequacy. + +Conditional recovery jobs `22650786_0` and `22650787_1` are queued behind failure dependencies on original tasks `22643258_0` and `22643258_1` respectively. +They resume the corresponding verified new-worker initialization checkpoints and retain numerical seeds 200 and 201. +A separate startup gate requires a successful current queue query showing the original absent and scheduler accounting affirmatively showing terminal failure. +It refuses an active or requeued original, successful completion, missing evidence or a failed query. +Nine controlled gate cases passed, and an end-to-end check against the actually running original correctly refused recovery. +Thus a scheduler dependency alone is not used as proof that replacement is safe to start. +Unused dependency jobs must be cancelled if their originals complete successfully. +The initially queued unguarded recovery submissions `22650769` and `22650770` were cancelled while pending and superseded by the guarded jobs before any sampler started. + +The recovery manifest, gate checks and authoritative scheduler evidence are in the same bundle. +Original jobs remain live and unchanged; no full replacement is running concurrently. +Original attempts, initialization checks and any eventual recovery all belong to the same two numerical seeds, not additional agent outcomes or independent posterior replications. diff --git a/docs/uncertainty/implementation-progress.md b/docs/uncertainty/implementation-progress.md new file mode 100644 index 000000000..2163030ba --- /dev/null +++ b/docs/uncertainty/implementation-progress.md @@ -0,0 +1,498 @@ +# Uncertainty simplification: implementation progress + +Updated September 12, 2026. +This tracks implementation of the [simplification proposal](simplification-proposal.md). +The incumbent estimator remains the production default. + +The new offline [stochastic future integration component](stochastic-future-integration.md) retains exact joint/speed density factors and reports Monte Carlo concentration explicitly. +Forty functional tests and focused static checks pass. +Its first native Balloons diagnostic reproduces the reference trajectory but fails all four numerical comparisons: eight and 64 complete paths remain dominated by a single contribution. +This is an unresolved integration problem, so these scores are not used to compare estimators or change agent behavior. +A native factor audit reproduces ten selected paths exactly and attributes their output-score variation to the box and attached balloon positions; robot factors remain invariant. + +The latest [likelihood cost reduction](likelihood-cost.md) preserves all 2,560 archived orientation densities and five complete Fan likelihoods exactly on the checked runtimes. +It removes array reductions from two-term quadrature sums, making the measured density evaluations about four times faster while retaining the statistical model and numerical acceptance checks. +Twenty-three functional tests and focused type/lint/format checks pass; running fits retain their existing frozen source. + +The offline sampler now has optional [continuation checkpoints](sampler-checkpoints.md) for long fits on preemptable nodes. +They preserve the complete weighted population, density factors, random state and diagnostics at stage boundaries, while keeping unfinished solver state separate from an assessed posterior. +Changed inference inputs or sampler settings reject a resume, and the cumulative numerical budget remains fixed. +The checkpoint restores numerical inference state; candidate simulations still reconstruct their full validated action prefix. +Compute validation passed forty functional tests, three-file type/lint/format checks, and thirty-two exact paired comparisons with the pre-change sampler. + +## Implemented boundary + +The rollout fitter exposes `SysIdOutcome.inference`, a version 1 `LegacyInferenceResult`. +The summary contains the existing point estimate, selected parameters, per-parameter diagnostics, and segment coverage. +The synthesis tool and approach consume this summary without changing parameter selection or publication. +Each view owns copies of its dictionaries, so a caller cannot change the fit cache by modifying its diagnostics. +The adapter neither fits nor samples. + +The metadata explicitly identifies `legacy_rollout_sysid` and `legacy_widths`. +These widths are not newly claimed credible intervals, and the adapter does not turn optimizer candidates into weighted posterior samples. +The original `FitResult`, diagnostics, caches, publication methods, and checkpoint fields remain available to their existing consumers. +The adapter is a property rather than a new stored field, so historical outcomes need no schema migration. +Canonical, diagnostic, cached, and no-survivor fits retain their existing handling. + +The separate offline prototype now defines program, prior, sensor-model, runtime, and observation-ledger hashes. +Real training recordings now pass the corrected reader and content-addressed snapshot audit in all five domains. +The reader reconstructs the seeded observation channel from stored sanitized truth; it does not expose the noiseless stored poses as inference observations. +Complete simulator runtime and resource closure is still pending. +Explicit working-directory files, optional-file absence, and the complete child environment now have an immutable `RuntimeInputs` contract. +Fresh-process balloons replay validates this portion of the runtime identity; native dependencies and arbitrary external reads remain outside that guarantee. +The existing legacy cache key is not represented as an immutable statistical data identity. +There is no new public estimator flag or agent-facing tool output in this chunk. + +The offline `assess_inference` boundary now separates numerical availability from predictive checks. +Its identified assessment protocol requires an explicit set of numerical checks; omitted checks remain unevaluated rather than becoming implicit passes. +Completed sampler output is exposed as a posterior only after those checks pass and its sample structure and normalized weights are valid. +Predictive failures remain attached to an available posterior, while sampler failures, failed numerical checks, and missing checks expose no usable posterior through this boundary. +The boundary does not publish a canonical fit, retain an older fit, approve an action, or establish that a caller's chosen assessment protocol is scientifically sufficient. +Production integration and the remaining physical inference gates are still pending. +Compute job `22632593` passed 21 functional tests, two-file mypy and lint, and pinned formatting checks for this boundary. +The first check attempt failed because its new test fixture requested zero sampler moves, which the sampler correctly rejects; the corrected frozen fixture and final check artifacts are in `logs/uncertainty_assessment_v2_20260912`. + +The offline feasible-scene adapter now connects support checks to the conditional batch sampler without discarding exact-observation or proposal-density factors. +It explicitly distinguishes globally conditioning the entire parameter/state prior from normalizing each conditional state law while preserving the parameter prior. +The latter requires the original support probability as a declared function of its retained variables, before conditioning on observations. +This closes an integration gap between the generated scene sampler and the numerical posterior reference; it does not supply the still-missing historical scene laws or their normalizers. +See [scene-prior composition](scene-prior-composition.md#connecting-feasible-scenes-to-parameter-inference) for the equations and applicability limits. +Compute validation passed 22 functional tests plus focused static and formatting checks. +Both support-normalization references passed 8/8 trials at 2,048 particles; the smaller 512-particle budget passed 15/16, with the failed trial retained. + +The [Balloons initial-scene reference](balloons-initial-scene.md) now combines a declared scene law, exact initial conditioning, noisy-position conditioning, full-candidate collision checks, and fresh-world replay of an actual frozen learned program. +All sixteen accepted root samples satisfy the declared support and exact initial observations, and each has identical repeated 16-action predictions. +All sixteen still contradict a later exact output at the first action; a separately tested supported-rest configuration substantially reduces the box-speed discrepancy. +This advances the physical initial-state gate and identifies a concrete trajectory constraint; it is not a complete recording posterior or deployment acceptance. +The Gaussian-coordinate conditioning implementation used by the reference passed seventeen functional tests, focused type/lint checks, and pinned formatters on compute nodes. + +## Candidate replay contract + +The separate offline `replay_candidate` API accepts a fresh environment factory, an explicit `ReplayState`, executed actions, and fixed candidate parameters. +It returns the reconstructed initial state and every subsequent state. +This permits separate measurement of initialization and transition errors. +The incumbent `rollout_states` continues to zero velocities exactly as before. + +| Quantity | Offline replay behavior | +| --- | --- | +| Object features | Restore the supplied candidate through the domain's state interface. | +| Object linear and angular velocities | Require explicit finite values for every physical object and restore them even if its pose already matches. | +| Robot joints | Require position and velocity for every URDF joint, including passive joints; check agreement with the state's controlled joint positions. | +| Robot base | Restore the supplied base velocity; mobile robots must also supply a base pose. | +| Model memory | Require explicit memory when the subclass declares it; preserve and copy it across branches. | +| Command attachments | Validate object names and restore the existing portable attachment topology. | +| Environment lifetime | Build and dispose a fresh world per candidate, including on restoration or step failure. | + +`capture_replay_state` reads a simulated candidate, or evaluator state for a mechanical offline audit. +It is not installed in the agent's observation, recording, or tool interface. +It drops privileged payloads and live simulator handles while retaining candidate memory. +An inference caller must supply sampled or legitimately known unobserved quantities; it must not capture the live task to initialize its candidates. +Historical recordings do not supply all passive-joint positions or joint velocities, so those quantities still need an explicit initialization assumption or prior. + +This representation is not an exact engine checkpoint. +Replay must use the same domain layout, robot/URDF, and environment configuration as the candidate state; it does not support arbitrary changes to body allocation or morphology. +The state interface omits solver caches. +The later replay correction explicitly captures full body orientations, original command-attachment frames, and pending next-step commands. +Arbitrary subclass instance variables and arbitrary native engine constraints are not implicitly captured. +Model authors must put persistent inferred quantities in declared model memory, and further audits must determine whether omitted engine state materially affects predictions. +Geometrically valid initial-state sampling and the observation likelihood remain separate work. +Passing the structural checks does not certify that an arbitrary candidate pose is feasible or explains the data. + +## Validation evidence + +The pre-change source is preserved in the detached worktree `/home/ycliang/predicators-uncertainty-baseline-20260911` at `6179fe1e7`. +This is the direct interface-parity baseline, distinct from the historical successful experiment tag `noisy-mb-five-domain-15of15-20260910`. +No historical experiment runtime or result was modified. + +The moving-start reproduction creates a box with vertical velocity 0.5 m/s and records 15 real engine steps. +Legacy fitting replay differs by up to 0.1636 m because it discards that velocity. +The new replay tests exercise motion through table contact, a resumed prefix, independent memory branches, complete robot motion, and welded-assembly restoration. +An additional reproduction showed that `State.copy()` shares mutable object metadata across candidate worlds. +Offline replay now copies object metadata as well as feature arrays and model memory, without changing the production state-copy implementation. +They also reject missing motion, missing memory, inconsistent joint positions, and unknown attachment endpoints. + +The compute-node checks additionally compare the actual `sim.fit` reports and publication calls for canonical, cached, and diagnostic fits against the pre-change source. +Scripted continual interactions compare observations, actions, tool replies, steps, and resets across the five noisy physics domains and a solved cover task. +Action traces, tool replies, and counters are compared exactly, with only the existing elapsed-clock text normalization. +Numeric observation values use an absolute comparison tolerance of 1e-12, with every nonzero difference retained in the comparison report. +The first comparison found one floating-point observation difference of 3.39e-21 across compute nodes; all actions, replies, and counters were identical. +These mechanical comparisons are not stochastic LLM solve-rate replications. +The wider regression run also exposed three pre-existing publication-test failures on the unchanged baseline: an old stub lacked the subclass-parameter synchronization method. +Those tests now exercise the real approach's publication and cache methods without launching an SDK session. +The production publication implementation was not changed to accommodate the tests. + +The reusable audit is [scripts/audit_inference_replay.py](../../scripts/audit_inference_replay.py). +It runs a 30-action hold sequence and resumes from its third action in each domain, comparing reconstruction, repeated replay, and continuation against the source world. +It uses the evaluator program with registry parameters pinned identically in source and replay, solely to isolate restoration error. +It does not test learned programs or certify long task-solving trajectories. + +The completed audit restored initial features exactly and repeated fresh replay exactly in all five domains. +Maximum absolute position differences from the source world, in millimeters, were: + +| Domain | Replay from initial state | Replay from action 3 | +| --- | ---: | ---: | +| Bridge | 0.0154 | 0.0113 | +| Fan | 0 | 0 | +| Domino | 0 | 0.00261 | +| Boil | 0 | 0.000730 | +| Original balloons | 0 | 0.0142 | + +These small errors describe the audited short hold sequences only. +They do not establish a bound for releases, sustained contacts, long trajectories, or incomplete learned models. + +Commands, raw comparisons, and audit JSON are under `logs/uncertainty_migration_20260911/`. +All simulations and test suites run on `mit_preemptable` compute nodes. +The completed functional checks cover 79 tests: 12 focused adapter/replay tests and 67 existing uncertainty, fitting, publication, and synthesis tests. +Focused mypy and lint checks and the pinned formatter checks cover the changed Python files. +The shared environment had `isort` 5.13.2, so the final checks use an isolated installation of the required 5.10.1 without modifying the shared environment. +These are scoped local checks, not a full repository test run or a PR/CI result. + +## Offline probability prototype + +The next chunk adds immutable observations and reset-episode ledgers, the declared additive sensor likelihood, and a bounded continuous tempered sampler beside the incumbent. +The joint sample vector can represent parameters and uncertain initial states in the small reference problems. +Repeated observation reads are deduplicated, exact predictions are constraints, and failed numerical runs return no posterior samples. +The sampler preserves the original prior across repeated calls. + +See [the probability model contract](offline-probability-model.md) for assumptions, result semantics, and limitations. +The independent uniform reference prior is not yet a feasible physical-state prior for the five domains. +There is no new production import, estimator flag, prompt change, or agent experiment from this chunk. + +The earlier probability prototype passed eight focused functional checks, four-file mypy with `--follow-imports=skip`, configured lint, and pinned formatters. +Those results apply before the sampler correction and recording adapter described below; they do not validate the current complete working tree. +A normal dependency-following mypy attempt exceeded its bounded local allowance. + +### Numerical gate findings + +The original every-temperature resampling prototype passed the stationary Gaussian reference and the position/velocity grid reference, including correlation and retention of an uninformed parameter. +The two-mode test first exhausted an undersized test budget. +After assigning the budget required by its declared particle/move counts, seed 19 retained both modes but assigned 72.2% to the positive mode of a symmetric posterior. +That failed the unchanged 35%-65% tolerance. +These are numerical reference problems, not agent solve-rate results. + +The candidate implementation now resamples only below the declared effective-sample-size threshold and retains importance weights otherwise. +Posterior summaries and tests now use those weights, including inverse empirical-CDF quantiles. +The correction passed its compute reference suite, including the original failing seed and three additional seeds. +A separate eight-seed, three-reference experiment passed all 24 larger-budget comparisons; the smaller budget missed one uninformed-parameter tolerance. +These results validate the tested numerical references, not physical-domain inference. + +### Recording and artifact preparation + +The new read-only `inference_recording` adapter reads explicitly selected flushed level recordings, verifies reset markers and every primitive action, and preserves original source bytes. +It rejects missing action boundaries, unflushed/inconsistent files, duplicate reset identities, and incompatible sensor semantics. +Public joints and mobile base pose receive explicit exact sensor entries. +Extra metadata, including body velocities or command welds, requires an explicit exclusion reason rather than silently entering or disappearing from the likelihood. +A candidate simulator's memory and privileged fields cannot enter through the observation projection. +Named source bundles preserve bytes and manifests without overwriting prior snapshots. +Dependency enumeration remains an explicit caller responsibility. +Writer-to-reader and artifact integrity tests passed on compute. +The adapter also validated frozen first-training-level recordings from all five domains, preserving 1,978 recorded primitive actions in total. + +### Historical compute blocker, resolved September 12 + +Slurm originally returned `Unable to contact slurm controller (connect failure)`. +A fresh queue query timed out, and a bounded submission retry also timed out without a job ID. +This session cannot verify whether the retry was accepted; no compute output has been observed. +Before another submission, inspect the queue for the prepared `checks.sbatch` job once connectivity is restored. +The user reiterated that expensive tasks must run on compute nodes. +Inference sweeps, physical replay audits, full checks and live experiments therefore remain pending compute access. +No live estimator change or new agent experiment was made. + +Evidence and reproducible commands are under `logs/uncertainty_probability_20260911/`. +The prepared compute entry point is `checks.sbatch`; current source hashes and validation limits are in `continuation-manifest.json`. +Only syntax and formatter checks have been applied to the new recording code and resampling correction locally. +Changes remain uncommitted. + +### September 12 preparation before network access was restored + +Compute access remains unavailable from this session: another bounded queue query timed out, and no output from the earlier validation submission was found. +A connection-tracing attempt was also disallowed by the execution environment, so the timeout has not been diagnosed as a cluster outage. + +A frozen validation job is now prepared at `logs/uncertainty_stage_a_20260912/checks.sbatch`. +It reconstructs commit `6179fe1e7`, the captured working-tree patch, and 13 captured overlay files in compute-node scratch space. +It validates there without formatting or otherwise modifying the live checkout. +Input hashes, runtime-version verification, per-job test reports, and an exit-status record make the result attributable to that snapshot. +Preparation passed syntax, source-consistency, and standalone patch-application checks; the full job has not run. + +The frozen job covers the corrected weighted sampler, four two-mode seeds, recording integrity, focused legacy/replay regressions, dependency-following mypy, configured lint, and pinned formatters. +The user has been asked to submit it from a normal cluster terminal and share its job ID, because this session still cannot reach Slurm. +Inspect the queue for the earlier `checks.sbatch` attempt before submitting another job. +The frozen job itself has not been submitted by this assistant session. +See `logs/uncertainty_stage_a_20260912/README.md` for the exact command and result paths. + +### September 12 completed checks and active experiments + +Network access was restored, and the assistant submitted the prepared jobs directly. +Frozen-source validation job `22625595` completed successfully on `mit_preemptable`. +It passed 24 numerical/recording tests, 15 focused legacy/replay regressions, dependency-following mypy on 16 files, 16 configured lint checks, and pinned formatting checks. +These are focused checks, not a full repository test run or CI result. + +Independent numerical experiment `22625659` completed 48 runs over three reference problems, eight seeds (100 through 107), and two particle budgets. +All 24 runs at 1,024 particles passed the criteria frozen before submission. +At 256 particles, 23 of 24 passed; correlated-reference seed 102 shifted the uninformed parameter mean to 0.143, beyond its 0.12 tolerance. +The smaller budget remains a documented stress-test failure rather than a recommended default. +See [the September 12 experiments](experiments-20260912.md) for per-reference counts and source paths. + +The corrected recording audit `22625932` passed for all five domains. +Initial audit `22625681` established file integrity but used the wrong observation projection; its statistical data is superseded. +It reads only explicitly selected historical training levels, reconstructs the same step-keyed noisy views as the continual agent, verifies action/reset alignment, retains public joints, rejects an exact-observation contradiction, and freezes the source bytes and channel coordinates. +This audit is not a fit or prediction-quality result. + +Nominal fixed-program prediction preflight `22625747` failed during configuration setup because the launcher manifest uses the CLI alias `log`, while `reset_config` expects `log_file`. +No simulation ran in that attempt. +Corrected setup job `22625774` keeps the same programs, data, and numerical predictions; it translates that alias before configuring the environment. +This experiment measures nominal prediction disagreement and repeated-replay error from fixed training-program snapshots before posterior fitting. +It does not claim to reproduce historical fitted parameters, and explicitly excludes the optional balloons `model_params.json` override in an isolated working directory. +All such setup outcomes remain separate from model or agent outcomes. + +### Observation-channel correction and physical prediction findings + +The reader's first implementation mistook stored sanitized simulator truth for noisy agent observations. +A regression through the actual continual session and writer reproduced this in job `22625874`. +The corrected reader requires run seed and level index and reconstructs the same observation channel used by `ContinualRun._observed`. +Final job `22626126` passes all 29 functional tests, mypy, configured lint, and pinned formatting after correcting a lint-only type check. +This change is offline-only and does not alter existing recordings or acting MF/MB agents. + +Prediction setup also had to restore the original `b09217bb3` runtime: current-branch balloons scene controls differ from the historical recording runtime. +Corrected prediction job `22625933` used reconstructed noisy starts, public predicted outputs, and frozen training programs on that historical runtime. +All 14 executable nominal cases were exactly repeatable, but all contradicted at least one exact observation under the strict likelihood. +Two early Fan cases were invalid programs with a zero lower bound for a logarithmic parameter; they were not modified to make the experiment pass. +Nominal failures are not a proof that every feasible initial state and parameter is impossible. +They require explicit initial-state support and a reconstruction/model-error diagnosis before a meaningful posterior comparison. + +Mechanical follow-up `22626183` isolates full-state restoration versus the legacy zero-velocity reset under recorded-action stress inputs in recreated evaluator worlds. +It does not feed evaluator state into a fit or an agent. +The completed audit shows residual reconstruction errors in every domain when robot joints are included. +The moving-start balloons continuation differs by 106.17 mm and changes attachment topology, compared with 86.78 mm for legacy zero-velocity replay. +Subsequent diagnostics `22626945` and `22626966` isolated omitted next-step commands, unobserved body orientations, and original weld frames. +Preserving all three reduced the balloons midpoint non-robot position error to 9.73e-14 m and restored the correct attachment sequence. +Robot joint differences remain, so a portable mid-trajectory state is still not an exact engine checkpoint. + +The earlier `22626292` checkpoint failure had a separate lifecycle bug: restoring the engine did not remove constraints created after the saved boundary. +Removing those later constraints and verifying the originals reduced non-robot position error to zero in both follow-up checkpoint attempts. +This is diagnostic evidence, not a general checkpoint implementation or justification for adding sensor variance. + +The offline replay now supports reconstructing the full action prefix in one fresh world. +Corrected audit `22627021` produced bit-identical prefix continuations versus uninterrupted candidate trajectories across all five domains, at both tested boundaries. +The explicit initializer API makes the candidate root protocol part of the model and artifact contract. +It never selects evaluator tasks implicitly; inference initialization must use the declared prior and allowed conditioned inputs. +The task-cache investigation additionally reproduced and fixed lost initial robot joints in Domino's cache. +Matching the existing continual fresh-world lifecycle then gave zero measured replay error in all five domains at both tested boundaries in audit `22627245`. +This validates explicit initialization plus full-prefix reconstruction for the tested development trajectories; arbitrary portable checkpoints remain approximate. +Physical-prior design, exact-output feasibility, and learned-program prediction quality remain separate gates. +Detailed results and limitations are in [the experiment record](experiments-20260912.md). + +### Remaining full-plan execution + +The [initial-state inventory](initial-state-inventory.md) now records the observed quantities, missing state, and unresolved support choices for all five frozen development recordings. +Visible-model audit `22627492` confirms Fetch has four unobserved movable joints in addition to its nine observed arm/gripper joints. +It also verifies that the first balloon box-speed observation is exactly zero. +These findings prevent incorrectly treating robot motion as fully observed or using only a positive-speed velocity chart. +The inventory is a gap audit; normalized physical priors and final joint dimensions remain unresolved. + +The new offline `AffineConditioning` primitive eliminates a square nonsingular affine observation while retaining the induced density correction. +It separates unsupported singular charts, individual points outside prior support, and numerical solve failures. +This construction handles exact initial coordinate observations and parameter-dependent affine elimination; it is not a nonlinear contact-constraint solver. +Compute job `22627437` passed 15 functional tests, mypy, configured lint, and pinned formatting. +An eight-seed importance-sampling reference passed its predeclared checks in 8/8 runs at 8,192 particles and 5/8 at 512 particles; the smaller-budget failures remain recorded. +Those importance-sampling references are not agent solve-rate seeds. +The later conditional-base extension below integrates this construction with offline SMC; production fitting remains unchanged. + +The next velocity component is implemented as an explicit rest atom plus an isotropic Gaussian moving component. +Exact rest retains the prior atom's mass, while positive speed retains the Maxwell radial-density factor and two uncertain direction coordinates. +This is a normalized component prior, not a completed joint scene prior; hyperparameters and dependencies still need specification. +Validation `22628133` passed 19 functional tests and focused type/lint/format checks. + +Runtime reproduction `22628126` found up to 0.4961 m of predicted balloon-height change from an optional parameter sidecar, despite unchanged program bytes and declared parameters. +The new working-directory input contract distinguishes present/absent files and fixes the child environment. +Fresh-process validation `22628194` gave identical repeated feature predictions within each condition and distinct runtime-input identities between them. +Contract validation `22628197` passed 13 functional tests and focused type/lint/format checks. +The velocity and runtime suites therefore cover 32 distinct functional tests in this chunk. + +Bridge audit `22628262` establishes a separate model-adequacy obstruction: the frozen no-op model keeps glue attributes constant, but four exact recorded glue attributes change. +Under this reviewed invariant, the full sensor-only target is inconsistent regardless of initial-state prior or sampling budget. +This case must return unavailable inference with its model contradiction visible; it is not a finite-search failure or a successful posterior with ordinary predictive residuals. +See [the experiment record](experiments-20260912.md#a-structural-exact-output-contradiction-in-the-frozen-bridge-model). + +The offline sampler now accepts an explicitly identified conditional base measure and returns full joint parameter/initial-state samples. +Exact-observation density and proposal corrections enter the initial weights and every Metropolis acceptance ratio; only the remaining likelihood is tempered. +An integrated affine-dynamics reference checks these weights, parameter/state dependence, an uninformed coordinate, and prediction at a held-out time against numerical integration. +All eight reference trials pass at 2,048 particles; six of eight pass at 512 particles, with both smaller-budget failures retained. +This validates the tested conditional construction, not a contact-state prior or repeated-dataset calibration. +Eight comparisons against the prior Box sampler produce identical serialized results. +Validation passed 26 functional tests, four-file mypy and configured lint, and pinned formatting on `mit_preemptable`. + +`audit_constant_outputs` checks exact observation contradictions under a separately reviewed, versioned program/runtime invariant. +It returns `model_inconsistent` with observation witnesses, or `not_disproved`; neither outcome is a posterior. +It does not infer invariants from successful rollouts or declare feasibility when no contradiction is found. +The frozen Bridge ledger yields all four known witnesses without sampler or simulation work. +See [the integrated validation record](experiments-20260912.md#integrated-conditional-base-sampling-and-support-assessment). + +The new offline [rigid-assembly prior component](assembly-prior.md) generates correlated body poses, velocities, and original weld frames from one root pose and twist. +It provides explicit free/rest, free/moving, and horizontal-support cases with 6, 12, and 3 continuous coordinates respectively. +The supported case fixes height from a declared physical face instead of projecting independent noisy poses onto contact. +All 12 generated mechanical trials passed initialization and replay checks across gravity and plane contact, totaling 8,640 simulator steps. +Fresh repeats and complete-prefix replay matched exactly, while finite-force welds deflected by up to 3.41 mm during impacts. +Validation passed 21 functional tests and focused type, lint, and pinned formatting checks on compute nodes. +These are known-geometry component references, not historical-domain posteriors; case probabilities, geometry uncertainty, full scene support, and robot-state priors remain unresolved. + +The offline [joint-state prior component](robot-state-prior.md) now retains exact initial-position density, unobserved joint positions, and explicit initial-velocity distributions or rest atoms. +Validation passed 25 functional tests and focused type, lint, and pinned formatting checks. +A visible-model audit restored 256 sampled joint states exactly across Bridge, Fan, Domino, and Boil, with 4 free coordinates in the declared rest component and 17 in the moving component. +The same bounded prior is incompatible with the frozen balloons initial shoulder position; it correctly returns no conditional samples for that case. +Head-geometry witnesses in all five domains show why identical public robot kinematics does not establish collision irrelevance of unobserved joints. +These findings refine the remaining initialization-law and scene-support work rather than completing the full physical prior. + +The next [scene-composition reference](scene-prior-composition.md) covers the balloons joint start with a declared Gaussian reset law and retains its observation density. +An actual wrapper reset reproduces every recorded initial joint exactly; the old bounded prior remains a negative control. +Whole-candidate rejection preserves the stated joint/body measure, with an explicit finite-search outcome and no claim to estimate model evidence. +The generated five-domain reference accepted 80 scenes in 82 draws after treating the two source-established wheel/plane contacts as fixed fixture geometry. +The stricter all-intersections predicate rejected every candidate because those wheel spheres overlap the floor independently of joint angle; that failed policy remains recorded. +Final validation passed 35 functional tests, four-file mypy and lint, and pinned formatting on compute nodes. +These are generated component compositions, not historical scene reconstructions or replacements for the later exact-trajectory gate. + +| Stage | Required work before advancement | +| --- | --- | +| A: probability model | Complete the five domain initial-state inventories, exact-conditioning construction and reference checks, full runtime capture, and feasible physical initial-state priors; existing numerical and recording references pass at the tested budget. | +| B: recorded predictions | Freeze development programs/data and compare with legacy on all five domains, including contact transitions, incomplete programs and held-out causal suffixes. | +| C: planning | Run saved-decision shadow reports, then matched live parameter-belief comparisons; evaluate exploration and conditional-state rollout changes separately. | +| D: execution state (optional) | Validate causal conditional filtering, reconstruction and bounded degraded-mode recovery if pursuing a replacement for the observation smoother. | +| E: retirement | Predeclare margins, evaluation size and budgets; run matched per-domain comparisons, retain inconclusive results, and retire the legacy parameter fitter only after acceptance. | + +Under the September 12 proposal revision, Stage C can proceed directly to Stage E with the existing execution state estimator. +Conditional state sampling for planning and the live conditional filter are optional extensions, not requirements for completing parameter-uncertainty simplification. +The revised result contract also requires returning numerically adequate posterior fits with predictive failures visible, separately from decision use; this is a requirement for the replacement, not a claim about current production behavior. + +The numerical-reference gate now passes at the tested larger budget. +Stage A remains incomplete until physical initial-state support, exact-output feasibility, and simulator/runtime closure are established. +No posterior estimator has been deployed to the acting agent. +Later stages are not implicitly complete because an offline interface exists. + +The [explicit transition-discrepancy reference](transition-discrepancy.md) now implements analytic conditioning of a rest/Gaussian velocity transition on exact speed. +It retains the noncentral radial density and directional uncertainty instead of inflating sensor variance or projecting without a likelihood factor. +This is a separately declared stochastic model extension, motivated by the unresolved deterministic contact constraints. +The recorded Balloons diagnostic tests conditional prefixes and unconditioned future continuations; it is not a posterior comparison or agent result. +Seventeen functional tests, focused type/lint checks and pinned formatting pass for the transition component. +The accompanying native-link cache audit explains why resetting unchanged joints introduced artificial Cartesian residuals into the first diagnostic. +Preserving the native predicted link-observation phase removes those early residuals without using observed Cartesian values. +All twelve corrected diagnostic paths repeat exactly, but later robot/contact constraints still fail at actions 18 or 22, so they do not supply a complete 32-action posterior. + +A separate [marginalized output-discrepancy extension](output-discrepancy.md) integrates a declared Gaussian error history without modifying physical simulator states or sensor variance. +Joint inference of velocity, uncertain initial position and error scale agrees with an independent dense grid in four of four larger-budget numerical trials; a smaller-budget prior-marginal failure remains recorded. +The five-domain diagnostic compares persistent and independent error on selected real-valued channels with fixed native predictions and a suffix held out from discrepancy fitting. +It does not yet supply a full-recording posterior, an uncertain-scene comparison or a replacement for the legacy fitter. + +The [exact-readout boundary](observation-reductions.md) now verifies deterministic readouts of exactly observed source fields before reducing an observation view. +It preserves source likelihoods, refuses noisy or missing sources, and returns no reduced view for contradictions. +The runtime-derived finger map matches all 1,983 public frames across the five development domains; the integrated reducer also rejects all 1,983 perturbed-readout controls. +This removes a redundant finger constraint after verification, without relaxing the underlying joint observation or changing production observations. + +The subsequent [native Euler support audit](observation-reductions.md#native-euler-readout-support) rejects a proposed canonical-angle assumption. +Of 1,983 recorded robot orientations, 1,310 have pitch exactly positive pi/2 and zero roll, including 31 whose native yaw lies outside [-pi, pi]. +Direct conversion probes reproduce collapse to the pole across a nonzero range of requested pitch values. +The audit preserves those readings as valid evidence and leaves their coupled likelihood unresolved; independent continuous angle densities or silent wrapping are not a validated replacement. + +The next [coupled quaternion-output model](orientation-discrepancy.md) integrates an explicit Gaussian-mixture readout discrepancy through the native Euler map. +Independent analytic and sampled references check ordinary densities, pole masses and the native yaw branches. +The corrected implementation completes all 2,560 recorded density evaluations across five domains, four scales and two numerical tolerances; the largest tolerance change is below 8e-13 per reading. +The full observation composition then accounts for every output field in the existing 64-action conditional forecasts. +Fan and Domino have finite complete-output likelihoods under the declared extension, while Bridge glue, Boil switch/faucet and Balloons speed discrepancies still force zero likelihood for their supplied forecasts. +These positive cases enable the next joint inference experiment; they do not establish an uncertain physical initial-state posterior, legacy-fit comparison or production readiness. +Final compute checks passed 27 functional tests, four-file mypy and lint, and pinned formatting; the complete-output integration accounts for 24,896 measured fields across its five conditional forecast windows. + +The next [Domino joint-inference pilot](domino-joint-inference.md) specifies an uncertain physical scene under an explicit unheld-case prior and includes the initial observation in the complete likelihood. +Eight generated candidates passed geometric support and exact repeated 64-action replay; two had finite complete-output likelihood. +The sampler now supports optional disjoint proposal blocks while preserving the existing full-vector default. +These developments enable a physical integration experiment, but numerical adequacy, complete runtime capture, all-domain prior closure and the legacy-fit comparison remain open. +Both small-budget Domino pilots completed but collapsed to one initial ancestor, retaining only one and three distinct physical parameter vectors and disagreeing across independent runs. +They do not provide usable parameter uncertainty; the next numerical experiment must address first-temperature concentration and parameter movement before comparison with the incumbent. + +The [sampling reproducibility follow-up](sampling-reproducibility.md) identified unordered candidate initialization and cross-node quantile differences that also confounded the initial pilots. +Canonical object ordering with identical saved physical candidates reproduced all 31 candidate trajectories and likelihoods across three CPU types and two hash seeds. +New pilots pin the candidate-generation runtime and factor the initial observation into the conditioned base before tempering the remaining trajectory, preserving the complete target. +The explicit-schedule sampler passed 19 functional tests, type/lint/format checks and eight exact default-parity comparisons. +The new physical fits remain experimental until independent agreement, budget sensitivity and prediction checks establish usable inference. + +The [full legacy comparison setup](offline-fitter-comparison.md) now consumes the same reconstructed noisy public frames through a checked observation-to-state adapter. +All 295 Domino and Fan training frames round-trip exactly; seven functional tests and focused type/lint/format checks pass. +Four cold-fit comparisons are submitted for the two 64-action windows and the two complete training recordings, using the actual legacy preparation and orchestration pipeline. +The Fan audit separates its earliest broad support from later data-derived bounds and declares a fixed uniform prior for future posterior experiments without changing the latest program dynamics. +These are comparison inputs and submitted experiments, not completed Stage B evidence or a production replacement. + +## Next gate + +The [parameter consumer boundary](parameter-consumers.md) now derives parameter quantiles and weighted or resampled ensembles from the same assessed joint approximation. +It preserves parameter dependence and predictive diagnostics, requires explicit coordinate/name mapping, and refuses numerical results that are unavailable or unevaluated. +This prepares an offline interface for later saved-decision comparisons; it does not make the current physical posterior pilots adequate or route the acting agent through a new estimator. +Nineteen functional tests, focused type/lint checks and pinned formatters pass for this consumer boundary. +The same ensemble now supplies its weights to the incumbent per-atom information criterion through `atom_information`, with explicit observation-channel probabilities and no acting-agent routing change. +This extension passed 61 functional tests and focused static/format checks on a compute node; 768 comparisons preserve the default scoring path exactly. +The physical inference and saved-decision comparison gates remain open. +The [conditional forecast interface](conditional-forecasts.md) now draws complete future observation histories from the fitted output model, retaining scalar temporal dependence, coupled Euler outputs and checked readouts. +Its native Domino check repeats both complete 161-action histories exactly, rejects the incompatible prefix and generates all 70 fields for the supported candidate's 97-action suffix without future-observation conditioning. +This is a physical integration check on two fixed candidates; neither is an assessed posterior sample. +The same output model now provides a causal joint future-likelihood score, preserving temporal error dependence while accumulating only future observation factors. +It distinguishes an unsupported fitting prefix from a supported prefix followed by a zero-likelihood future, and avoids cancellation from subtracting large full-history scores. +Forty-three functional tests pass, including independent conditional Gaussian references, and the native scoring check preserves all 33 archived complete-history scores exactly. +The separate `JointForecast` adapter now constructs a deterministic physical-history mixture from assessed joint rows, preserving initial-state dependence, posterior mass and predictive diagnostics. +It verifies the fitting ledger and output-model identity, rejects lost or unsupported positive-weight histories, and keeps one source particle fixed across an entire sampled future. +Forty-seven functional tests and four-file static/format checks pass. +An end-to-end linear Gaussian reference passes all declared prediction checks on both 2,048-particle runs and one of two 256-particle runs; the failed smaller-budget density check is retained despite passing parameter moments. +Real-domain numerical adequacy, stochastic Balloons future integration and the matched estimator comparison remain open. +The Balloons generation audit now reproduces its original full conditional path factor and eight repeated, distinct 32-action futures from a fixed 64-action prefix, with future readings removed from the generator's lookup table. +All 58 observation fields are generated under the physical transition and output laws, and the extracted velocity sampler preserves the previous random stream exactly. +Twenty-five component/conditioning tests and focused static/format checks pass; the native audit performs 1,963 actions. +This is a selected-witness integration check, not a posterior forecast; the exact-speed density still requires conditional integration rather than finite-path equality checks. +The four full legacy comparison tasks have also completed, providing saved predictions for both 64-action fits and complete Domino/Fan recordings. +Both domains retain their anchor parameters for prediction under the incumbent policy; a matched replacement posterior is still unavailable. +The [Boil incomplete-model control](boil-incomplete-control.md) now separates missing filling/heating dynamics from sensor noise using a scalar likelihood bound and a causal suffix calculation. +Compute job `22650461` completed the source/data audit and independent numerical references. +The original Boil/Bridge control array `22650411` was cancelled before starting because its worker bypassed the parameter-free public fit dispatch. +Replacement array `22651160` completed all four tasks, verifying the actual no-fit return and declared-dynamics predictions. +The paired Boil and Bridge runs respectively replayed 264 and 1,186 actions with exactly identical predictions and zero fitting evaluations. +They retain the missing filling/heating and exact glue-transition failures; these are offline parameter-free controls, not fits or agent seeds. + +The [full Fan initial-scene experiment](fan-initial-scene.md) now declares the remaining placement and geometry components, retaining all articulated and robot uncertainty under a normalized original law and explicit whole-scene support policy. +The corrected proposal preserves support for every quarter-turn case and records actual worker hardware instead of inherited CPU labels. +Eight scenes pass geometry and repeated 32-action replay; seven have finite complete-output likelihood on that prefix. +All eight earlier saved scenes still fail later exact events over the complete 132-action recording, despite exact repeatability. +Directed parameter and fixture-placement probes are investigating that conditional support; they are not posterior estimates or evidence of an improvement over legacy fitting. +Those probes have now found a complete-recording Fan support witness by adjusting a sampled switch placement within the original prior at speed 0.09. +Independent full replays verify that point and three nearby positive perturbations; a fourth perturbation fails, preserving the sensitivity evidence. +This opens a supported full-recording inference experiment while leaving numerical adequacy and comparison gates unresolved. +The subsequent [Fan joint-inference integration](fan-joint-inference.md) supplies a fixed coordinate map covering all declared cases and a broad/local mixture proposal with full density corrections. +The mapped witness reproduces its full likelihood exactly, and native proposal audits find finite targets in both local components while retaining broad-component failures. +Two full-recording Fan sampler pilots are submitted alongside the existing Domino runs; none is automatically treated as numerically adequate. + +The [Balloons transition/output composition](balloons-composed-inference.md) now accounts for the exact-speed transition density and all remaining outputs in one conditional path factor. +Six paths from one sampled root have finite factors over the complete 235-action training episode and repeat exactly; six from the other root retain their exact tie/clip failures. +This supplies complete-recording support for the explicit stochastic extension, while the deterministic reference, original-prior joint inference and predictive-adequacy questions remain separate. +The subsequent native sphere audit justifies integrating out nine initial orientation coordinates for this fixed program while retaining angular velocities. +A complete joint coordinate map now covers the ten parameters, all sixteen initial motion cases and every conditional velocity direction. +All 24 mapped test cases preserve initial exact observations and repeat full trajectories; seventeen have finite full-recording factors and seven retain event contradictions. +A density-corrected proposal that accounts for table clearance passes independent normalization checks and supplies twelve finite local candidates. +Two full joint Balloons inference pilots are submitted with stage checkpoints; posterior adequacy and deployment remain unevaluated. + +The [articulated replay correction](articulated-replay.md) closes an omission exposed by the Fan prior work: snapshots previously lost all four slider and twenty rotor joint states. +The corrected offline snapshot restores those states exactly and repeats a 64-action native Fan trajectory at every recorded boundary. +Twenty-two functional replay tests and focused static/format checks pass. +This does not establish arbitrary engine checkpoint portability or complete the Fan scene prior. + +The [Fan articulated-state component](fan-articulated-prior.md) now conditions a normalized rest/motion law on exact switch flags while retaining the event probability in parameter inference. +Fifteen functional tests and focused static/format checks pass, and the corrected compute audit verifies all 2,048 native state readbacks plus the actual enforced slider cap. +The inventory also identifies twenty collision-bearing rotor joints and now declares a uniform position/velocity component for their initial state. +Eight sampled rotor/switch candidates restore exactly and repeat sixteen recorded actions, adding an explicit forty-dimensional rotor component while leaving scene layout and contact support open. +This closes a switch component, not Stage A or the full Fan scene model. + +Finish Stage A by defining candidate initialization and priors with valid geometric and attachment support, completing runtime artifact capture, and resolving exact-output feasibility. +Retain the explicit Bridge inconsistency control while constructing supported positive cases. +Explaining that full recording requires model revision or a separately declared discrepancy model; additional state uncertainty alone cannot resolve the invariant contradiction. +Complete the per-domain inventories before choosing physical sampling proposals. +Exact predicted features must reject contradictions, while continuous exact observations require a valid conditional representation rather than generic sampling followed by an equality check. +Distinguish failed feasible-candidate search from demonstrated model inconsistency, and identify conditioned inputs explicitly. +The numerical references and observation-channel checks have passed; retain them while adding physical-model validation. +The sampler must not silently interpret unsupported replay state or program mismatch as sensor noise. + +Then compare fixed-prior batch inference and uncertain initial states with the incumbent on fixed development programs and recorded interactions from all five domains. +Include long prefixes, releases, contact transitions, incomplete programs, and held-out future predictions. +Planning, exploration, and execution estimation remain on the incumbent until their separate validation gates pass. diff --git a/docs/uncertainty/initial-state-inventory.md b/docs/uncertainty/initial-state-inventory.md new file mode 100644 index 000000000..7f76de008 --- /dev/null +++ b/docs/uncertainty/initial-state-inventory.md @@ -0,0 +1,234 @@ +# Initial-state and exact-observation inventory + +September 12, 2026. +This is a source and recording inventory for Stage A of the [simplification proposal](simplification-proposal.md), including the unresolved prior choices. +It does not certify a feasible physical prior or an implemented five-domain posterior. +The acting MB agent continues to use legacy inference. + +## Version and information boundary + +The inventory uses the five frozen first training levels selected in `logs/uncertainty_recording_audit_v2_20260912/plan.json`. +Each is one actual reset episode from historical MB seed 0; subsequent observations belong to the same initial state and full action history. +The accompanying report in `job-22625932/report.json` identifies every data and sensor digest. +The associated task and model runtime is `b09217bb38f2c3082136994ae43fbef2eb590e82`, with the corrected offline observation reader overlaid. +Recorded sanitized truth is transformed through the original keyed noise channel before becoming inference evidence. +Body velocities, privileged heat/cure state, and weld metadata are excluded from that evidence. + +| Domain | Training actions | Observations | Noisy scalar readings per frame | Exact scalar readings per frame | Frozen cycle-000 program versions | +| --- | ---: | ---: | ---: | ---: | --- | +| Bridge | 1,186 | 1,187 | 40 | 67 | 1 | +| Fan | 132 | 133 | 59 | 49 | 1, 2, 3 | +| Domino | 161 | 162 | 30 | 40 | 1, 2 | +| Boil | 264 | 265 | 22 | 24 | 1 | +| Original non-hatch balloons | 235 | 236 | 26 | 32 | 1, 2, 3, 4, 5 | + +These are observation counts, not independent physical coordinates or posterior dimensions. +For example, robot Cartesian pose and joint positions describe the same mechanism, and a balloon's tied flag does not independently specify its weld frame. +All five recordings include nine exact controlled joint positions and seven exact robot features per frame. +The runtime defaults to fixed-base Fetch; nine observed joints must not be mistaken for the whole URDF joint state. +The later recorded-action cache audit `22633028` establishes an additional timing distinction: the Cartesian robot pose comes from PyBullet's cached link transform, while joint positions are read from the current joint state. +After a physics step, an explicit forward-kinematics query or an identical joint reset can change the reported Cartesian pose while leaving joint positions and velocities unchanged. +On eight recorded Balloons actions, the native cached poses match the historical observations exactly; refreshed poses differ by up to 1.094 mm in a coordinate and 0.00634 radians in an angle. +The Cartesian readings therefore cannot be eliminated as instantaneous functions of the observed joint vector without preserving this historical observation phase. +This is engine observation timing, not evidence for increasing sensor noise. +See the [cache audit](../../logs/uncertainty_robot_cache_20260912/assessment.json) and [transition reference](transition-discrepancy.md). +The finger readout has a different result: all 1,983 recorded frames match the fixed endpoint interpolation applied after float32 conversion of the exact left-finger joint observation. +The [checked readout reduction](observation-reductions.md) therefore retains the joint's likelihood and verifies the extra readout without treating it as independent evidence. +Omitting the float32 conversion produces 1,967 false mismatches; incompatible or source-missing readouts are not silently dropped. +The compute audit in `logs/uncertainty_state_inventory_v2_20260912` enumerates the actual visible-model joints without generating evaluator tasks. +Job `22627492` confirms the same layout in all five domains: 24 URDF joints, comprising nine observed movable joints, four unobserved movable joints, and eleven fixed joints. +The unobserved movable joints are the two wheels and head pan/tilt. +Before justified reductions, robot motion therefore contributes nine unknown controlled velocities plus four unobserved positions and four unobserved velocities per episode. +This is 17 possible continuous robot coordinates per root, not an assertion that all affect the likelihood. + +The frozen Bridge and Boil cycle-000 programs are no-op models with empty parameter declarations. +They are useful incomplete-program controls, but cannot by themselves establish predictive performance of the final learned models. +Later cycle versions exist in the source run archives; selecting them requires recording their training-information boundary and parameter sidecars before comparison. +The final archived Bridge and Boil `simulator.py` files were also checked: their byte hashes match their cycle-000 no-op versions. +For these two particular seed-0 runs, selecting a later cycle does not supply a more complete model. +The frozen programs declare no `MODEL_STATE_INIT` quantities. +That gives these particular programs zero declared memory dimensions; it does not establish that real heat, curing, or other hidden processes are absent. + +## Common state contract + +Sources are the [noise injector](../../predicators/observation_noise.py), [recording projection](../../predicators/code_sim_learning/inference_recording.py), [robot wrapper](../../predicators/pybullet_helpers/robots/single_arm.py), [visible simulator loader](../../predicators/code_sim_learning/base_simulator.py), and [model-memory contract](../../predicators/agent_sdk/prompts/subclass_model.md). +The rows below apply separately to every domain inventory that follows. + +| Quantity and source | What the interface establishes | What remains unknown | Prior and feasible representation | Conditioning or elimination | Remaining continuous dimensions and discrete cases | +| --- | --- | --- | --- | --- | --- | +| Controlled robot joint positions, public observation | Nine exact positions at each observed step | Their relation to contacts and subsequent dynamics | Initial positions are coordinate constraints; future outputs must follow candidate dynamics | Eliminate nine initial position coordinates; do not overwrite later predictions | Zero sampled initial controlled positions; trajectory constraints remain | +| Robot Cartesian pose and fingers, public features | Seven exact values, partly redundant with joint positions | Consistency with fixed kinematics and any unobserved joints | Check the actual observation map and its dependent coordinates | Remove only proven deterministic redundancy; retain any independent constraint | Count depends on the robot map, not seven additional free coordinates | +| Robot motion and non-controlled movable joints, URDF and public observations | Controlled joint velocities are not measured; the URDF establishes which joints can move | Initial velocities and any unobserved movable positions | Explicit initial-position and motion prior; URDF limits alone are not a reset guarantee, and zero velocity needs a guarantee or prior atom | Fixed URDF joints and a fixed base have no physical degrees of freedom; missing movable values are not zero by omission | Nine controlled velocities, plus unknown movable-joint coordinates pending reduction | +| Object geometry and static descriptors | Exact sizes, colors, labels, and selected device links | Whether a value is an immutable input or a predicted output | Fixed known geometry; represent true static scene placement once per episode | Condition on immutable descriptors after verifying the program uses them as inputs | No dimensions for conditioned descriptors; scene pose uncertainty remains | +| Object pose and motion | Selected noisy pose coordinates; most velocities are unobserved | Omitted rotations, linear/angular motion, and dependencies through support contacts | A normalized prior on feasible assemblies, with explicit contact/rest/moving cases; support and case masses still to be specified | Derive constrained coordinates only with the induced density; no noisy-pose plug-in initialization | Domain rows below count known observation coordinates; final free dimensions remain unresolved | +| Grasp/attachment state | Some exact flags, but no complete original constraint frames | Endpoint frames, pending commands, and native attachment history | A coherent assembly representation; simulate all recorded actions from its root | Root flags constrain cases; later weld frames and command queues come from candidate history | Continuous frame coordinates and discrete alternatives depend on the root assembly | +| Subclass memory | Declared initialization is part of each candidate program | Undeclared Python state or external mutable resources | Use `MODEL_STATE_INIT` for the model's own reset memory; reject incomplete runtime capture | Derive parameter-dependent declared initialization; continue memory across the full prefix | Zero declared memory dimensions in this frozen program set; future programs require new rows | + +The environment reset tool promises a restart of the level, not a universal observation of every initial velocity or attachment frame. +Reading a default in a privileged restoration path is not evidence that the agent observed that value. +Similarly, `rollout_states` starting a simulated trial at rest is a legacy fitting assumption, not a reason to assign all physical initial velocities probability one at zero. + +## Bridge + +The recording contains five blocks, one bottle, two site markers, and the robot. +The [environment source](../../predicators/envs/pybullet_bridge.py) separates partially observed block features from hidden curing counters and attached partners. +Its hidden task generator and evaluator restoration code are diagnostic sources, not an agent prior. + +| Quantity and source | What the interface establishes | What remains unknown | Prior and feasible representation | Conditioning or elimination | Remaining continuous dimensions and discrete cases | +| --- | --- | --- | --- | --- | --- | +| Five block poses and geometry, public features | Noisy xyz/roll/pitch/yaw; exact half-extents and colors | True poses and their support contacts | Feasible oriented boxes under known geometry; contact-case density not yet specified | Condition on size/color, score all noisy pose readings | 30 noisy pose coordinates before support reduction | +| Bottle and two sites, public features | Bottle noisy xyz/rot; sites noisy xyz | Bottle's omitted rotations; true scene locations | Bottle geometry plus static site locations; avoid treating each repeated reading as a new location | Condition only on descriptors established as fixed inputs | 10 noisy coordinates; omitted bottle orientation still needs treatment | +| Motion, holding, glue, and attachments | Exact holding and glue flags | Body velocities, cure state, weld topology/frames | Common motion and coherent-assembly contract; incomplete no-op program has no invented cure-memory variables | Keep predicted glue/holding transitions as exact constraints | Six potentially movable bodies; root attachment cases unresolved | +| Frozen program memory | No declared memory | Missing mechanism is a model-adequacy issue | No-op model remains an explicitly incomplete control | No evaluator cure counters supplied to the candidate | Zero declared memory coordinates | + +## Fan + +The recording contains one ball, four fans, four switches, four boundary objects, one wall, one target, and the robot. +The supplied [visible base source](../../predicators/envs/pybullet_fan_base.py) establishes geometry and device accessors without exposing the hidden airflow task generator. + +| Quantity and source | What the interface establishes | What remains unknown | Prior and feasible representation | Conditioning or elimination | Remaining continuous dimensions and discrete cases | +| --- | --- | --- | --- | --- | --- | +| Ball, public features | Noisy xyz and exact radius | True position, velocity, spin, contact state | Sphere pose/motion under known geometry; eliminate rotational gauge only after showing the program and contacts are invariant to it | Fix radius; retain xyz likelihood | Three noisy coordinates; omitted motion and rotational relevance unresolved | +| Fans, switches, boundaries, wall, and target | Noisy xyz/rot; exact extents and selected device links | True fixed layout and any relevant articulated state | One static scene representation per episode with geometric/device dependencies | Condition on immutable extents/links, not noisy layout | 56 noisy coordinates before shared-layout reductions | +| Switch and target events | Exact `is_on` and `is_hit` | Event-consistent dynamics and initial articulated configuration | Discrete cases consistent with observed flags and public mechanisms | Indicators for predicted events; observed initial flags constrain cases | Compatible event cases, not extra continuous noise | +| Frozen program memory | No declared memory | Invalid parameter declarations in the earliest version | Keep invalid logarithmic bounds classified as setup failures | Do not repair bounds silently for an inference comparison | Zero declared memory coordinates | + +The later [prior-provenance audit](offline-fitter-comparison.md#fan-prior-provenance) verifies identical dynamics methods across all three saved Fan versions and distinguishes optimizer declarations from probability densities. +The planned posterior comparison uses an explicitly declared uniform prior on the earliest [0, 1] support with the latest executable program; the invalid earliest artifact remains unchanged. +This resolves that parameter-prior choice for the development experiment, but not the static-layout, contact or articulated-state rows above. + +The [articulated-state follow-up](fan-articulated-prior.md) implements exact Boolean conditioning of a declared switch rest/motion prior and verifies 2,048 native state readbacks. +The four prismatic switches have 29.6 mm enforced travel and a 14.8 mm on threshold; unchanged URDF metadata reports a larger nominal interval. +Four public fan objects represent twenty physical fan bodies with continuous, collision-bearing rotor joints. +The later articulated replay correction preserves all these nonrobot joint states, which earlier snapshots omitted. +A declared uniform rotor position/velocity component adds forty continuous coordinates; eight joint slider/rotor samples restore exactly and repeat sixteen recorded actions in fresh worlds. +The articulated portion has 40 to 48 continuous dimensions across sixteen switch rest/moving combinations. +The subsequent [full Fan root law](fan-initial-scene.md) combines those variables with fixture placement, box orientation cases, ball pose/motion and conditioned robot state under an explicit fixed-mounting and contact policy. +That development representation has 82 to 112 continuous state coordinates, or 83 to 113 with its airflow parameter, across 65,536 case combinations before support and remaining observations. +Eight sampled whole scenes pass the declared support gate and repeat exactly, but none of the earlier eight saved candidates satisfies the entire 132-action event sequence. +The subsequent placement/contact audit finds a full-recording positive witness and three nearby positive perturbations, all reproduced exactly in fresh worlds. +Numerical posterior exploration and predictive adequacy remain open; these selected support witnesses are not a calibrated task prior or a production posterior. + +## Domino + +The recording contains six dominoes and the robot. +The [domain implementation](../../predicators/envs/pybullet_domino) and observation schema show that each domino exposes roll and yaw but not pitch. +The task-cache repair preserves exact robot initialization; it does not reveal missing object coordinates to the agent. + +| Quantity and source | What the interface establishes | What remains unknown | Prior and feasible representation | Conditioning or elimination | Remaining continuous dimensions and discrete cases | +| --- | --- | --- | --- | --- | --- | +| Six domino poses, public features | Noisy xyz/roll/yaw; exact colors and held flags | Six omitted pitches, true poses, contact modes | Oriented rigid bodies using known geometry; stable support and moving cases need normalized prior mass | Condition on colors; retain holding constraints and pose likelihood | 30 noisy pose coordinates plus up to six omitted rotation coordinates before constraints | +| Domino motion and grasp state | No direct body velocity measurement | Linear/angular velocities and grasp frames | Common motion/assembly representation; contacts couple pose and motion | Reconstruct all later states from one episode root | Six potentially movable bodies; initial grasp alternatives unresolved | +| Frozen program memory | Five physical parameter declarations, no memory | Physical parameter/initial-state tradeoffs | Preserve the chosen original prior for each fixed program | Do not turn fitted bounds or optimizer output into a new prior | Zero declared memory coordinates | + +## Boil + +The recording contains one jug, one burner, one faucet, two switches, and the robot. +The [environment source](../../predicators/envs/pybullet_boil.py) stores partially observed heating state privately; it is not a recorded scalar sensor. + +| Quantity and source | What the interface establishes | What remains unknown | Prior and feasible representation | Conditioning or elimination | Remaining continuous dimensions and discrete cases | +| Jug pose and liquid readings | Noisy xyz/rot, water volume, bubbling level; exact color/held flag | True liquid quantities, omitted orientation, motion, and heat | Feasible jug/liquid state under the candidate program; heating memory must be declared by a revised program | Score the declared 0.07 scalar noise; never initialize heat from evaluator metadata | Six noisy coordinates, including two liquid readings; omitted orientation/motion remain | +| Burner, faucet, and switches | Noisy positions/rotations and spilled level; exact on/off flags | True fixture poses, spilled amount, event state | Shared static fixture geometry plus valid liquid/event support | Condition on fixed scene descriptors; retain event constraints | 16 noisy coordinates before scene reductions | +| Jug motion, grasp, and model memory | Holding flag is exact; no declared memory in frozen no-op model | Real heat dynamics are missing from this program | Keep no-op as an inadequacy control; a later heat model needs its own initialization contract | Do not add hidden evaluator heat to make this program fit | One potentially movable jug; zero declared memory coordinates | + +The no-op artifact also contains an optional geometry-dump side effect guarded by `BOIL_DUMP_GEOM`. +A replay identity must record its setting and external-file policy even if disabled in the comparison. +The [scalar inadequacy control](boil-incomplete-control.md) now quantifies the frozen program's constant-output limitation using all 265 noisy public frames. +Even the best unrestricted constants leave bubbling and water-volume RMSE at 5.51 and 5.93 times the declared sensor sigma, while spill remains near the noise scale. +This is a noisy predictive failure, not an exact contradiction or a reason to invent hidden heat initialization from evaluator metadata. + +## Original non-hatch balloons + +The recording contains one box, three balloons, three clips, one band, and the robot. +The supplied [visible base](../../predicators/envs/pybullet_balloons_base.py) exposes box speed as the norm of its three-dimensional linear velocity. +The hatch layout and acceptance rules are outside this inventory. + +| Quantity and source | What the interface establishes | What remains unknown | Prior and feasible representation | Conditioning or elimination | Remaining continuous dimensions and discrete cases | +| Box and balloon poses | Noisy xyz; exact material/gas labels | Full box orientation, balloon orientations where attachments make them relevant, true pose | Rigid bodies and a coherent tied assembly under the visible geometry | Condition on labels; do not derive hidden orientations from archived metadata | 12 noisy pose coordinates; omitted rotations require an assembly chart | +| Clips and band | Clip noisy xyz/rot and exact on/off; band noisy xy and exact lo/hi | True placement and possible placement dependencies | Static scene/clip mechanism from the public base | Fix band bounds, not its noisy placement | 14 noisy coordinates before layout reductions | +| Box speed | Exact nonnegative speed, not a noisy pose channel | Velocity direction at positive speed; support of the zero-speed event | Positive speed gives a sphere constraint; exact zero requires explicit treatment of rest support and its prior mass | Do not merely keep one velocity component or add a noise floor; affine elimination alone does not handle the norm constraint | Two directional dimensions at positive speed; zero-speed conditional prior needs a separate construction | +| Ties, pops, releases, and forces | Exact tied/popped and clip states | Original weld frames, velocities, pending attachment/lift commands | Coherent assemblies initialized from the prior; full-history replay supplies subsequent commands and original frames | Indicators for discrete predictions; do not reattach at a resumed deflected pose | Four potentially moving bodies plus articulated clips; compatible initial assembly cases unresolved | +| Program memory and external input | No declared memory; an instance cache contains body IDs | Optional `model_params.json` and its precedence over runtime parameters | Body-ID cache is derived per world; freeze sidecar bytes or declared absence | Do not treat a cached body ID as a latent physical quantity | Zero declared memory coordinates; sidecar is a runtime dependency | + +The audited first balloon observation has exact box speed `0.0`. +Consequently, the positive-speed sphere construction alone would not cover even this first development episode. +An explicitly declared rest component is one candidate prior design; its mass must be specified rather than inferred from an omitted velocity field. +The offline `RestOrGaussianVelocityPrior` now implements that component: probability `rho` at zero velocity and probability `1-rho` in a zero-mean isotropic Gaussian with declared per-axis standard deviation `sigma`. +Its exact zero-speed conditional selects the rest component, has no free velocity direction, and retains the observation mass `rho`. +At positive speed, two uniform sphere coordinates describe direction and the Maxwell radial density retains the information about `rho` and `sigma`. +Zero speed without a declared atom remains unsupported unless a separate conditional extension is specified. +This resolves the mathematical support construction for this candidate velocity component; it does not choose its hyperparameters or establish independence from pose, attachment, and robot state in a full physical prior. + +The balloon program versions also change parameter names and narrow some bounds after observing transients. +A fixed-original-prior comparison cannot silently reuse the latest narrowed bounds as though they preceded the data. +Record parameter meanings, units, prior provenance, and sidecar precedence for each chosen fixed-program target. +The later [composed target and prior audit](balloons-composed-inference.md) records those five-version changes, declares a fixed ten-parameter development prior, and verifies every lower/middle/upper program override. +It also gives finite complete-recording conditional path factors under an explicit stochastic extension for one sampled root, while retaining another root's exact event failures. +The resulting joint target has 522-553 active continuous coordinates, including two conditional velocity directions at each of 235 actions, across the existing sixteen initial motion cases. +The later [native sphere audit](balloons-composed-inference.md#native-justification-and-quotient-representation) justifies a fixed-program quotient over the nine initial balloon-orientation coordinates, reducing that target to 513-544 active coordinates while retaining every motion case and all world-frame angular velocities. +This is a defined candidate inference problem, not a numerically adequate posterior or support for the deterministic sensor-only model. + +## What this permits next + +The later public-candidate map audit `22631317` constructs all five visible worlds using only projected feature values and fresh model-owned body handles. +At the initial boundary, all five reproduce every exact recorded feature when feature values retain float64 precision. +This is an initialization-map check, not a geometric-prior or trajectory-feasibility certificate. +The first audit's float32 cast introduced artificial descriptor mismatches; that audit implementation was corrected without changing the agent or recorded data. + +The audit independently perturbs each of the 177 noisy coordinates in both directions, for 354 probes. +All sixteen Fan pose coordinates are reset to configured fan placements by the visible base and therefore cannot be independent initial-state coordinates under that initialization protocol. +Boil likewise resets spilled level and derives bubbling from its initialized hidden heat; a noisy observation of either is not itself a writable latent quantity. +Bridge's Euler-angle probes exhibit canonicalization and coupling near a pitch pole, so independent noisy Euler coordinates cannot be confused with independent physical rotations. +These are properties of the tested visible initialization map; reductions in a learned-program prior still require checking that program's initialization and output overrides. +See [the map audit](../../logs/uncertainty_scene_map_v3_20260912/reference-22631317.json). + +The first complete Balloons candidate-scene reference now has an explicit conditional root law with 42 through 73 continuous dimensions across sixteen motion cases. +Its sixteen accepted samples satisfy the declared geometry policy, reproduce every exact initial feature, and give identical repeated fresh-world continuations. +They still contradict later exact observations, starting with moving robot joints or box speed at action one. +The [root-prior definition](balloons-initial-scene.md) records its assumptions, the reset-dependent fixture geometry, the supported-rest diagnostic, and the remaining exact-trajectory constraint. + +The measured-coordinate counts sum to 177 noisy scalars per initial frame across five reset episodes. +They are an audit of evidence, not a 177-dimensional independent box prior. +The actual posterior dimension and discrete case count remain undefined until normalized physical support, robot-state reductions, assembly charts, and memory initialization are fixed. +Reporting a numerical joint dimension now would conceal those unresolved choices. + +The [affine conditioning reference](../../predicators/code_sim_learning/inference_conditioning.py) implements one restricted reduction correctly: a square nonsingular equation `y = A(u) z + b(u)` eliminates `z` and retains `1 / abs(det(A(u)))` in the density on free coordinates `u`. +It supports exact initial coordinate elimination as a special case. +It does not solve overdetermined robot-contact trajectories or arbitrary nonlinear observations. +The separate velocity-prior construction handles a speed-norm observation only under its explicit rest/isotropic-Gaussian assumption. +Unsupported charts, rejected individual prior points, and numerical solve errors remain separate outcomes. +The offline batch sampler now retains these conditional-base factors throughout tempering and Metropolis moves, and returns full joint samples including eliminated coordinates. +Its integrated affine-dynamics references pass at the tested larger budget; they do not resolve the physical rows above. +The reviewed constant-output checker separately represents the frozen Bridge contradiction without requiring an invented physical prior or treating failed candidate search as proof. + +The [rigid-assembly component](assembly-prior.md) now supplies one explicit generative representation for fixed relative geometry. +It derives all body poses and weld frames from a root pose, and child linear velocities include the shared angular motion around that root. +Free/rest, free/moving, and planar-support components have 6, 12, and 3 continuous coordinates, independent of the number of bodies whose relative poses are fixed by that model. +These dimensions describe those declared components only; the recordings do not establish fixed relative geometry or identify a support case merely by omitting fields. +Normalized case masses, uncertain relative transforms, scene regions, and robot motion still require specification before reporting a full task-prior dimension. +The generated physical reference passed all 12 trials, including plane contact and full-prefix replay, without loading evaluator tasks or recorded hidden state. + +The [joint-state component](robot-state-prior.md) now conditions exact initial positions while retaining their original prior density and all unknown joint motion. +For the audited Fetch schema, compatible rest and moving components have 4 and 17 free coordinates respectively. +The initial-position check passed for Bridge, Fan, Domino, and Boil under the declared bounds, with exact restoration of 256 sampled joint states. +The original balloons initial shoulder-lift angle is -1.5119263 rad, outside the assumed URDF lower bound -1.221 rad, so that component returns no conditional samples for balloons. +This is an incompatible prior assumption, not an agent failure or grounds to clip the observation. +The same visible-geometry audit finds identical public robot features but different head collision envelopes in all five domains, ruling out kinematics alone as a justification for eliminating head state. +Scene-specific irrelevance, initialization-law support, and joint/body collision compatibility still require resolution. + +The subsequent [composed reference](scene-prior-composition.md) adds an explicit Gaussian joint initialization law, preserving the bounded-prior rejection as a control. +The robot wrapper reproduces the exact recorded joint vector, including the balloons angle outside the URDF interval. +Global rejection then combines conditioned joints with generated rigid assemblies, retaining the declared base measure rather than resampling only a colliding body. +The support predicate explicitly permits the supplied fixed wheel/plane fixture contacts, whose -9.675 mm signed distance follows from the checked wheel radii and fixed centers; all other tested intersections are rejected. +It produced 80 accepted generated scenes in 82 draws across all five visible environments. +This closes those component-support obstructions under a declared model, not the historical scene-layout, case-mass, full-runtime, or exact-trajectory requirements. + +Before the physical comparison, close the unresolved rows with an explicit generative initial-state model and its normalizing/case factors. +Then validate its support on known-model recordings and measure exact-output feasibility on frozen learned programs. +Use the existing complete-prefix replay; do not promote arbitrary mid-run snapshots to exact engine state. + +The subsequent [Domino integration](domino-joint-inference.md) closes a restricted positive case with a declared global rest/moving mixture, full robot state and six uncertain body poses. +Its active dimensions are 27 at rest and 94 when moving, including five physical parameters, plus one discrete case. +It conditions on the observed unheld initial case; this does not close other attachment cases or the inventories of the other four domains. +All eight preflight roots were geometrically feasible and repeated exactly over 64 actions, with finite full-output likelihood for two roots. +The supported-root construction is now available for inference testing; reliable physical posterior sampling is still a separate requirement. diff --git a/docs/uncertainty/likelihood-cost.md b/docs/uncertainty/likelihood-cost.md new file mode 100644 index 000000000..611d8a765 --- /dev/null +++ b/docs/uncertainty/likelihood-cost.md @@ -0,0 +1,42 @@ +# Quaternion likelihood cost reduction + +September 12, 2026. +The [Fan cost audit](fan-joint-inference.md#rollout-cost-audit) found that likelihood evaluation cost more than candidate initialization or native rollout. +The optimization changes scalar arithmetic inside the existing quadrature integrands, leaving the probability model and quadrature acceptance criteria intact. +It does not change an acting agent or the source snapshots of running fits. + +## Profile and implementation + +Profiling 64 archived Fan orientation readings under the original implementation found 42,189 calls to SciPy's general array `logsumexp` routine. +Those calls consumed 2.23 of the profiled 3.28 seconds. +Every hot integrand call sums exactly two nonnegative density contributions in log space, so general array construction and reduction are unnecessary there. + +The implementation uses the stable scalar identity `max(a,b) + log1p(exp(min(a,b)-max(a,b)))`, with explicit handling when a contribution has log density minus infinity. +Both antipodal contributions remain in the density. +Fixed roll/yaw trigonometric terms and the fixed radial lower bound are also computed once per observation instead of once per quadrature callback. +The tail calculation, adaptive integration, numerical tolerances, ordinary/pole branches and statistical model identity are unchanged. +Runtime source identity still records the implementation change. + +## Validation + +Compute job `22645511` passed 23 existing functional tests, focused mypy and pylint, and the pinned formatting checks. +The functional suite includes independent analytic densities, native Gaussian sampling references, branch probabilities, angular support, the recorded near-pole regression and complete observation composition. +These probability checks complement comparison with the old implementation. + +The same job evaluated every archived orientation case across five domains, four discrepancy scales and two quadrature tolerances. +All 2,560 old/new log densities matched exactly on the measured runtime. +Total unprofiled density time fell from 79.24 seconds to 19.34 seconds, a 4.10-fold speedup. +Method order alternated across paired cases. +The [comparison report](../../logs/uncertainty_orientation_scalar_checks_20260912/parity-22645511.json) retains every value and timing, along with the actual node1387 Intel Xeon Gold 6230 runtime. +Exact equality on these cases is measured evidence, not a universal claim about all floating-point inputs. + +An independent complete-history check, `22645714_0`, reran five saved physical Fan candidates with both implementations on the same worker. +Every public prediction and complete likelihood matched exactly, including the two event-incompatible candidates. +Cold full-likelihood times fell from 4.88-5.01 seconds to 1.35-1.39 seconds, approximately 3.6-fold faster. +Initialization and native rollout still contribute to total inference cost, and active samplers also benefit from cross-candidate caching. +Consequently, the likelihood timing ratio is not a claimed end-to-end sampler speedup. +The [full Fan report](../../logs/uncertainty_orientation_e2e_20260912/pilot-22645714_0.json) records the paired histories, likelihood differences and timings. + +The earlier standalone profile job `22645118` was cancelled while pending after these stronger paired checks and the original-code profile completed. +No running inference experiment was cancelled, restarted or modified. +Subsequent source snapshots can use the checked implementation while retaining all inference-adequacy and prediction gates. diff --git a/docs/uncertainty/observation-reductions.md b/docs/uncertainty/observation-reductions.md new file mode 100644 index 000000000..1ececf998 --- /dev/null +++ b/docs/uncertainty/observation-reductions.md @@ -0,0 +1,99 @@ +# Checked observation reductions + +September 12, 2026. +This records reductions justified by the observation interface for the [uncertainty simplification proposal](simplification-proposal.md). +The original observation ledger remains intact, and the acting agent's observations remain unchanged. + +## Exact deterministic readouts + +Suppose an exactly observed source `q` has an additional deterministic readout `r=g(q)`. +Its likelihood factors as the density or mass of `q`, followed by a conditional point mass at `g(q)`. +If the observed pair is compatible, the readout adds conditional mass one; it is not another independent measurement of `q`. +If the pair is incompatible, its likelihood is zero. +Changing the display scale does not create additional evidence or an extra Jacobian, because the observation measure uses `q` as the source coordinate. + +`ExactReadout` identifies the source field, output field, sensor schema and reviewed map. +Its mapping identity must include the implementation, constants, precision and observation timing, and the map must not depend on parameters being inferred. +The generic interface does not discover or prove those dependencies. + +`reduce_exact_readout` checks the observed pair before producing a reduced observation view. +The source remains in that view and must still contribute its likelihood under the candidate model, including any declared latent output error. +An inconsistent readout returns no reduced view and a negative-infinite factor, so it cannot silently disappear from the calculation. +A missing or noisy source requires a different conditional construction; the function refuses to discard its informative readout. +The original data identity and the readout-map identity both belong in the complete inference identity. + +## Robot finger reading + +The five frozen development programs inherit the standard robot finger readout. +`SingleArmPyBulletRobot.get_state()` first casts the left finger joint into its float32 state vector. +`PyBulletEnv._get_robot_state_dict()` then applies `_fingers_joint_to_state` using the fixed robot and feature endpoints. +The correct map is therefore the runtime's endpoint interpolation applied to `np.float32(observed_left_finger_joint)`. +Skipping that cast changes the exact readout. +Clipping or introducing an observation tolerance is not part of this map. + +The public joint source is index 7 of the observed arm-joint vector in these five Fetch configurations. +Its index and endpoint constants come from each instantiated visible model, not a guessed global convention or evaluator-private state. +The frozen programs do not override the readout or infer its constants. +Other robot configurations or future program changes require a new declaration and audit. + +| Domain | Recorded frames | Exact matches with the runtime map | Mismatches if float32 conversion is omitted | +| --- | ---: | ---: | ---: | +| Bridge | 1,187 | 1,187 | 1,186 | +| Fan | 133 | 133 | 123 | +| Domino | 162 | 162 | 161 | +| Boil | 265 | 265 | 262 | +| Balloons | 236 | 236 | 235 | + +Audit `22634263` verifies all 1,983 frames using the source-derived map and retains the 1,967 failed direct-double readouts as a negative control. +Integration audit `22634439` then applies the new reduction to the same complete public recordings. +All 1,983 reductions preserve their source joint and all other measurements. +All 1,983 deliberately perturbed finger readings are rejected, with no reduced observation returned. +These are observation checks, not physical rollouts or agent seeds. +Artifacts are in `logs/uncertainty_exact_readout_audit_20260912` and `logs/uncertainty_exact_readout_audit_v2_20260912`. +The [integrated assessment](../../logs/uncertainty_exact_readout_audit_v2_20260912/assessment.json) checks that the data and sensor identities match the preceding five-domain output-error diagnostic and lists its remaining exact constraints after this reduction. + +Final compute job `22634508` passed fifteen functional tests, two-file mypy and lint, and pinned formatting checks. +The tests verify source-likelihood preservation, display-scale invariance, precision-sensitive contradictions, missing/noisy-source handling and invalid mapping callbacks. +The first check attempt found a test-lambda type annotation issue; a subsequent attempt was canceled after an overlong assertion was found, and both were corrected before the final checks. +The library implementation used by the recorded integration audit matches the final checked implementation. + +This reduction can remove the separate finger constraint from an inference likelihood after verifying it against the exact joint source. +It does not excuse a discrepancy in the modeled joint reading, supply its posterior density, or make the complete recording compatible. +In particular, the earlier recorded output-error comparison must still model the joint source and retain its remaining orientation, event and speed constraints. + +## Cartesian robot pose is different + +The [cached-link audit](transition-discrepancy.md#preserve-the-native-robot-observation-phase) showed that current joint positions do not directly reconstruct the historical Cartesian robot observation phase. +Calling fresh forward kinematics can change the reported pose without changing the joint readings. +That pose therefore cannot be removed using the finger-readout argument. +The exact robot orientation and Cartesian outputs need a model that respects their actual timing and dependencies. + +## Native Euler readout support + +Compute audit `22634634` completed successfully on `mit_preemptable` and examined the same 1,983 public frames. +It found that the proposed canonical Euler support was too restrictive; its `invalid_angles` field records violations of that proposed support, not invalid environment observations. +All 31 witnesses have zero roll, pitch exactly positive pi/2, and yaw outside [-pi, pi]. +They must remain in the observation ledger. + +| Domain | Ordinary pitch | Pitch exactly positive pi/2 | Pole readings with yaw outside [-pi, pi] | +| --- | ---: | ---: | ---: | +| Bridge | 401 | 786 | 2 | +| Fan | 60 | 73 | 13 | +| Domino | 66 | 96 | 3 | +| Boil | 118 | 147 | 13 | +| Balloons | 28 | 208 | 0 | + +The audit also exercised the installed quaternion-to-Euler conversion directly, using roll 0.4 and yaw 0.7 at both pitch poles. +For both float64 and float32 quaternion inputs, tested pitch offsets through 0.004 radians returned pitch exactly at the pole and roll zero; offsets 0.005, 0.01 and 0.05 did not. +These probes bracket a change in this runtime's readout behavior; they do not identify an exact threshold or establish a probability law for orientations. +The complete probes and recorded witnesses are preserved in [the audit artifact](../../logs/uncertainty_robot_angle_audit_20260912/reference-22634634.json). + +An orientation likelihood must account for this coupled readout, including its collapsed pole regime and native yaw branch. +Three independent continuous angle densities would assign the wrong measure to the pole readings. +Wrapping yaw and discarding its original branch would also change the exact observation target unless that reduction is separately justified. +No such likelihood or reduction is deployed here. +The next construction must retain these observations and validate its induced mass and density factors before using orientation evidence in a full-recording posterior. + +The subsequent [quaternion-output discrepancy construction](orientation-discrepancy.md) now supplies and checks such a coupled likelihood under an explicitly different statistical output model. +It retains both pole branches and integrates their latent input coordinates. +The complete-output composition preserves the remaining exact event and speed failures rather than treating orientation support as sufficient for a physical posterior. diff --git a/docs/uncertainty/offline-fitter-comparison.md b/docs/uncertainty/offline-fitter-comparison.md new file mode 100644 index 000000000..69ea5de4b --- /dev/null +++ b/docs/uncertainty/offline-fitter-comparison.md @@ -0,0 +1,132 @@ +# Offline fitter comparison setup + +September 12, 2026. + +This comparison connects the incumbent's complete fitting pipeline to the same public observation ledger used by the posterior experiments. +It is a cold-fit development comparison on fixed programs, not a reconstruction of historical agent conversations or evidence of closed-loop improvement. +The [simplification proposal](simplification-proposal.md) still requires independent posterior validation, prediction comparisons and matched live runs before retirement. + +## Shared evidence and legacy path + +`RecordingProjection.to_state()` reconstructs object features and public joint/base observations using object handles owned by the visible model. +It preserves the supplied floating-point values, creates new feature arrays and uses canonical object insertion order. +It rejects missing features, unmatched objects, duplicate handles, unknown channels, discontinuous joint indices and incomplete base poses. +It does not read private recording velocities, attachment frames, inferred memory or evaluator state. +Its result is an observation frame for the incumbent fitter, not a feasible physical initial state for joint inference. + +Both paths use the same declared step-keyed noise reconstructed by `load_recorded_level()`. +The adapter verifies an exact projection round trip before a frame reaches the legacy fitter. +The compute preflight checked all 162 Domino frames and 133 Fan frames from the selected first training levels, with no changed or dropped public values. +These recordings contain 161 and 132 actions respectively, each in one reset episode. +Seven functional tests and two-file type/lint/format checks passed in job `22637746`. + +The comparison invokes the frozen approach's actual `_rollout_fit_trajectories()` preparation, followed by `run_rollout_sysid()`. +It retains the configured settled-tail truncation, rest segmentation, noise filter, residual scaling, trimming, identifiability reports, interval handling, evidence calculation and trust selection. +It does not replace the incumbent with a least-squares proxy. +Registry scale stamping and prior anchors are obtained from the same visible model. +The comparison starts with no carried fit history or cache, making the meaning of a cold fit explicit even though the experiment configuration enables carrying for successive fits. +Sequential carried-prior comparisons remain a separate required comparison. + +Reports retain fitted and applied parameters separately. +Subsequent predictions use the applied values merged with the program's declared initial values, which exposes refusals to apply a fit rather than silently evaluating an unpublished optimum. +Predictions replay each complete recorded action sequence from its initial observation using the incumbent's existing rest-start assumption. +The original observation ledger is checked again after fitting to detect unintended mutation. +Worker time, constructed worlds, rollout calls and primitive simulation steps are recorded alongside per-feature prediction errors. +The experiment retains six configured rollout workers and requests six CPUs. +Step and world telemetry is collected at disconnection in each process, so completed child rollouts are included instead of reporting only parent-process work. +Running reports exclude unfinished child worlds until their disconnection; final reports include them. +Jobs `22638339_0` and `_1` verified this accounting for Domino and Fan using four three-step rollouts across two forked workers: each report contained exactly 12 steps and five worlds including the unstepped reference world. + +Canonical object ordering is a declared runtime control shared with the new initialization path. +It is not a claim of bitwise reproduction of historical fit calls with their original insertion order and carried history. +Keep runtime-control changes separate from estimator effects in the final matched agent experiments. +No acting-agent code or production defaults were changed by this comparison setup. + +## Fan prior provenance + +The saved Fan programs have identical dynamics-method syntax after excluding docstrings, but their parameter declarations changed: + +| Version | Initial speed | Declared bounds | Optimizer scale | +| --- | --- | --- | --- | +| 1 | 0.05 | [0, 1] | Log, invalid with zero lower bound | +| 2 | 0.05 | [0.001, 2] | Log | +| 3 | 0.0846 | [0.077, 0.092] | Linear | + +Version 3 explicitly derives its narrow interval from the recorded trajectory. +Reusing that interval as an independent original Bayesian prior would reuse information from the fitting data. +The earliest optimizer declaration also cannot define a proper log-uniform prior on its support: the integral diverges at zero, and the repository rejects the declaration. +The invalid original artifact is retained rather than silently changing its bound or importing it as if executable. + +For the planned posterior comparison, declare `fan_speed ~ Uniform(0, 1)` using the earliest saved support and an explicit normalized density. +This is a new, fixed experimental prior declaration, not a claim that the incumbent had this probability distribution or that optimizer scale determines prior density. +It does not reuse the fitted value or narrow fitted interval as its center or support. +The program itself was synthesized from training data, so this is a fixed-program development comparison, not evidence of prior specification before all observation of the domain. + +The latest executable program remains fixed. +A compute-node audit confirmed that its parameter override accepts 0, 0.5 and 1 exactly without clipping to its current fitting bounds. +The method-syntax check confirmed the same dynamics across all three saved versions. +The legacy arm retains its current declarations and fitting bounds; its prior and deployment policy are part of the incumbent algorithm being compared. +A future ablation must separate the effect of the fixed original prior from uncertain-state inference. +Fan's [full initial-state prior](fan-initial-scene.md), including static layout and articulated switch state, now has a complete-recording support witness. +A numerically adequate joint-posterior approximation remains required before comparing estimators. + +## Submitted comparison jobs + +Array `22638308` is submitted to `mit_preemptable`, pinned to the same `node1412` CPU and explicit Python hash seed as the controlled Domino posterior runs. + +| Array task | Domain | Fitting data | Prediction use | +| --- | --- | --- | --- | +| 0 | Domino | Initial frame and first 64 actions | Remaining 97 actions held out from this fit | +| 1 | Fan | Initial frame and first 64 actions | Remaining 68 actions held out from this fit | +| 2 | Domino | All 161 training actions | Reconstruction; no unused suffix in this recording | +| 3 | Fan | All 132 training actions | Reconstruction; no unused suffix in this recording | + +The suffixes are not established as unseen during historical program synthesis. +All four tasks have now completed and supply incumbent prediction baselines; this does not establish posterior numerical adequacy or an agent advantage. +The frozen worker uses historical runtime `b09217bb3` plus the identified offline modules, the same physics source as the current Domino posterior pilots. +The eight-hour job limit is an external compute cap, not a statistical stopping criterion or evidence that an interrupted fit completed. + +The first preflight failed only on Fan because it assumed every visible environment had `_components`. +The corrected object lookup supports direct environment-owned objects as well as optional components. +The still-pending first comparison array `22637634` was cancelled before execution after that setup failure. +Its still-pending replacement `22637957` was superseded by `22638308` to allocate the configured six workers correctly and include child-process telemetry. +Corrected preflight jobs `22637747_0` and `_1` both completed successfully. +Infrastructure/setup outcomes are not agent seeds. + +Artifacts are in `logs/uncertainty_legacy_preflight_v2_20260912`, `logs/uncertainty_observation_state_v3_20260912`, `logs/uncertainty_legacy_telemetry_20260912` and `logs/uncertainty_legacy_comparison_v3_20260912`. + +## Completed incumbent comparisons + +All four tasks completed successfully on their declared node1412 with their original frozen configuration. +The retained segments are the incumbent's prepared fitting units; segmentation can overlap, so their action counts must not be interpreted as distinct observations. + +| Task | Domain | Supplied actions | Retained segments | Worker seconds | Native steps | Worlds | +| --- | --- | --- | --- | --- | --- | --- | +| 22638308_0 | Domino | 64 | 3 | 214.39 | 13,625 | 563 | +| 22638308_1 | Fan | 64 | 1 | 30.88 | 1,777 | 37 | +| 22638308_2 | Domino | 161 | 6 | 279.03 | 20,753 | 704 | +| 22638308_3 | Fan | 132 | 3 | 52.38 | 4,461 | 119 | + +Both Domino fits predict using the original anchor values: lateral friction 0.674, restitution 0.02, rolling friction 0.006, spinning friction 0.5 and mass 0.1. +The 64-action fit reports four parameters as anchored and restitution as insensitive. +The full-recording fit reports four as anchored and mass as not identified, retaining its anchor for prediction. +Both Fan fits predict with speed 0.0846 and report it as anchored, with legacy belief interval [0.077, 0.092]. +These values describe the incumbent publication policy, not a posterior inference result or proof that the recordings contain no parameter information. + +Each Domino report stores 161 predicted frames and each Fan report stores 132. +The 64-action arms leave suffixes of 97 and 68 actions unused by those fits respectively; the full-recording arms leave none. +These outputs are ready for a matched prediction comparison when replacement inference passes its numerical checks. + +Fan's 64-action cold legacy fit, task `22638308_1`, completed on the originally declared node1412. +Its preprocessing produces one 47-action segment and retains that segment through fitting. +The internal fitted move to approximately 0.085223 is rejected by the incumbent anchor test as data-equivalent; the final applied `fan_speed` remains 0.0846. +The legacy belief interval remains [0.077, 0.092], with its original `anchored` verdict. +Those are legacy quantities, not posterior credible intervals. + +The [completed report](../../logs/uncertainty_legacy_comparison_v3_20260912/pilot-22638308_1.json) includes predictions for all 132 training actions, including the 68-action suffix unused by this particular fit. +That suffix is not certified as unseen during historical program synthesis. +The worker records 30.88 seconds, 1,777 native steps and 37 worlds across parent and child processes. +No adequate replacement posterior is yet available for a matched estimator conclusion. + +An [eligibility review](../../logs/uncertainty_legacy_comparison_v3_20260912/eligibility-review.json) verified a second matching CPU node, but jobs began on their original node before eligibility was changed. +No source, prior or resource changes were applied, and all temporary scheduling holds were released and checked. diff --git a/docs/uncertainty/offline-probability-model.md b/docs/uncertainty/offline-probability-model.md new file mode 100644 index 000000000..e6d78c61d --- /dev/null +++ b/docs/uncertainty/offline-probability-model.md @@ -0,0 +1,146 @@ +# Offline probability model prototype + +Updated September 12, 2026. +This implements part of Stage A in the [simplification proposal](simplification-proposal.md). +It is an offline reference implementation; the acting MB agent still uses the incumbent estimator. +The numerical reference suite and five-domain recording-integrity audit passed. +No five-domain posterior-inference or agent-performance result is claimed. + +## Raw evidence and identity + +[The data module](../../predicators/code_sim_learning/inference_data.py) snapshots scalar measurements, primitive actions, reset episode identities, and observation step indices into immutable tuples. +Repeated identical reads at one step are deduplicated; conflicting reads at that step are rejected. +Equal values at different steps remain separate measurements. +Missing measurements are explicitly absent entries rather than fabricated values or NaNs. +The likelihood requires a prediction for every primitive state index, including the initial state, and checks that episode identities and trajectory lengths match the ledger. + +A level boundary does not by itself define a reset episode. +The recording adapter identifies actual reset markers and must preserve continuous history across any level changes without a reset. +It must also record a unique run identity as part of each reset episode ID. +The separate read-only recording adapter loads explicitly selected flushed continual levels and checks every action against the reset/action log. +Those files store sanitized simulator truth, so the reader requires the run seed and level index and regenerates the agent's step-keyed noise before constructing observations. +The channel coordinates and source implementation join the immutable artifact bundle. +The initial reader incorrectly skipped this transformation; the actual-session regression exposed and corrected it. +Its writer-to-reader tests passed, as did the audit of 1,978 primitive actions from five frozen training levels. + +The statistical identity separates hashes of data, sensor semantics, program and parameter definitions, prior, and runtime configuration. +Sampler seed and numerical settings are stored separately in the result. +Program and runtime hashes are caller-supplied artifact hashes; there is no automatic discovery of imported dependencies or simulator configuration. +Before domain use, the recording adapter must freeze those artifacts and verify their completeness. +A digest alone cannot establish that a caller supplied the correct program or data to a likelihood callback. + +The `from_state` helpers snapshot object feature arrays only. +They exclude simulator metadata, privileged state, and inferred memory. +The recording adapter additionally projects public joint positions and mobile base pose as exact observations. +Extra metadata requires an explicit exclusion reason; unclassified fields cause rejection. +Recorded body velocities and command-weld metadata therefore require a declared modeling decision before a domain comparison. +No inferred memory or privileged payload is accepted as a measurement. + +## Sensor distribution + +For each nonzero declared sensor standard deviation, the log likelihood is + +$$ +\log p(o_f\mid s_f) = -\tfrac12 ((o_f-s_f)/\sigma_f)^2 + -\log\sigma_f-\tfrac12\log(2\pi). +$$ + +Feature classification comes from the same `ObservationNoise.feature_sigma` used by the actual injector. +Angles use the raw stored coordinate difference, including differences across a full turn. +Scalar readings remain unclipped. +No additional motion scale, robust residual, or inferred discrepancy variance is introduced. + +Zero-sigma predicted features are exact constraints: equal values contribute zero log likelihood and contradictions contribute negative infinity. +There is no arbitrary epsilon or tiny Gaussian variance. +This may expose real replay mismatch; that mismatch requires investigation rather than silently increasing sensor noise. +An exact feature may instead be explicitly declared an exogenous conditioned input. +The likelihood then retains its recorded value without scoring it, and the future simulator adapter must explicitly consume that input. +This classification must be fixed before comparison; it cannot be used to dismiss inconvenient prediction errors. +No noisy feature can be declared an exact conditioned input. + +A missing predicted value for an observed output is an error. +A missing measurement adds no likelihood term. +The missingness mechanism is assumed ignorable in these references; informative missingness needs its own model. + +## Joint prior and numerical method + +[The sampler module](../../predicators/code_sim_learning/inference_sampling.py) accepts a named joint vector containing shared dynamics parameters and uncertain initial-state coordinates for reset episodes. +The implemented reference prior is a product of normalized uniform distributions with finite bounds. +Known exact initial values must be conditioned outside this vector. +This is appropriate for the small numerical examples, and is not yet a feasible geometry, attachment, velocity, or memory prior for the physics domains. +Correlated and constrained physical priors remain unimplemented. + +Each call initializes from the same original prior and evaluates the complete batch likelihood. +It never adopts previous posterior weights as the new prior and does not use the first observation as a second prior factor. +The initial prototype deliberately supports only prior initialization, so there is no uncorrected observation-guided proposal. + +The temperature schedule is fixed from zero to one. +At each temperature, previous importance weights are multiplied by the likelihood increment and normalized. +Multinomial resampling occurs only when effective sample size drops below the declared fraction, initially one half of the particle count. +Symmetric Gaussian random-walk Metropolis moves then target that temperature's distribution inside the prior bounds. +Weights are retained through those target-invariant moves when no resampling occurs. +Out-of-support proposals are rejected rather than clipped. +The previous every-temperature resampling prototype failed the symmetric two-mode reference. +The conditional-resampling correction passed the original regression and independent eight-seed comparisons at 1,024 particles. +No tempering step is skipped to meet the evaluation budget. + +The result contains joint samples, retained importance weights, weighted empirical marginal quantiles, the original prior, identity, seed, configuration, evaluation count, pre-resampling effective sample sizes, move counts, resampling count, and surviving initial ancestors. +Quantiles use the inverse weighted empirical CDF and exclude zero-mass samples; they do not interpolate across a gap between modes. +Completion means the configured algorithm reached temperature one, not that it discovered every mode or passed predictive adequacy checks. +Effective sample size and ancestor counts cannot certify exploration or calibration. +Independent runs and comparisons against numerical references remain necessary. + +Budget exhaustion returns a failed numerical result with no posterior samples or intervals. +If initialization finds no finite likelihoods, the status is `no_particle_support`. +That is not proof that the data are impossible: finite prior sampling may have missed feasible support, particularly for exact constraints. +The implementation does not yet recover from that failure by constructing constrained proposals. +Simulator/setup exceptions and invalid likelihood numbers propagate rather than being counted as agent or model failures. + +## Reference checks and remaining gates + +The saved compute script is `logs/uncertainty_probability_20260911/checks.sbatch`. +It includes the following tests: + +- The real noise injector against an independently evaluated Gaussian density, with raw angular differences and exact constraints. +- Cached observations, sparse measurements, trajectory alignment, immutable copies, and invalidation when any statistical identity component changes. +- A stationary unknown position against its analytic Gaussian reference, using actual injected observations and the batch ledger. +- Unknown initial position plus constant velocity against dense grid integration, including their correlation and an independent uninformed parameter. +- A two-mode likelihood, repeated identical fits, strict evaluation budgets, zero sampled support, and simulator exceptions. + +Slurm submission failed because the controller could not be contacted, and a bounded queue query timed out. +No compute validation job ID was obtained. +Before the resampling correction, the stationary Gaussian and correlated position/velocity grid tests passed. +The two-mode test initially exhausted its mistakenly undersized test budget. +With that budget corrected, the same seed assigned 72.2% positive-mode mass where symmetry implies 50%, failing its existing 35%-65% acceptance range. +The acceptance range was preserved, and independent seeds 19, 0, 1 and 2 are now included in the prepared suite. +The new weighted implementation has not yet passed that suite. +A subsequent bounded Slurm submission attempt also timed out without returning a job ID; its submission status cannot be verified from this session. +The earlier scoped checks and the current unvalidated changes are distinguished in [implementation progress](implementation-progress.md). + +Before fitting real recordings, complete these numerical checks, independent-seed and increasing-budget comparisons, feasible initial-state priors, exact-input adapters, and immutable artifact capture. +Exact-constraint support recovery and intentionally incomplete programs also need explicit validation. +Then connect the sampler to the existing candidate replay API and compare held-out predictions across all five domains. +Planning and execution integration remain gated on those results. + +## September 12 validation + +Compute job `22625595` passed the frozen numerical, recording, legacy/replay, type, lint, and formatting checks. +Independent job `22625659` evaluated three references on eight additional seeds and two particle budgets. +All 24 comparisons at 1,024 particles passed the predeclared development tolerances. +One of 24 at 256 particles failed the uninformed-parameter mean tolerance. +These repetitions assess the fixed reference distributions; they are not repeated-dataset calibration experiments. + +Corrected five-domain recording audit `22625932` passed reset/action consistency and exact-constraint checks on reconstructed noisy training observations. +The original audit `22625681` is superseded for statistical use because it projected stored truth directly. +The next physical-prediction preflight explicitly distinguishes replay/setup failures, nominal model mismatch, and statistical inference. +No inference failure is automatically converted into extra sensor noise, dropped observations, or live agent actions. +See [the experiment record](experiments-20260912.md). + +Corrected nominal prediction job `22625933` found exact-output contradictions in all 14 executable fixed-program cases. +Repeated fresh replay was identical, so the failures cannot be dismissed as randomness between those repeated evaluations. +They do not prove that the full latent-state and parameter target lacks support. +Physical-state priors and explicit reconstruction/model-discrepancy diagnostics remain a gate before posterior deployment. + +The final reader correction passed compute validation `22626126`, including the real-session observation roundtrip. +Contact-rich replay audit `22626183` additionally shows that explicit motion restoration is still incomplete: the moving-start balloons case differs by 106.17 mm and changes attachments. +A discrepancy model must not conceal a fixable restoration error; resolve that distinction before fitting or publishing physical posteriors. diff --git a/docs/uncertainty/orientation-discrepancy.md b/docs/uncertainty/orientation-discrepancy.md new file mode 100644 index 000000000..fee068400 --- /dev/null +++ b/docs/uncertainty/orientation-discrepancy.md @@ -0,0 +1,138 @@ +# Coupled orientation output discrepancy + +September 12, 2026. +This is an offline model extension for the [uncertainty simplification plan](simplification-proposal.md), followed by integration with the complete output likelihood. +It changes neither the declared sensor variance nor production agent behavior. + +## Probability model + +The [native readout audit](observation-reductions.md#native-euler-readout-support) found that independent ordinary angle densities cannot describe the robot's exact Euler observations. +The [PyBullet conversion implementation](https://github.com/bulletphysics/bullet3/blob/master/examples/pybullet/pybullet.c) branches on the raw quaternion product `s = 2*(w*y-x*z)` at thresholds +/-0.99999. +Outside the ordinary branch it sets roll to zero, sets pitch to the corresponding pole, and computes a half-angle yaw that can extend beyond [-pi, pi]. +The implementation does not normalize its quaternion input before this calculation. +Native conversion probes and Gaussian draws validate these behaviors against the installed runtime; the linked upstream file alone does not pin that runtime. + +For a native predicted readout, the declared extension introduces a latent quaternion input: + +$$ +Q_t\sim \tfrac12\mathcal N(\mu_t,\sigma_q^2 I_4) + +\tfrac12\mathcal N(-\mu_t,\sigma_q^2 I_4), +\qquad o_t=g(Q_t). +$$ + +Here `g` is the specified raw quaternion-to-Euler map, and the observation is exact conditional on the latent input. +The Gaussian scale is a discrepancy parameter with a fixed value or an original prior; it is not sensor variance. +The sign mixture describes unmodeled quaternion sign while retaining the original yaw branch in the observation. +Errors are independent over primitive steps conditional on the predictions and scale. +The scalar Cartesian discrepancy may separately retain its temporal dependence. + +This is a statistical model of the readout input, not isotropic angular noise on SO(3), a physical force correction, or an exact finite-precision quantization model. +In particular, Gaussian samples are not normalized to unit length. +Normalizing them would change the pole probabilities and require a different likelihood. +These assumptions remain candidates for predictive evaluation, not established properties of real model error. + +The recorded diagnostic constructs `mu_t` from the native predicted Euler triple using `getQuaternionFromEuler` and includes both signs. +This defines a representative center using only the forecast; it does not recover information lost by the earlier Euler readout or copy a recorded hidden quaternion. +Its conversion implementation belongs in the inference runtime identity. + +## Marginalization and numerical checks + +`QuaternionOutputError` in `inference_orientation.py` integrates the latent quaternion input. +The observation measure has a three-dimensional ordinary component and two one-dimensional pole components. +Their densities must be combined with their respective measures, rather than compared as three independent scalar angle densities. + +For an ordinary observed pitch `p`, write quaternion radius as `r` and unit-quaternion pitch as `beta`. +The exact constraint is `r^2*sin(beta)=sin(p)`. +The likelihood integrates both quaternion lifts and all radii from `sqrt(abs(sin(p)))` upward, retaining the resulting `r*cos(p)/8` Jacobian. +For stable quadrature near the endpoint, the implementation instead uses `r^2=abs(sin(p))+u^2`, retaining `u` throughout the inverse map and using `r dr = u du`. +Reconstructing the small difference by subtracting rounded squared radii caused the recorded Balloons action-23 convergence failure; its regression test now passes at both requested tolerances. + +At either pole, the observed raw yaw fixes the x/y polar angle. +The remaining x/y radius is integrated numerically, while the Gaussian z/w half-space probability is integrated analytically. +The yaw change of variable contributes a factor one-half, separately from the antipodal mixture weights. +Yaw retains its native [-2*pi, 2*pi] support. +Wrapping away its branch would combine distinct observations and require summing their densities explicitly. + +Adaptive quadrature operates on scaled log integrands and bounds the omitted radial tail by an integrated Gaussian envelope. +Nonconvergence is an inference computation error, not a zero-likelihood declaration against the physical model. +Quadrature's reported errors do not prove discovery of every possible mode; independent checks and sensitivity to numerical settings remain necessary. + +For a zero-centered four-dimensional Gaussian, an independent analytic reference gives ordinary density + +$$ +\frac{\cos(p)}{16\pi^2\sigma_q^2} +\exp\left(-\frac{|\sin(p)|}{2\sigma_q^2}\right), +$$ + +and density `exp(-c/(2*sigma_q^2))/(8*pi)` on each pole's raw yaw interval, where `c` is the pole threshold. +The ordinary mass and the two pole masses sum to one. +Tests check this reference at three scales, compare noncentral event probabilities with independent Gaussian samples and native conversion calls, preserve antipodal branch mass, and exercise extremely small log densities and invalid support. +The sampling checks use generated numerical cases, not agent seeds. + +## Recorded orientation diagnostic + +Job `22635163` completed on `mit_preemptable` using the five previously saved native 64-action forecasts. +The original fixed-state, noisy-first-frame initialization ablation is unchanged. +The first frame supplies that initializer and is excluded from this conditional forecast diagnostic's likelihood. +There is no inferred physical initial state or fitted simulator parameter in this experiment. + +The original discrepancy-scale prior assigns equal mass to 0.0001, 0.001, 0.01 and 0.1. +Thirty-two actions inform its weights; the following 32 supply the joint future score. +The future suffix is excluded from discrepancy-scale fitting, but it is not established as unseen during historical simulator-program synthesis. +Every prior component is retained; a numerical failure makes the diagnostic unavailable rather than authorizing renormalization over successful components. + +| Domain | Orientation readings scored | Dominant scale after prefix | Future joint log score | Largest per-reading change at tighter quadrature | +| --- | ---: | ---: | ---: | ---: | +| Bridge | 64 | 0.0001 | 331.1062 | 3.20e-13 | +| Fan | 64 | 0.0001 | 345.8598 | 3.06e-13 | +| Domino | 64 | 0.0001 | 91.1091 | 3.55e-13 | +| Boil | 64 | 0.001 | 124.8735 | 5.69e-13 | +| Balloons | 64 | 0.001 | 114.1498 | 7.96e-13 | + +Scores are log densities under the declared mixed observation measure, not solve rates or evidence of improvement over the incumbent. +Tolerances were 1e-7 and 1e-9 for every scale and frame. +All 2,560 density evaluations completed; each tolerance covered 1,280 evaluations. +Artifacts are in [the frozen diagnostic bundle](../../logs/uncertainty_orientation_domains_v3_20260912/plan.json) and [its report](../../logs/uncertainty_orientation_domains_v3_20260912/reference-22635163.json). +Earlier bundles retain the robot-field selection setup failure and the unresolved near-pole quadrature attempts. + +## Complete output composition + +`OutputObservationModel` in `inference_observation.py` combines scalar discrepancy factors, coupled Euler factors, and checked deterministic readouts. +It enforces disjoint measurement assignments, preserves the source likelihood after readout verification, and scores every unassigned measurement under the original sensor model. +Unknown observations, missing predictions, partial Euler readings, duplicate factors, and unsupported noisy Euler readings are explicit errors. +Complete reset episodes retain their initial observation unless a caller explicitly supplies an empty reading for a separately declared conditional diagnostic. +Repeated full-data fits restart the original discrepancy laws. +The original data and the model's complete factor identity belong in the joint inference identity. + +Integration job `22635183` applies this composition to all public output fields in the five 64-action forecasts, conditional on the initial frame. +It fixes scalar discrepancy persistence to 0.9, initial discrepancy to zero, Cartesian innovation scale to 0.005 and joint innovation scale to 0.001. +The orientation discrepancy scale is fixed at 0.001. +These settings are an explicit integration reference, not selected production settings or a fit against the legacy estimator. + +| Domain | Complete conditional output likelihood | Remaining exact witnesses | +| --- | --- | --- | +| Fan | Finite | None in this window | +| Domino | Finite | None in this window | +| Bridge | Zero | One glue-field discrepancy | +| Boil | Zero | Two switch/faucet discrepancies | +| Balloons | Zero | Box speed discrepancies at all 64 actions | + +Every finger reduction is checked against its observed source joint; orientation fields retain their coupled density. +The [integration report](../../logs/uncertainty_observation_domains_20260912/reference-22635183.json) records the full observed-field counts, factor identities and exact witnesses. +No event or speed observation is dropped to make a candidate compatible. +The three zero-likelihood outcomes concern these supplied forecasts; they are not proofs against every parameter and initial state. +The separately reviewed Bridge constant-glue invariant remains the stronger full-recording inconsistency control. + +Fan and Domino now provide positive complete-output cases for the next joint parameter/initial-state comparison. +This does not complete that comparison: feasible uncertain initial-state priors, full runtime capture, the incumbent fitter, prediction assessment and the later planning/retirement gates remain required. +The current production agent remains unchanged. + +## Validation provenance + +Final compute job `22635342` passed 27 functional tests, four-file mypy and lint, and pinned isort, yapf and docformatter checks. +The working implementation and test hashes match that frozen validation snapshot. +The functional suite covers the new orientation and composition code together with the existing scalar-output and checked-readout components; the count is not added to older suites as unique tests. +Earlier attempts preserve the near-pole numerical failure, test type-annotation errors, and static-check issues that were fixed before this result. +The recorded diagnostics used the same corrected likelihood arithmetic before the final callback binding, import annotation, and schema-validation refinements. +Those refinements preserve scores for their valid complete recordings; the final tests additionally ensure that an unknown field is rejected before a contradictory readout could short-circuit validation. +Artifacts for the final checks are in `logs/uncertainty_orientation_v7_20260912`. diff --git a/docs/uncertainty/output-discrepancy.md b/docs/uncertainty/output-discrepancy.md new file mode 100644 index 000000000..6504df3ba --- /dev/null +++ b/docs/uncertainty/output-discrepancy.md @@ -0,0 +1,136 @@ +# Marginalized output discrepancy + +September 12, 2026. +This evaluates an explicit statistical model extension under the [simplification proposal](simplification-proposal.md). +It does not replace the production fitter, certify a physical-state posterior, or complete the five-domain prediction gate. + +## Model and implementation + +For a declared real-valued output of a fixed candidate simulator, write its prediction as `h_t(theta, s0)`. +The discrepancy process is: + +$$ +b_0 \sim \mathcal{N}(0,\sigma_0^2),\qquad +b_t=\rho b_{t-1}+\eta_t,\quad +\eta_t\sim\mathcal{N}(0,\sigma_b^2), +$$ + +$$ +o_t=h_t(\theta,s_0)+b_t+\epsilon_t,\qquad +\epsilon_t\sim\mathcal{N}(0,\sigma_{\mathrm{sensor}}^2). +$$ + +Sensor variance remains its declared value. +The separate discrepancy scale and persistence describe correlated prediction error; they have explicit values or an identified original prior. +They are not recalculated from each residual to make a candidate acceptable. +This is an output-discrepancy model rather than a force correction: the physical simulator state and its subsequent native dynamics remain unchanged. + +Conditional on parameters and an initial simulator state, the entire Gaussian discrepancy history can be integrated analytically. +The implementation in `inference_output_error.py` uses the product of causal predictive densities to evaluate that marginal likelihood. +This avoids sampling a new error coordinate for every primitive step while retaining its temporal dependence. +The batch target can therefore infer simulator parameters, uncertain initial states and discrepancy hyperparameters together using the existing fixed-prior sampler. +It is the marginal of the declared augmented model, not the deterministic sensor-only target with troublesome observations removed. + +Exactly observed continuous outputs condition the corresponding latent discrepancy and retain its Gaussian density. +Their filtered discrepancy variance becomes zero at the observation boundary; later innovations can make it positive again. +When both discrepancy and sensor variance are zero, an unequal prediction remains an exact contradiction for that supplied candidate history. +That does not prove that all parameters or initial states are inconsistent. +Missing readings add no evidence but still advance the process by each intervening primitive step. +Future forecasts propagate the error distribution without consuming future observations. +Repeated full-data fits start from the same original error law, rather than using the previous filtered error as a new prior. + +The returned error moments are causal filtering marginals. +They are not independent joint-history samples, smoothed states, or corrected physical scene states. +Events, bounded outputs, angle branches and coupled kinematic constraints require separately justified observation models. +This scalar Gaussian construction is not automatically appropriate for those quantities. + +## Independent numerical references + +Functional tests compare the likelihood and each causal conditional against dense multivariate Gaussian integration, including missing readings, negative persistence, random walks and exact observations. +They also verify future-prefix separation, repeated-fit identity, deterministic contradictions and explicit numerical failure. + +Compute reference `22633541` fits constant-velocity dynamics with an uncertain initial position, a log-uniform innovation scale, and an independent uninformed parameter. +The original box prior remains fixed. +Discrepancy persistence is 0.85, initial discrepancy is zero, and sensor standard deviation is 0.03. +Eight generated observations enter the fit; four additional observations are used only for predictive scoring. +The target is checked against independently constructed dense Gaussian grid integrals at 81 and 161 cells per informed coordinate. +The finer grid has 4,173,281 cells across velocity, initial position and log innovation scale. +Grid changes pass the limits declared before the sampler trials. + +| Particle budget | Independent seeds | Reference checks passed | +| --- | --- | --- | +| 512 | 100, 101, 102, 103 | 3/4 | +| 2,048 | 100, 101, 102, 103 | 4/4 | + +The failed 512-particle trial has an uninformed-parameter mean of -0.186 instead of zero and median of -0.251 instead of zero. +Its final weight ESS is approximately 292, demonstrating why ESS alone cannot establish approximation reliability. +The failure remains part of the result and does not justify increasing the claimed accuracy of that budget. +These are numerical sampler runs on one generated dataset, not agent seeds or repeated-dataset coverage evidence. +Artifacts and the predeclared limits are in `logs/uncertainty_output_error_reference_20260912`. + +## Five-domain recorded-data diagnostic + +The first real-data comparison uses the same frozen first training levels as the earlier state inventory. +It fixes each selected simulator program and its declared nominal parameters, then compares independent versus persistent output error. +This isolates the discrepancy model; it is not a comparison against the full legacy parameter fitter. +Bridge and Boil retain their frozen no-op programs as incomplete controls. + +The initializer uses model-owned handles and the first noisy public frame in an explicit fixed-state rest-start ablation. +It is not an uncertain-initial-state posterior or a proof of feasible geometry. +The first reading is consumed by initialization and omitted from discrepancy fitting. +All later states come from uninterrupted native simulation, preserving its cached-link observation timing. +Fresh repeated trajectories are compared exactly. + +Each selected channel is an object or robot Cartesian coordinate, or a current joint position. +The diagnostic infers an innovation scale separately for each channel under a fixed log-uniform prior: 0.0001 through 0.05 for Cartesian coordinates and 0.00001 through 0.02 for joint-coordinate units. +The independent arm fixes persistence to zero. +The persistent arm averages over persistence values 0.5, 0.9 and 0.99 with equal prior mass. +Initial discrepancy is zero in both arms. +The original sensor variance stays unchanged. + +Thirty-two actions enter the discrepancy fit, followed by a 32-action suffix held out from that fit. +Both arms marginalize their hyperparameters using the same log-scale quadrature grid. +The suffix joint log score integrates future discrepancy states; its forecast means and probability-integral-transform values use only the fitted prefix. +Moving objects, robot outputs and static fixtures are reported separately. +Central 90% predictive coverage is evaluated from the mixture CDF, rather than a Gaussian interval fitted to mixture moments. +These are descriptive coverage counts on correlated observations, not calibration estimates from independent datasets. + +The selected program versions are frozen historical learned artifacts; the suffix is held out from discrepancy-hyperparameter fitting, not established as unseen during those programs' synthesis. +This diagnostic therefore does not establish generalization of program learning or satisfy the final held-out evaluation requirement. +Unmodeled exact outputs remain explicitly listed, including events and Balloons speed. +Their failures prevent treating the selected-channel analysis as a posterior over the full recording. + +The native trajectories and first grid report are in `logs/uncertainty_output_error_domains_20260912`. +A doubled-grid analysis reuses the exact saved prediction and observation artifacts, without rerunning physics, in `logs/uncertainty_output_error_domains_grid_20260912`. + +Native replay job `22633841` completed all five domains, and every fresh repeat matched exactly over the 64-action history. +Grid-sensitivity job `22633902` doubled the innovation-scale grid from 41 to 81 cells. +The table below reports moving-object Cartesian channels only. +A positive score difference favors persistent discrepancy over independent discrepancy; it is expressed in nats per scalar future observation to make the differing channel counts visible. + +| Domain | Future scalar observations | Log-score gain per scalar | Independent 90% coverage | Persistent 90% coverage | +| --- | ---: | ---: | ---: | ---: | +| Bridge | 576 | +0.0244 | 523/576 | 540/576 | +| Fan | 96 | +0.1200 | 88/96 | 91/96 | +| Domino | 576 | +0.0858 | 522/576 | 536/576 | +| Boil | 96 | +0.0423 | 85/96 | 85/96 | +| Balloons | 384 | +0.2267 | 332/384 | 354/384 | + +The moving-object score gains change by less than 0.001 nats in total per domain when the grid doubles. +Robot channels are less favorable and more sensitive to quadrature: Balloons' persistent model loses approximately 37.23 nats in total, and Bridge's coverage falls from 299/384 to 271/384 despite a better joint score. +Individual robot-channel scores change by as much as 1.42 nats when the grid doubles, so their absolute numerical values are not certified by this sensitivity check. +These mixed results argue for retaining channel-specific diagnostics rather than adopting a persistence rule solely from a pooled gain. +All five cases still have unmodeled exact-output disagreements, preventing a full-recording posterior from this selected-channel model. +The [recorded assessment](../../logs/uncertainty_output_error_domains_grid_20260912/assessment.json) retains every group and the grid changes. + +The final component checks, job `22633903`, passed fifteen functional tests, two-file mypy and lint, and pinned formatting. +An earlier check caught a missing local type annotation; the final result also names a supplied-history failure `exact_contradiction` to avoid implying a proof against every parameter or initial state. +The numerical and recorded diagnostics exercise successful likelihood paths, whose arithmetic is unchanged by that annotation and failure-label refinement. + +## Remaining acceptance work + +The scalar likelihood and small numerical posterior can be checked independently of the real-domain initial-state and support questions. +For the full agent replacement, those questions remain required. +This extension also needs a justified complete output model, stable real-data numerical inference, and predictions on interactions held out from all relevant fitting and model selection. +Improvements on selected Cartesian channels cannot establish that exact event predictions, contact behavior, parameter coverage or agent performance are adequate. +Only subsequent comparisons under the proposal's gates can select a replacement for production. diff --git a/docs/uncertainty/parameter-consumers.md b/docs/uncertainty/parameter-consumers.md new file mode 100644 index 000000000..120adb8b1 --- /dev/null +++ b/docs/uncertainty/parameter-consumers.md @@ -0,0 +1,98 @@ +# Parameter consumers of assessed inference + +September 12, 2026. +The [simplification proposal](simplification-proposal.md#3-standardize-the-inference-result-evaluate-the-approximation) requires credible intervals and planning ensembles to come from the same joint posterior. +[ParameterPosterior](../../predicators/code_sim_learning/inference_parameters.py) now implements that consumer boundary beside the incumbent adapter. +It does not select an estimator, publish a parameter update, change a prompt or approve a plan. + +## One approximation, explicit projections + +Construct a view from `AssessedInference` and an explicit parameter schema. +Simulator parameter names may differ from joint inference coordinates: + +```python +view = ParameterPosterior( + assessed, + names=("mass", "friction"), + coordinates=("theta.mass", "theta.friction"), +) +quantiles = view.marginal_quantiles((0.05, 0.5, 0.95)) +weighted = view.weighted_samples() +parameter_maps = weighted.as_dicts() +``` + +If coordinates are omitted, they equal the supplied names. +No prefix stripping, marginal refitting or parameter-name inference occurs. +The mapping may explicitly tie two simulator parameters to the same inferred coordinate. + +`marginal_quantiles` uses the source approximation's weighted empirical CDF and its existing inverse-CDF convention. +`weighted_samples` projects complete rows onto the requested parameters, removing only zero-mass rows and retaining the remaining original weights. +It preserves dependence across parameters, including separated modes and nonlinear relationships. +Dropping episode initial-state coordinates performs a parameter marginal projection; it does not independently recombine parameter values. +This operation also does not provide a joint belief over parameters and the current execution state. +The proposal's initial planning comparison still retains the existing execution estimator, while any conditional execution-state extension remains separate. + +Every returned ensemble records the inference identity, assessment-protocol identity, simulator names, source coordinate names, source particle indices, weights and predictive checks. +Parameter dictionaries returned to a simulator are owned copies. +Empty parameter schemas yield explicit empty maps without inventing a fitted parameter. + +For consumers that require equal weights, `resample(count, seed)` draws complete rows multinomially with an explicit local RNG seed. +The output records that seed, equal weights and the original particle indices, including repeated selections. +This adds Monte Carlo error to the represented approximation and does not increase its information or discover missing modes. +`expectation(outcomes)` computes the weighted expectation of one finite outcome per row. +Boolean outcomes give success probability under that represented ensemble, not a guarantee or an action decision. +Stress-test candidates and their outcomes must remain separately labeled; these weights do not assign them posterior probability. + +## Information seeking from the same weights + +`ParameterEnsemble.atom_information(read_probabilities)` applies the existing information-seeking criterion to the ensemble's own weights. +Rows must follow `as_dicts()` order, columns identify atoms, and each entry gives the probability of reading that atom as true under the declared observation channel. +Binary entries represent exact reads. +The caller remains responsible for evaluating the same atoms and observation channel under each parameter map. + +For each atom, the score is `H(sum_k w_k p_k) - sum_k w_k H(p_k)`, averaged over atoms. +Both terms use the same posterior mass, avoiding an implicit conversion of unequal posterior weights into equal member counts. +This is the existing mean of per-atom information scores, not the joint information in reading all atoms together. +It does not define a new probe threshold, approve an action, or remove predictive failures attached to the ensemble. + +The existing `mean_bernoulli_entropy` and `noisy_read_information` helpers accept optional normalized member weights. +The weighted path checks probabilities, dimensions and finite nonnegative masses, and refuses unnormalized inputs rather than repairing them. +A valid ensemble with no atom columns scores zero; an empty or malformed weighted ensemble is rejected. +Omitting weights retains the incumbent arithmetic and call sites. +Acting-agent routing remains unchanged until the offline inference and saved-decision gates pass. + +## Numerical and predictive status + +Construction checks assessment/posterior identity, original-prior identity, source coordinates and the declared numerical assessment protocol. +It also validates the completed source sample structure through the existing assessment boundary. +An unavailable or unevaluated result retains its diagnostics but raises `UnavailableParameterPosterior` if a consumer requests samples or quantiles. +Sampler completion alone does not authorize access through this view. +The boundary does not choose adequate numerical criteria or verify the scientific sufficiency of a caller's protocol. + +A predictive failure remains attached to a numerically available posterior and every derived ensemble. +It does not silently remove that posterior, turn uncertainty into a deployment verdict or excuse a contradictory exact constraint in the fitted target. +Decision thresholds, stress tests, model revision and parameter publication remain separate responsibilities. +Malformed weights, sample dimensions, source indices and requested outcomes fail explicitly. + +## Validation and integration gate + +The tests use an exact weighted multimodal reference with a nonlinear parameter relation, a zero-mass outlier and a nuisance initial-state coordinate. +They verify source quantiles, weights, row provenance, explicit name mapping, shared coordinates, resampling frequencies and preservation of parameter dependence. +Unavailable numerical results and predictive failures exercise distinct paths. + +An end-to-end numerical test fits a noisy sum observation under a three-coordinate prior, checks analytic mean references, passes that result through assessment and evaluates the resulting joint parameter ensemble. +The ensemble retains the learned relation between the two parameters, while the unused coordinate retains its prior mean. +This is a small numerical consumer test, not a protocol sufficient to certify a physical-domain posterior. + +Compute job `22642559` passed all 19 functional tests, focused mypy and pylint, and pinned formatter checks. +The checked source hashes match the committed module and test files; the frozen check manifest and output are in `logs/uncertainty_parameter_view_checks_v6_20260912`. + +The weighted information extension passed 61 functional tests, four-file mypy and pylint, and pinned formatter checks in compute job `22650348`. +Its independent reference enumerates the joint distribution of parameter member and binary read, including unequal masses, irrelevant zero-mass rows and identical-member splitting. +The assessed-posterior integration test retains a predictive failure while producing the analytically expected information score through the shared ensemble. +All 768 default-path comparisons with the frozen incumbent matched exactly, including empty atom sets and unanimous or uncertain reads. +Artifacts and checked source hashes are in `logs/uncertainty_weighted_information_checks_20260912`. +These checks establish scoring and interface behavior, not physical-domain posterior adequacy or unchanged solve rates. + +These APIs are ready for offline and saved-decision comparisons once the source inference passes its numerical and predictive investigations. +No acting agent has been routed through them, and no legacy mechanism has been retired. diff --git a/docs/uncertainty/robot-state-prior.md b/docs/uncertainty/robot-state-prior.md new file mode 100644 index 000000000..223db0899 --- /dev/null +++ b/docs/uncertainty/robot-state-prior.md @@ -0,0 +1,78 @@ +# Robot initial-state prior and conditioning + +September 12, 2026. +This offline component implements the robot-motion portion of the [initial-state inventory](initial-state-inventory.md). +It does not replace the acting agent's state estimator, establish collision-free full scenes, or solve exact trajectory constraints. + +## Explicit initial-state assumptions + +[JointStatePrior](../../predicators/code_sim_learning/inference_joints.py) requires an entry for every joint in URDF order. +Mechanically fixed joints have position and velocity zero and no free coordinates. +Every movable joint has an explicit position prior: finite uniform bounds or the later Gaussian reset-law extension. +A positive velocity half-width specifies a normalized uniform initial velocity distribution; zero specifies a prior atom at rest. +The component declares independence among these coordinates. +A full scene prior must justify dependencies, contact compatibility, and probabilities of its motion cases separately. + +The interface deliberately does not infer position bounds or motion from recording omissions. +A URDF continuous joint needs a declared prior over its winding as well as its physical orientation; the API never wraps an exact observed angle into a preferred interval. +URDF limited-joint intervals are possible modeling assumptions, not automatically valid hard support for the simulator's recorded initialization. +The balloons counterexample below demonstrates why that distinction matters. + +`condition_positions` takes exact initial joint measurements and eliminates those coordinates. +The remaining independent coordinates keep their normalized original distributions. +Each conditioned movable position contributes its original uniform or Gaussian density; a fixed joint's exact zero contributes unit mass. +`log_observation_factor` retains these factors, including when every coordinate is determined and no sampler is needed. +The factor can matter when comparing components with different position priors and must not be silently dropped. +No later observation is substituted into a rollout by this operation. + +An out-of-support initial measurement raises `IncompatibleJointObservation`. +This means that the declared component assigns zero support to that measurement. +It does not mean a finite sampler missed feasible particles, that every possible prior is inconsistent, or that the agent failed its task. +Other malformed inputs raise ordinary validation errors. + +## What the Fetch inventory permits + +The five audited visible simulators share the same Fetch URDF: nine observed movable joints, four unobserved movable joints, and eleven fixed joints. +The unobserved joints are the two wheels and head pan/tilt. +After conditioning on the nine measured positions, the explicit components have: + +| Component | Unknown positions | Unknown velocities | Total free coordinates | +| --- | ---: | ---: | ---: | +| Declared instantaneous rest | 4 | 0 | 4 | +| Declared moving robot | 4 | 13 | 17 | + +These are dimensions of the component conditional when the readings lie in its support. +They are not a completed full-scene posterior dimension, a claim that the robot really starts at rest, or a reason to keep coordinates that a stronger program-and-scene invariant could legitimately eliminate. + +The geometry audit changes the head configuration while keeping the observed arm/gripper positions fixed. +Public robot features remain identical, but a 1 cm-radius probe intersects the head in one configuration and is separated from it in the other. +The first witness is about 5.62 mm inside one collision envelope and 210.05 mm outside the other. +This demonstrates that identical forward kinematics does not imply identical collision behavior. +The intersecting probe is a geometric witness, not a proposed valid penetrating initial scene or evidence that a historical task hit the head. +Eliminating these joints would require proof that their geometry and all program reads are irrelevant throughout the declared scene support and action history. + +## A real initial state outside an assumed hard bound + +The frozen original balloons training recording starts its shoulder-lift joint at `-1.5119263197144368` rad. +The matching URDF limit interval is `[-1.221, 1.518]` rad. +Both rest and moving components using that interval therefore reject the initial observation, before candidate sampling. +The other four audited recordings admit their exact initial positions under the same declared rules. + +The robot wrapper restores positions through `resetJointState`; the prior cannot treat an idealized joint-limit interval as a proven invariant of all simulator reset states. +This result does not justify clipping the observation, changing the archived task, silently widening the prior, or relabeling initial joint positions as external inputs to make this target pass. +A broader initialization law or an explicitly justified conditional-input formulation would be a different declared model and needs its own validation. +The rejected component remains a negative control. +The later [Gaussian reset-law and scene-composition reference](scene-prior-composition.md) covers this initial position under a separately declared prior, while retaining its original reading density. +That support result does not validate the Gaussian law's calibration or replace the full-scene and trajectory checks. + +## Validation boundary + +The numerical tests check exact conditioning, retained observation density, unobserved position/motion marginals, deterministic conditionals, and rejection without clipping or wrapping. +The visible-model audit uses the previously verified public initial joint readings and checks the URDF byte identity. +It restores generated joint states through the engine and checks the actual joint values and public robot features. +No hidden body motion, task generator, or live evaluator state supplies prior values. +The geometry witness uses synthetic probe positions solely to test whether hidden head configuration can matter for collision geometry. + +These checks validate the component's declared semantics. +They do not certify that independent joint and assembly priors form a collision-free scene or that a learned simulator can satisfy all later exact proprioception. +See [the experiment record](experiments-20260912.md#robot-prior-conditioning-and-hidden-joint-geometry). diff --git a/docs/uncertainty/sampler-checkpoints.md b/docs/uncertainty/sampler-checkpoints.md new file mode 100644 index 000000000..4a645006b --- /dev/null +++ b/docs/uncertainty/sampler-checkpoints.md @@ -0,0 +1,65 @@ +# Resumable offline inference + +September 12, 2026. + +The full-recording Domino and Fan fits take hours on preemptable compute nodes. +Previously, interruption lost the sampler population, even when a diagnostic report retained its best candidate. +A best candidate cannot reconstruct the weighted population or its random stream. + +`sample_batch` now accepts an optional checkpoint callback and an optional saved continuation record. +It emits a record after successful initialization and after each complete temperature stage. +Each record preserves proposal coordinates, mapped joint states, base-density factors, likelihoods, weights, ancestry, random-generator state, evaluation counts, acceptance counters, resampling counts and stage diagnostics. +Resuming starts at the next stage without evaluating the saved population again. + +```python +from pathlib import Path + +from predicators.code_sim_learning.inference_checkpoint import SamplerCheckpoint +from predicators.code_sim_learning.inference_sampling import sample_batch + +checkpoint_path = Path("/shared/run/sampler.json") +resume = (SamplerCheckpoint.load(checkpoint_path) + if checkpoint_path.exists() else None) +result = sample_batch( + prior, identity, likelihood, config, seed, + condition=condition, + checkpoint=lambda record: record.save(checkpoint_path), + resume=resume, +) +``` + +Use a distinct path for each identified run and serialize writes to that path. +The parent directory must already exist. +Saving writes and flushes a temporary file in the same directory before replacing the previous file atomically. +Loading checks the file checksum and schema and uses JSON rather than executable deserialization. + +## Compatibility and result semantics + +The continuation signature includes the data, program, sensor model, original/conditional prior, declared runtime, seed, NumPy version, full sampler configuration and checkpoint kernel version. +A mismatch rejects the checkpoint before evaluating a candidate. +Callers remain responsible for identifying their callback code, simulator dependencies and execution controls in the runtime identity. +The checkpoint does not discover missing dependencies or make nondeterministic callbacks reproducible. + +The evaluation limit remains cumulative across a resumed numerical run. +An interruption inside a stage loses that unfinished stage and repeats it from the last complete boundary. +The result's evaluation count describes the retained numerical run; job telemetry must separately account for discarded work and repeated initialization of the simulator process. +There is no checkpoint before the initial population has been evaluated successfully. +This first implementation does not continue from the middle of a Metropolis move or automatically extend a budget. + +A continuation record is solver state, not an inference result. +It does not expose marginal quantiles, pass numerical assessment, publish parameters, or approve actions. +Budget-exhausted and unsupported results still contain no posterior samples. +Even a completed sampler result must pass the separate numerical assessment before parameter consumers can use it. + +Running fits retain their frozen source and do not acquire checkpoint support retroactively. +Future launchers must opt into persistence explicitly; the API does not submit, resume or modify Slurm jobs. + +## Validation + +Compute job `22646779` completed successfully on `mit_preemptable`, with artifacts in `logs/uncertainty_sampler_checkpoint_20260912`. +All forty functional tests pass, including interrupted disk round trips for ordinary and conditional priors, exact preservation of the final population and diagnostics, compatibility rejection, corrupted files, failed atomic replacement and cumulative budget handling. +Three-file type checking, lint and the pinned formatting checks also pass. + +All thirty-two paired comparisons with the frozen pre-change sampler match exactly, including samples, weights, diagnostics and evaluation counts. +These cover eight seeds, ordinary and conditional priors, and complete and budget-exhausted fits, with a multimodal target, zero-support candidates, blocked moves and a nonuniform temperature schedule. +The tests establish continuation and unchanged numerical behavior for these references; they do not establish adequate exploration of the physical-domain posterior targets. diff --git a/docs/uncertainty/sampling-reproducibility.md b/docs/uncertainty/sampling-reproducibility.md new file mode 100644 index 000000000..c3e08cb49 --- /dev/null +++ b/docs/uncertainty/sampling-reproducibility.md @@ -0,0 +1,79 @@ +# Joint-sampling reproducibility and conditioning + +September 12, 2026. + +The first [Domino pilots](domino-joint-inference.md) completed but lost parameter diversity. +Follow-up diagnostics also found that their uncontrolled process initialization prevented a clean numerical repeat-run comparison. +The results below separate those issues before another inference experiment. + +## Reproducing a physical candidate + +The first weight audit, jobs `22636950_100` and `_101`, regenerated the same unit-coordinate candidate sequences but obtained different finite-likelihood counts from the original pilots. +That audit omitted the original preflight lifecycle, so it was not sufficient to attribute the difference. +Jobs `22637147_200`, `_201` and `_202` restored that lifecycle and used the same candidate RNG seed 100 while controlling the Python hash seed. + +The initializer constructed its object dictionary from a set. +The environment copied that insertion order into its object list during `_set_state`, so hash order could affect the ensuing physical replay. +With different hash seeds, 28 of 32 prediction digests differed. +Even with hash seed 0 in both processes, four candidates differed slightly in their generated physical poses, and four prediction digests differed. +The pose differences were in the last floating-point bits, including an approximately 1e-16 m position difference. +The sampled unit coordinates and fitting-data identity were identical. +This observation does not identify a specific math-library routine as the cause, but it rules out assuming bitwise identical coordinate transforms on all compute nodes. + +The corrected control sorts objects by name and type before semantic initialization and supplies identical saved physical candidates rather than recomputing their quantiles. +Jobs `22637238_200`, `_201` and `_202` replayed 31 such candidates on AMD EPYC 7542, AMD EPYC 9474F and Intel Xeon Platinum 8462Y+ CPUs, using hash seeds 0 and 1. +Every physical-candidate record, full 65-frame prediction digest and complete log likelihood matched exactly across all three processes. +Twelve of those candidates had finite complete likelihood in each process. +The remaining candidates contradicted exact outputs consistently; they were not setup failures. + +This closes the tested cross-process replay discrepancy when physical candidate values and initialization order are fixed. +It does not establish complete runtime closure, hardware-independent candidate quantile generation or portable mid-trajectory engine restoration. +New sampling runs therefore use canonical object order, explicit hash seed 0 and the same declared compute node/CPU for independent chains. +Those controls are included in the experimental runtime identity, and the worker checks the hash seed and CPU model before fitting. +The incumbent environment and its action behavior have not been changed. + +## Separating initial conditioning from future evidence + +The regenerated weight diagnostics show concentration before a substantial future-likelihood temperature is applied. +For the two same-hash seed-100 processes, the effective sample size of prior/proposal weights restricted to finite-likelihood candidates was approximately 1.71 before tempering. +An informative proposal around the first observation can have poor importance weights against the unconditioned physical prior even though it is useful for the final posterior. +Merely choosing a smaller first temperature does not remove that initial weighting problem. + +The next experiment changes the intermediate sampling distributions while preserving the complete target. +Writing the full likelihood as `L(o0, o1:T)` and the initial-frame likelihood as `L0(o0)`, it uses: + +- Initial base weight: `(original prior / proposal) * L0`. +- Remaining likelihood: `L(o0, o1:T) / L0`. + +The product is exactly the original full-data importance weight. +The fixed original parameter prior and the physical initial-state prior are unchanged. +The initial observation enters once, and the remaining factor is conditional on that observation, including dependencies represented by the output-discrepancy model. +An initial observation with zero likelihood rejects the candidate before this division; undefined conditional likelihoods are not assigned an invented value. +Geometric and exact-output support constraints remain in force. +This construction is an initial-observation-conditioned sampling path, not a new prior centered on a previous fit. + +## Declared temperature schedules + +`SamplerConfig.temperature_schedule` optionally supplies a fixed, strictly increasing sequence ending at one, with the configured stage count. +An empty sequence retains equally spaced temperatures and the original random stream. +Reweighting uses the actual temperature increment, and Metropolis moves target the corresponding distribution with the same conditional-base factors. +The schedule changes exploration, not the final posterior or the evaluation budget. +It is not an adaptive-temperature implementation or an adequacy certificate. + +Job `22637164` passed 19 functional tests, two-file type checking and lint, pinned formatting, and eight exact default-result comparisons against commit `4455bdb1a`. +The new nonlinear-schedule reference checks a narrow conditional Gaussian against its analytic mean and variance while verifying an uninformed parameter's prior marginal. +Explicit linear schedules also reproduce the implicit default exactly, apart from the configuration field itself. + +## Next physical experiment + +Jobs `22637359_100` and `_101` use the initial-conditioned path, 64 particles, 32 cubic-spaced temperatures, eight moves per stage and at most 16,448 target evaluations per run. +Physical parameters move individually; body horizontal position, supported yaw/moving height, and other body coordinates use separate declared groups. +The rest/moving prior, complete observation model and 64-action fitting window are retained. +The runs are pinned to `node1412` on `mit_preemptable` to keep candidate transforms on the same CPU model. + +Independent-run agreement, parameter movement, weight concentration, budget sensitivity and held-out prediction still need assessment. +Completion or a higher acceptance count cannot establish numerical adequacy, especially when some moves change only auxiliary coordinates. +The older pilots lack these runtime controls and cannot serve as a controlled sampler baseline. +These are offline inference experiments, not new agent seeds, and they do not authorize deploying or retiring either fitter. + +Frozen scripts, configurations and reports are in `logs/uncertainty_domino_weight_audit_20260912`, `logs/uncertainty_domino_process_audit_20260912`, `logs/uncertainty_domino_fixed_candidates_20260912`, `logs/uncertainty_schedule_20260912` and `logs/uncertainty_domino_initial_conditioned_20260912`. diff --git a/docs/uncertainty/scene-prior-composition.md b/docs/uncertainty/scene-prior-composition.md new file mode 100644 index 000000000..6f081c060 --- /dev/null +++ b/docs/uncertainty/scene-prior-composition.md @@ -0,0 +1,122 @@ +# Reset-compatible priors and whole-candidate feasibility + +September 12, 2026. +This extends the offline [joint-state](robot-state-prior.md) and [assembly](assembly-prior.md) components. +It resolves two incorrect hard-support assumptions in the generated physical reference, while keeping their failed versions as controls. +It does not change the acting agent or establish a fitted historical-domain posterior. + +## A declared reset law instead of an ideal joint-limit bound + +The robot wrapper writes joint positions through `resetJointState`. +The vanilla IK routine repeatedly resets its candidate and checks end-effector position for convergence; that validation is not a certificate that every returned joint respects the URDF interval. +A mechanical reproduction passes the recorded initial joint vector through the actual wrapper and reads it back exactly, including the balloons shoulder angle below the URDF lower limit. +Changing that archived reading or the agent's initialization is unnecessary for this inference experiment. + +`JointStatePrior` now accepts either finite uniform position bounds or `GaussianJointPosition` distributions through its `position_priors` field. +Gaussian positions describe a simulator initialization law over the real-valued joint coordinate, including winding. +They do not wrap angles or assert an ideal hardware joint limit. +Exact measured positions contribute their original Gaussian log density when conditioned out, and the other coordinates keep their original distributions. +Unobserved Gaussian positions use uniform quantile coordinates; zero-measure endpoints have no finite lift and are rejected explicitly, while unrepresentable numerical results raise arithmetic errors. +The implementation uses the standard-library normal inverse CDF. + +The new reference declares zero-mean Gaussian position priors with standard deviation pi radians for revolute coordinates and 0.1 m for prismatic coordinates. +These are engineering assumptions, fixed in the experiment plan rather than centered or fitted on each observed joint value. +The Gaussian family was introduced after identifying the bounded model's failure on development data, so positive support on these same recordings is not independent validation of its calibration. +The original bounded component still rejects the balloons start and remains a distinct negative control. +The new joint-prior schema identifies the changed family and representation; it does not reuse the old prior's identity. + +## Collision conditioning must apply to the whole draw + +Suppose a normalized base prior draws a complete candidate `x`, including any component case, robot state, and assembly state. +For a declared feasibility predicate `C`, rejecting the entire draw unless `C(x)` holds produces: + +``` +p(x | C) = p0(x) * I[C(x)] / Z +Z = Pr_p0(C) +``` + +[draw_feasible](../../predicators/code_sim_learning/inference_feasibility.py) implements this procedure with an explicit draw budget and separate identities for the base prior and support policy. +A failed search returns `budget_exhausted`, with no completed sample batch; it is not proof of empty support. +Predicate and setup errors propagate instead of becoming collision rejections. +These outputs are prior draws, not posterior samples. + +Rejecting only an offending body while keeping the rest of the sample generally changes the distribution. +Likewise, normalizing feasibility separately within each mixture case preserves different case probabilities from global rejection. +For example, equally likely cases with feasibility probabilities 0.2 and 0.8 have accepted probabilities 0.2 and 0.8 under global rejection. +The numerical tests check this result and the dependence induced by a triangular joint constraint. + +The acceptance fraction is not an exact normalizing constant or a model-evidence estimate. +A common `Z` cancels in ratios for one fixed posterior target, but a parameter-dependent or case-dependent normalizer cannot silently be discarded. +This sampler neither asserts that `Z` is parameter-independent nor implements evidence comparison across differently normalized models. +A full generative scene model still has to specify how observation conditioning, component probabilities, geometry, and dynamics parameters interact. + +## Connecting feasible scenes to parameter inference + +`FeasibleConditioning` now carries that distinction into the existing offline batch sampler. +It wraps an exact-conditioning map, evaluates feasibility on the complete lifted candidate, and preserves the map's observation-density and proposal corrections. +It adds no resampling or alternative inference algorithm. +The caller must explicitly choose which original distribution is intended: + +| Declared law | Original distribution | Additional conditional-base factor | +| --- | --- | --- | +| `global_joint` | `p0(theta, s) I[C(theta, s)] / Z` | The feasibility indicator; one global constant cancels within this posterior. | +| `conditional_state` | `p0(theta) p0(s given theta) I[C(theta, s)] / Z(theta)` | The indicator and `1 / Z(theta)`, retaining the declared parameter marginal before observations. | + +For `conditional_state`, `Z(theta)` is the support probability under the original state law before observing the data. +It is not the acceptance rate after conditioning on the current recording. +The adapter requires a separately identified deterministic log-normalizer callback; it cannot derive that normalizer from finite rejection samples. +Its implementation and dependence on the retained variables remain part of the caller's reviewed probability model. +Missing normalization, invalid probability values, and scene-construction exceptions remain explicit errors rather than zero likelihoods. +Zero support found in a finite candidate batch remains a search outcome, not a proof of inconsistency. + +The supported original-prior identity includes the feasibility policy and the normalization choice, separately from the exact observations. +Changing observations therefore changes the inference target without redefining the original prior. +Weights flow through the same `PriorPoint` and `ConditionedPrior` interfaces, so the existing sampler retains them during initialization and every Metropolis move. +Noisy observations are applied afterward, once, through the remaining likelihood. + +An analytic reference makes the distinction measurable. +Let `theta` be uniform on `[1, 4]`, let `x` be independently uniform on `[0, 4]`, require `x <= theta`, and observe `theta*x = 0.5` exactly. +The coordinate map eliminates `x`, retaining the factor `1/theta`. +Global joint conditioning then gives mean `theta = 3/log(4)`, approximately 2.164. +Normalizing the state prior separately uses `Z(theta) = theta/4`, giving density proportional to `1/theta^2` and mean `theta = log(4)/0.75`, approximately 1.848. +Omitting that factor changes the statistical question despite using the same feasible candidates. +This is a numerical reference, not a historical-domain result or evidence that either law is the right task prior. + +For the proposal's fixed parameter-prior endpoint, use `conditional_state` when defining feasibility inside `p0(s given theta)`. +A support probability independent of all sampled parameters can cancel, but that independence needs justification. +If geometry or attachment parameters affect the normalizer and it is unavailable, retain an explicit unsupported construction until a normalized generative representation or validated normalization method is supplied. +Selecting `global_joint` just to avoid that calculation would generally change the declared parameter prior. +Full historical scene laws, attachment cases, and exact trajectory conditioning remain outstanding. + +## Fixed fixture contacts are part of the geometry contract + +Rejecting every robot/background intersection gives no accepted candidates in the strict reference. +The contact diagnostic identifies the same two pairs in every visible domain: each Fetch wheel against the floor plane. +The checked URDF places the spherical wheel collision centers at height 0.055325 m and gives them radius 0.065 m. +The fixed-base placement therefore produces signed distance -0.009675 m, independent of wheel rotation. +This is a property of the supplied fixture geometry; sampling different joint angles cannot remove it. +This particular placement is the constructor-only geometry used by that generated-component reference. +The later [historical-root reference](balloons-initial-scene.md#why-the-reset-protocol-matters) calls the full robot reset, which places the base COM at its configured pose and produces signed distance -0.011075 m instead. +The two fixture policies are tied to their explicit initialization protocols; the earlier result does not certify the later placement. + +The revised reference permits only those two named wheel/plane fixture contacts at that expected signed distance. +The support identity records the exception, expected distance, source hashes, and geometric roundoff policy. +A different distance raises an error rather than expanding the allowance. +Every other tested robot/background or sampled-assembly intersection is still rejected. +The strict no-overlap result remains recorded separately. +No robot geometry, collision mask, observation, sensor noise, or production behavior changes to make the revised predicate pass. + +## What the composed reference establishes + +The generated reference combines exact recorded initial robot positions with uncertain unobserved joints and a synthetic rigid box/sphere assembly. +Its base distribution chooses equally between a joint/assembly rest case and a joint/assembly moving case, then rejects the whole candidate under the declared geometry policy. +That shared case is a stated correlation in this generated reference, not an inferred rest guarantee or a proposed final task-wide motion model. +The policy checks the new bodies against existing collision geometry and the robot against existing nonrobot geometry, with only the named fixed fixture contacts permitted. +It conservatively includes queried geometry even where a visible environment may disable a collision pair. +Robot self-collision is not added to the existing fixed-base model by this audit. + +This tests a composition procedure and support under a particular declared model. +It does not reconstruct the historical object layouts, condition on their other exact readings, solve later exact robot/contact trajectories, or fit simulator parameters. +The finite accepted batch does not certify broad exploration or posterior quality. +Those checks remain necessary before replacing legacy inference. +See [the experiment record](experiments-20260912.md#reset-law-and-composed-scene-support). diff --git a/docs/uncertainty/simplification-proposal.md b/docs/uncertainty/simplification-proposal.md index a3097ea48..4ce57d8bf 100644 --- a/docs/uncertainty/simplification-proposal.md +++ b/docs/uncertainty/simplification-proposal.md @@ -1,42 +1,136 @@ # Proposal: simplify uncertainty handling in EMPIRIC September 11, 2026. +Revised September 12, 2026 to clarify exact conditioning, inference availability, and the optional execution-filter extension. This is a design proposal, not a description of an implemented or validated replacement. The companion [implementation explanation](explained.md) documents the current behavior and source locations. +Implementation of the staged migration is tracked in [implementation progress](implementation-progress.md). ## Recommendation -Replace the separate uncertainty heuristics with a shared inference model: +Unify the uncertainty interface first, then replace its estimators one at a time. +The first complete endpoint is: 1. A posterior over the fixed parameters of the current simulator program. -2. A conditional belief over physical and hidden state. -3. Planning, subgoal checking, and information seeking that use samples from these distributions. - -Start with parameter inference and uncertain initial states in `sim.fit()`. -Use a fixed prior and all available fitting recordings, with intervals and parameter ensembles derived from the same posterior approximation. -Then replace the separate state averages with filtering and smoothing under that observation model. +2. Joint inference of uncertain episode initial states during fitting, so parameter uncertainty accounts for uncertainty about how recordings began. +3. Planning, subgoal checking, and information seeking that obtain parameter samples from this same posterior, while retaining the existing execution state estimator. + +A conditional execution filter and conditional state sampling for planning are separately evaluated extensions. +Keeping the existing observation estimator is a valid final choice if those extensions do not improve results. +This endpoint unifies parameter uncertainty; it does not claim a full joint Bayesian belief during execution. + +Start by preserving the current agent behind a versioned result interface, without changing its estimates, reports, or decisions. +Verify state restoration and recorded-action replay before implementing parameter inference with uncertain initial states. +Evaluate that inference offline using a fixed prior and all available fitting recordings, with intervals and parameter ensembles derived from the same posterior approximation. +Introduce it into planning only after it passes prediction checks, while preserving the existing execution estimator and decision rules. +Replace execution state estimation last, if it independently demonstrates a benefit. Keep the simulator subclass interface and the agent's ability to choose its tools and actions. -This would simplify the statistical assumptions and remove duplicated uncertainty calculations. -It would not make contact dynamics smooth or remove the need for numerical diagnostics. +The design separates the posterior implied by the model and data, the reliability of its numerical approximation, the model's predictive adequacy, and the suitability of a particular action. +Return a numerically adequate posterior even when predictive diagnostics reveal an incomplete program. +Predictive failures must remain visible to model revision and decision rules; returning or publishing the inference result does not certify an action. +One posterior does not require a single decision criterion or a single numerical algorithm. +The intended simplification is to remove duplicated statistical assumptions while retaining explicit robustness decisions. Preserving performance is an experimental requirement, not something we can infer from a cleaner formulation. -## 1. Define one inference problem +The reference agent is the frozen noisy-sweep runtime tagged `noisy-mb-five-domain-15of15-20260910`, which achieved whole-run success on three seeds in each of five domains. +Those development results establish a working baseline, not a guarantee or an ablation of individual safeguards. +See the [recorded results](../uncertainty-results/noisy-sweep-table.md). +The current implementation remains the production default until a replacement passes the gates below. + +## 1. Establish the state and replay contract -Let \(P\) be the current simulator program, \(\theta\) its fixed dynamics parameters, and \(s_t\) the full physical and model state. +Define how an inference candidate becomes a runnable simulator state before defining its sampler. +This contract must cover object pose and velocity, robot state, attachments and constraints, and simulator-subclass memory. + +| Quantity | Contract | +| --- | --- | +| Exact observations and known reset values | Condition on them using only information exposed by the task interface. | +| Noisy observations | Evaluate the declared observation likelihood; do not treat a noisy pose as an exact initial state. | +| Unobserved initial quantities | Specify a prior and a representation that supports valid sampling and restoration. | +| Inferred model memory | Keep it candidate-specific and preserve it across rest windows and rollout branches. | +| Engine implementation state | Reconstruct reproducibly; measure any remaining replay discrepancy rather than attributing it to sensor noise. | + +Feasible samples must respect object geometry, attachment consistency, and the declared support of discrete and continuous state. +Document whether each exact feature is an exogenous conditioned input or an output predicted by the model. +A candidate that contradicts an exact predicted observation has zero likelihood; silently overwriting that prediction would define a different model. +This rejection rule alone is not a sampling method for continuous exact observations. + +| Exact quantity | Required treatment | +| --- | --- | +| Known reset value or declared external input | Set it directly from the task interface and remove it from the sampled coordinates. | +| Exactly observed continuous initial coordinate | Fix the coordinate to its observation and derive the conditional distribution of the remaining unknowns, retaining any information it supplies about parameters or other state. | +| Exactly observed continuous trajectory output | Use an explicit constrained representation or conditional proposal that reaches the observation-consistent states and preserves the induced conditional distribution. | +| Exactly observed discrete output, such as a switch state | Use an indicator likelihood; enumeration or proposals within compatible discrete cases may be needed when rejection is inefficient. | + +Sampling a continuous joint position and hoping for exact equality with its observed value almost surely fails under a continuous proposal, even when the conditional distribution is well defined. +Eliminating a coordinate or solving a constraint must preserve the appropriate density factors, including Jacobian factors where required; projecting arbitrary samples onto a constraint surface is not generally sufficient. +For smooth models, [Graham and Storkey (2017)](https://proceedings.mlr.press/v54/graham17a.html) describe conditioning on the set of inputs consistent with observed outputs; their smoothness assumptions do not establish a sampler for this repository's contact dynamics. +Report separately whether the conditional representation is unsupported, the finite search failed to find feasible candidates, or the model's constraints are demonstrably inconsistent. +None permits manufacturing a normalized posterior, and failure to find a feasible candidate is not proof that none exists. + +Keep numerical replay error distinct from sensor uncertainty. +Document any numerical constraint-solver tolerance and test its effect on the conditional approximation; do not silently turn an exact observation into a tolerance-band likelihood. +An explicit observation-resolution or dynamics-discrepancy model is a separately justified model change. +The [September 12 prediction preflight](experiments-20260912.md#fixed-program-prediction-preflight) found exact-output contradictions in all 14 executable nominal cases, including very small joint discrepancies and larger errors. +These results motivate the support and replay investigation; they do not establish that every feasible initial state and parameter is inconsistent. +The later [Bridge invariant audit](experiments-20260912.md#a-structural-exact-output-contradiction-in-the-frozen-bridge-model) establishes a narrower structural impossibility: the frozen no-op program keeps glue attributes constant while four exact recorded glue attributes change. +Retain that full-recording target as an explicit model-inconsistency control, rather than attempting to repair it with a larger initial-state prior or sampling budget. +Explaining those transitions requires program revision or a separately evaluated discrepancy model. + +The current [fitting replay](../../predicators/code_sim_learning/rollout_env.py), `rollout_states`, zeros velocities after restoring its initial state. +An inference path that samples initial velocities must restore and retain those velocities; it cannot inherit that rest-start assumption unchanged. +The legacy path keeps its current behavior for comparison. + +Validate repeated replay, moving starts, contact transitions, attachment changes, and memory continuity on short and long recorded prefixes. +Use known dynamics and evaluator-only state to diagnose reconstruction error offline, without exposing that information to the agent. +Separate reconstruction failures from candidate-program errors before judging a statistical estimator. + +The offline continuation reference uses an explicit candidate initialization protocol followed by the full action prefix in one fresh world. +This preserves engine history, native attachments, and model memory without requiring an exact portable checkpoint at every observation boundary. +Every change to the candidate parameters or initial state reconstructs the prefix under that candidate; the extra simulator steps count toward its compute budget. +Initialization is part of the probability model and runtime identity, not an implicit call to the evaluator's task generator. +Only evaluator-only mechanical audits may use evaluator reset state or private dynamics to establish a replay reference. + +A portable candidate must include full body orientations, original command-weld frames, and commands queued for the next action, even when these quantities are absent from public observations. +Their values must come from the candidate prior or simulated history, not privileged recording metadata. +Arbitrary mid-trajectory restoration remains an approximation until separately validated against uninterrupted replay. +Numerically repeatable candidate replay, faithful evaluator reconstruction, and predictive accuracy of a learned program are three distinct acceptance claims. + +### Required initial-state inventory + +Before defining real-domain sampling proposals, complete a separate inventory for boil, domino, fan, bridge, and the selected balloons variant, tied to the program, task-interface, and recording versions used in the comparison. +Each inventory must use the following columns and cover object pose and motion, controlled and passive robot joints, attachments, and every declared hidden-memory quantity. + +| Quantity and source | What the interface establishes | What remains unknown | Prior and feasible representation | Conditioning or elimination | Remaining continuous dimensions and discrete cases | +| --- | --- | --- | --- | --- | --- | +| One row per state quantity or coupled group | Exact value, noisy reading, reset guarantee, or no observation, with a source reference | Unknown values and dependencies across quantities or episodes | Density or mass function, bounds, geometric and attachment constraints, and memory initialization | Fixed input, conditioned coordinate, derived quantity, analytic integration, or sampled variable | Size after these reductions, for the actual recording set | + +This is a required design artifact, not a claim that physical priors have already been established. +The [September 12 inventory](initial-state-inventory.md) records the five development schemas and visible-model joint audit, with unresolved priors and dimensions explicitly marked. +A value present in engine metadata is not thereby known to the agent; in particular, do not infer passive-joint values, zero velocity, or absent attachments from recording omissions. +Do not assume URDF joint-limit intervals are hard support for every simulator initialization: the [joint audit](robot-state-prior.md) found a recorded balloons initial angle outside its URDF interval. +Retain that prior-support contradiction and specify a justified initialization law before fitting; clipping the exact reading or silently changing bounds would change the inference problem. +Distinguish actual resets from continued trajectories, and document which hidden quantities persist across task changes. +Start with the smallest valid uncertain representation and expand it only for quantities that cannot be conditioned on, derived, or integrated out under the declared model. +Report the resulting joint dimension and discrete alternatives across all episodes before selecting proposal blocks and compute budgets. + +## 2. Define one inference problem + +Let $P$ be the current simulator program, $\theta$ its fixed dynamics parameters, and $s_t$ the full physical and model state. The state includes velocities and accumulated quantities such as heat or curing state when they affect future dynamics. -Let \(o_t\) be the noisy object-centric observation and \(a_t\) the executed action. +Let $o_t$ be the noisy object-centric observation and $a_t$ the executed action. -For the first implementation, use deterministic candidate dynamics and the declared observation channel: +For the first offline reference, use deterministic candidate dynamics and the declared observation channel: -\[ +$$ s_{t+1}=F_{P,\theta}(s_t,a_t), \qquad o_t\sim p(o_t\mid s_t). -\] +$$ -For reset episodes indexed by \(e\), fit the joint posterior +For reset episodes indexed by $e$, fit the joint posterior -\[ +$$ p(\theta,\{s_{e,0}\}_e\mid D,P) \propto p_0(\theta\mid P) @@ -45,9 +139,11 @@ p_0(s_{e,0}\mid\theta,P) \prod_{t=0}^{T_e} p(o_{e,t}\mid s_{e,t}(\theta,s_{e,0},a_{e,0:t-1})) \right]. -\] +$$ This is the target distribution, independent of the numerical algorithm used to approximate it. +The likelihood notation includes deterministic constraints; continuous exact outputs require the conditional construction in section 1 rather than ordinary density multiplication and rejection in the original coordinates. +The numerical target must specify its free coordinates and the density or mass on that representation. The parameter posterior is its marginal over initial states. An uncertain initial pose is inferred together with the dynamics rather than fixed to one noisy measurement. Every observation enters once; repeated calls to `observe()` at the same environment step do not constitute new evidence. @@ -62,7 +158,7 @@ If the first observation is used to construct an informative sampling proposal f Keep the original parameter prior fixed while fitting pooled recordings. Previously fitted values may initialize optimization or sampling, but do not become a new prior center. -An uninformative parameter then retains its prior uncertainty naturally. +A parameter retains its prior marginal when neither the likelihood nor dependence on informed parameters supplies information about it. There is no need to classify it as sufficiently identified before allowing it to be represented in planning. ### Missing dynamics remain a separate problem @@ -71,32 +167,47 @@ A posterior can be narrow and wrong when the program is wrong. Check whether posterior predictions explain the observations, and report persistent discrepancies to the agent so it can revise its simulator. Do not inflate sensor noise or silently delete difficult trajectories until the current program looks accurate. -The initial comparison should use the declared sensor likelihood directly, so its limitations are visible. +The initial offline comparison should use the declared sensor likelihood directly, so its limitations are visible. +This is a reference model, not an assumption that sensor noise alone is sufficient for deployment. +The existing robust loss, segmentation, and trimming also protect against replay and program error. +They remain active in the legacy agent until a replacement demonstrates comparable behavior on incomplete programs as well as accurate ones. If replay mismatch requires a discrepancy model, make that an explicit, separately evaluated extension with its own parameters and prior. For example, a model of occasional bad measurements addresses a different failure from temporally correlated errors caused by missing forces. Neither should be introduced merely to reproduce the old trimming decisions. -## 2. Implement one posterior approximation +Keep sensor variance fixed at its declared value in these comparisons. +Evaluate any transition-discrepancy model on held-out development interactions and event predictions, so it cannot earn acceptance merely by explaining every trajectory with extra flexibility. +For a stochastic discrepancy extension, infer the intermediate states under its declared transition distribution; the deterministic initial-state formula above no longer suffices. +Persistent mismatch should still inform program revision. + +## 3. Standardize the inference result, evaluate the approximation -I propose a batch sampler over joint dynamics parameters and uncertain episode initial states as the reference implementation. -Use tempered sequential Monte Carlo with derivative-free Metropolis moves, rather than relying on the contact simulator's local Jacobian. -This provides a common representation for bounded, correlated, or multimodal parameter uncertainty. +Evaluate a batch sampler over joint dynamics parameters and uncertain episode initial states as the first candidate implementation. +Use the completed domain inventories to define the target and proposals before choosing a real-domain sampler configuration. +Tempered sequential Monte Carlo with derivative-free Metropolis moves is a reasonable candidate because it does not require a reliable local contact Jacobian. +Its representation can express bounded, correlated, or multimodal parameter uncertainty; actually discovering that uncertainty requires adequate exploration. Sequential Monte Carlo samplers support weighted approximations of distributions known up to normalization ([Del Moral, Doucet, and Jasra, 2006](https://www.stats.ox.ac.uk/~doucet/delmoral_doucet_jasra_sequentialmontecarlosamplersJRSSB.pdf)). +That reference also emphasizes dependence on the target sequence and proposal distributions; avoiding derivatives does not solve exploration of narrow feasible regions or disconnected explanations. The following adaptation is a proposal for this repository, not a result established by that reference. -A canonical `sim.fit()` would: +A candidate batch fit would: 1. Snapshot the simulator program, observation model, priors, and complete fitting data. -2. Initialize joint candidates from the declared prior or from a proposal with a known density and the appropriate importance correction. +2. Initialize joint candidates on the required exact-constraint support, from the correctly conditioned base distribution or a proposal with a known density and the appropriate importance correction. 3. Replay each recorded action sequence under each candidate, preserving model memory, and calculate the observation log likelihood. -4. Move from the prior toward the posterior by gradually increasing the likelihood exponent from zero to one. +4. Move from the base distribution toward the posterior by gradually increasing the remaining noisy-observation likelihood exponent from zero to one, maintaining exact constraints throughout. 5. Reweight, resample when weights concentrate, and apply Metropolis moves that target the current tempered distribution. -6. Publish weighted samples, marginal quantiles, predictive checks, and numerical diagnostics. +6. Return weighted samples and marginal quantiles when numerical inference succeeds, alongside predictive diagnostics and a separate record of publication and decision use. + +Tempering an equality indicator cannot gradually move unconstrained continuous candidates onto a zero-volume constraint surface: its value remains zero off that surface for every positive exponent. +Exact conditioning must be handled in the base distribution and valid moves, not deferred to the tempering schedule. Previous fits and optimizer results can help construct proposals, but are not automatically posterior samples for the new dataset. A finite collection of perturbations around a MAP estimate is also not a posterior unless its distribution and weights justify that interpretation. Grid integration on small synthetic problems should serve as a numerical reference for testing the sampler. -We should not maintain a second production interval estimator based on coordinate sweeps. +Do not commit to SMC as the sole production algorithm before these comparisons. +Whichever approximation is adopted should supply both credible intervals and planning samples; a coordinate sweep must not independently redefine its uncertainty. +Separate stress tests may still explore failure boundaries without claiming to estimate posterior mass. Use block proposals for shared parameters and individual episode initial states, while evaluating the appropriate joint target. Retain fresh simulator instances and correct replay initialization. @@ -114,26 +225,52 @@ A single result object should contain: | Field | Purpose | | --- | --- | | Program, prior, observation-model, and data identifiers | Establish exactly what distribution was fitted. | +| Estimator identity, numerical settings, and observation-prefix identity | Make the approximation and its information boundary reproducible. | | Joint samples and normalized weights | Supply one source for parameter and state uncertainty. | | Point summary and marginal credible intervals | Provide readable summaries without a separate width estimator. | | Posterior predictive diagnostics | Show discrepancies on specific recorded features and time intervals. | | Numerical diagnostics | Distinguish an unreliable approximation from uncertainty supported by the model and data. | +| Inference availability, publication identity, and decision-use record | Distinguish a returned candidate, the current canonical fit, and its use or refusal for a particular decision. | + +The initial legacy adapter preserves existing point estimates, heuristic widths, reports, and publication rules exactly. +Label those widths as legacy estimates in internal metadata; do not invent posterior samples or claim calibration that the adapter does not establish. +Keep user-visible tool output unchanged during this interface-only step because changes to the agent's input can change its behavior. A point estimate remains useful for deterministic debugging and nominal rollouts. A mean parameter vector can fall between incompatible modes, so nominal execution should use an explicitly selected representative candidate rather than assume every posterior mean describes plausible dynamics. Keep canonical `sim.fit()` as the operation that publishes a new parameter posterior. -Subset fits remain diagnostic. +Subset fits return their results and diagnostics without replacing the canonical fit. +For the posterior path, a successful numerical fit returns its approximation even if predictive checks fail; a canonical fit publishes that result together with the failures. +Publication identifies the current inference under the declared model, not a blanket approval for execution. +Predictive diagnostics remain feature-, time-, and event-specific so an incomplete program can still supply useful diagnostic information without being declared universally reliable. +Initially preserve the existing action-acceptance and risk rules; any new diagnostic-dependent refusal or acceptance rule is a separately evaluated policy change. +The development checks for adopting the replacement estimator remain required, but must not be conflated with withholding each inadequately predictive fit from the agent. +If numerical inference fails or no conditional posterior can be constructed, return the failure diagnostics without claiming posterior samples. +Retain a previously published estimate only if its program and parameter meaning remain compatible, and explicitly report its age, failed checks, and limitations. +Retention is a continuity choice, not evidence that the older estimate predicts the new data better. +Report its predictive discrepancies on the available new observations, or explicitly mark that comparison unevaluated; never multiply the old samples by reused data merely to perform this check. +An incompatible program edit leaves no valid published posterior; it must not silently inherit old weights or memory. +Preserve the existing agent-controlled interaction flow so an unavailable posterior does not impose an automatic environment probe, reset, or indefinite refit loop. +During migration, rollback to the legacy agent is an explicit, recorded experiment choice, not a silent per-fit estimator switch. A program edit invalidates old likelihoods and inferred memory; a refit evaluates recordings under the new program and its declared parameter space. Do not silently carry posterior weights across changes in program meaning or parameter definitions. -## 3. Use Bayesian state estimation without adding a separate uncertainty system +## 4. Optional extension: conditional execution state estimation + +This section specifies an extension beyond the first complete endpoint. +Retaining the existing observation estimator does not leave the parameter-uncertainty simplification unfinished. +Attempt this extension only as an independently evaluated change, retaining its additional inference cost and recovery behavior in the comparison. For each retained parameter candidate, maintain a conditional state belief. Propagate that state through the candidate simulator and update it using the next observation likelihood. The state includes model memory, so observations of downstream effects can constrain earlier hidden quantities. A rest detection event does not reset that memory. +Specify the conditional representation explicitly: one deterministic state trajectory per fixed parameter candidate cannot correct its state through likelihood reweighting alone. +Retain multiple plausible initial states or histories under each parameter candidate, or implement valid conditional moves that propose alternative histories. +Resampling duplicates represented possibilities; it does not create missing states or repair an incorrect program. + For execution, use filtering based only on the available observation prefix. For recorded experience, use smoothing to revise earlier states using later observations. These are standard distinctions in Bayesian state estimation ([Särkkä and Svensson, 2023](https://users.aalto.fi/~ssarkka/pub/bfs_book_2023_online.pdf)). @@ -149,26 +286,44 @@ Automatic online parameter reweighting can be considered later, but should not b Use the same raw observation ledger for the subsequent batch fit. Do not treat filtered states as new independent measurements, and do not multiply an already-updated belief by the same data again. When a new fit is published, reconstruct the current conditional state under its candidates from the recorded prefix. -If particles collapse during execution, report the failure and reconstruct the state belief from a longer prefix or request a refit; silently replacing it with the latest noisy pose would reintroduce the initial-state problem. +Before live use, define a bounded recovery procedure for state-particle collapse and observations inconsistent with every represented trajectory. +First attempt valid conditional reconstruction from the available prefix within an explicit compute budget. +If reconstruction fails, expose an unavailable or unreliable model-conditioned belief and request agent-controlled revision or fitting. +Retain the established observation-only estimator as an explicitly labeled degraded mode for observable features during this migration; its output is not a joint posterior and cannot certify hidden state. +Do not continue reporting stale hidden-state certainty, silently substitute raw poses, or automatically take environment actions as recovery. +Record degraded-mode use as part of the candidate system's results rather than excluding those episodes. +Reconstruction from a longer prefix can address missing particle support but does not itself resolve program mismatch. This replaces rest-window averaging only after it has demonstrated better state estimates and reliable online behavior. A stationary Gaussian average remains a useful reference case for testing the filter. -## 4. Make the consumers share samples +## 5. Share inference while preserving decision semantics ### Planning and subgoal checks -Draw a parameter candidate and a state conditional on that candidate, then simulate the proposed plan. +At the first endpoint, draw parameters from the common posterior and initialize rollouts with the existing execution state estimate and model memory. +Label the resulting success estimates as conditional on that supplied state estimate: they account for parameter uncertainty but do not integrate current-state uncertainty or preserve its full dependence on parameters. +Uncertain initial-state inference during fitting improves the parameter posterior without by itself supplying a continuously updated joint execution belief. +Evaluate conditional state sampling for planning separately, using only the observation prefix available at the decision. +For that extension, draw a parameter candidate and a state conditional on that candidate, then simulate the proposed plan. Keep the parameter vector fixed throughout a rollout because it represents an unknown constant, not process noise. Preserve hidden memory within the rollout and copy it when branching. -This avoids combining an independently sampled state and parameter vector that the observations rule out jointly. +The conditional extension avoids combining an independently sampled state and parameter vector that the observations rule out jointly. Report weighted plan-success estimates and predicate probabilities under the represented belief. Distinguish posterior uncertainty from Monte Carlo estimation error and simulator mismatch. A successful finite sample is not a guarantee over a continuous parameter region. The environment evaluator continues to determine whether a level is solved. -Initially keep existing subgoal decision thresholds so the state estimator can be compared without also changing execution policy. +Keep posterior-weighted plan success and stress-test outcomes as separate report fields. +The first estimates success under the represented distribution; the second identifies failures at explicitly chosen plausible candidates or boundaries without assigning them posterior probability. +Stress tests should respect joint parameter/state feasibility instead of combining incompatible marginal extremes. +Keep mixed-outcome warnings and the existing information-seeking trigger semantics during the first planning comparison. +Sampling that misses a rare failure mode must not be described as the equivalent of the old interval check. + +Initially keep existing subgoal decision thresholds, plan acceptance rules, and probe triggers. +This holds the decision policy fixed; changing its input distribution can still change actions, so closed-loop comparison remains necessary. +If moving to posterior probabilities requires a different acceptance rule, evaluate that as a separate policy change. Subsequent threshold choices should reflect the cost of false completion versus unnecessary continued action, rather than being presented as an inference algorithm. ### Information seeking @@ -176,85 +331,130 @@ Subsequent threshold choices should reflect the cost of false completion versus Keep the name **noise-aware information-seeking score**. At first, retain the existing score while replacing its parameter ensemble with samples from the common posterior. That isolates the effect of consistent uncertainty estimates. +Use the posterior weights in ensemble averages, or resample to an equally weighted ensemble with the resulting sampling error recorded. +Do not treat arbitrary weighted candidates as equally probable. A later improvement is to simulate each candidate interaction under joint parameter/state samples, generate observations through the sensor model, and estimate the expected information gained about the parameters. This would account for candidate-specific dynamics and uncertain state, which the current predicate score does not fully do. The agent should still decide whether an informative interaction is worth its environment steps; information gain alone is not the task objective. -## 5. What happens to the existing mechanisms? +## 6. What happens to the existing mechanisms? | Existing mechanism | Proposed treatment | | --- | --- | | Parameter interval flag and grid/probe widths | Replace with posterior marginal quantiles and samples from the same joint approximation. | | Noise-aware information-seeking flag | Retain the decision criterion, with a clearer name and posterior-derived candidates. | -| Noise-aware rest segmentation and start averaging | Replace with uncertain initial-state inference and state estimation; keep temporarily as the comparison baseline. | -| Carry-posterior flag | Remove prior recentering; keep the original prior and reuse experience through the likelihood. | -| Fit-evidence flag | Remove from the default agent report; use predictive diagnostics on common data for program revision. | -| Execution-belief flag | Replace with the conditional state estimator after validation. | -| Width floors and identified/weak/wide deployment verdicts | Remove from inference; preserve uncertainty instead of selecting which parameters are allowed to have it. | +| Noise-aware rest segmentation and start averaging | Preserve in legacy; replace only after replay and initial-state inference pass predictive checks. | +| Carry-posterior flag | Preserve in legacy; use a fixed original prior in the posterior path and compare resulting retention of previously learned dynamics. | +| Fit-evidence flag | Preserve legacy reports during parity checks; separately evaluate replacement by common-data predictive diagnostics. | +| Execution-belief flag | Retain the observation-only estimator as a valid endpoint; replace it only if the optional conditional filter and recovery behavior demonstrate a benefit. | +| Width floors and identified/weak/wide deployment verdicts | Preserve in legacy; return numerically adequate posterior inference with broad uncertainty and predictive failures visible, keeping decision use separate. | | Anchor-pinned backward elimination | Remove from the target estimator; correlations and prior preference should arise from joint inference. | -| Segment rejection and consistency-based dropping | Remove from the target likelihood; diagnose mismatch or model it explicitly. | -| Extra endpoint/onset losses | Keep as predictive diagnostics, not extra independent evidence alongside the original observations. | -| Huber residual transformation | Replace with an explicit observation or discrepancy model if robust treatment is needed. | +| Segment rejection and consistency-based dropping | Preserve legacy protection until an explicit likelihood explains difficult recordings without degrading useful predictions. | +| Extra endpoint/onset losses | Preserve in legacy; in the posterior path use these as decision-relevant diagnostics, not duplicated independent evidence. | +| Huber residual transformation | Preserve in legacy; assess whether an explicit discrepancy model is needed before deploying the replacement. | | Grid searches and zero-gradient recovery | May remain numerical proposal aids, but do not determine posterior widths or replace posterior weighting. | +| Interval stress tests and mixed-outcome warnings | Preserve their decision purpose separately from posterior probability estimation. | | Fresh replay, snapshotting, and caches | Retain as correctness and efficiency measures, with keys that identify all relevant inputs. | Removing a feature from the target design does not authorize deleting it before the replacement passes comparisons. During development, prefer a single estimator selection such as `legacy` versus `posterior` to a growing collection of mutually dependent flags. +Use immutable, named development configurations to isolate migration stages rather than adding every intermediate combination to the permanent public interface. +The temporary legacy parameter fitter is a comparison and rollback mechanism; the intended endpoint has one validated parameter-inference path with explicit diagnostics and failure reporting. +The existing execution state estimator may remain part of that endpoint without retaining a second parameter fitter. Keep sampling budgets and decision thresholds explicit because they control different tradeoffs. -## 6. Implementation order and acceptance checks +## 7. Implementation order and acceptance checks + +### Stage 0: preserve behavior behind the interface + +Snapshot the successful tagged runtime, its configuration, and the baseline reports. +Introduce the legacy result adapter without changing fitting, observation estimates, prompts, tool replies, parameter publication, or action rules. +Check recorded-action replay and scripted end-to-end parity of observations, tool replies, action traces, steps, and resets. +These checks establish plumbing parity, not unchanged solve rate for stochastic agent conversations. +Record later runtime changes separately instead of attributing every difference from the historical tag to uncertainty inference. ### Stage A: define and verify the probability model -Implement the observation likelihood, initial-state prior, immutable data identity, and result format beside the existing fitter. +Implement and verify the state/restoration contract from section 1 before the new fitter consumes real recordings. +Complete the five domain inventories and derive the reduced conditional targets before advancing to real-recording posterior comparisons. +Implement the observation likelihood, initial-state prior, immutable data identity, and posterior result format beside the existing fitter. Verify the likelihood against the noise injector, including angle handling, missing measurements, and cached observations. +Match the injector's actual angular representation; do not assume an additive unwrapped observation is distributed identically to a wrapped observation. Test inference on a stationary noisy object and a small parameterized dynamical system with a grid-computable reference posterior. Check that an uninformed parameter retains its prior, correlated parameters retain their tradeoff, and repeated fitting on identical data does not accumulate confidence. +Check valid recovery from poor initialization, impossible exact observations, and insufficient numerical budget. +Add distinct reference cases for an exactly observed continuous initial coordinate, a feasible continuous trajectory constraint, a discrete exact output, and a provably inconsistent constraint set. +Verify correct conditional weights when an observed coordinate depends on parameters, and verify that likelihood tempering is not being used to repair missing exact support. +Check that a numerically adequate but poorly predictive fit is returned with its failures, while numerical failure cannot be presented as a usable posterior. These tests check statistical behavior rather than reproduce the implementation. ### Stage B: compare parameter inference on recorded experience -Add the batch sampler and uncertain initial states, leaving the existing agent in control of interactions. +Add the candidate batch sampler and uncertain initial states, leaving the existing agent in control of interactions. Compare fixed-prior posterior inference against the full legacy fitter on the same development recordings from boil, domino, fan, bridge, and balloons. -Measure predictive error, interval coverage where ground truth is available to the offline evaluator, multimodal behavior, and inference cost. +Freeze candidate programs for each offline comparison so the estimator is the manipulated variable. +Measure predictions on held-out development interactions or causal future suffixes, not only reconstruction of the fitted recordings. +Measure feature error, contact and attachment outcomes, event timing, and goal-relevant predictions. +Assess parameter coverage only where a known parameter meaning exists, across repeated datasets; a single interval containing the truth does not establish calibration. +Also measure multimodal behavior, prediction stability across independent sampler runs, and inference cost. Use recordings with quiet starts, moving starts, contact transitions, hidden memory, and deliberately incomplete candidate programs. +Include matched ablations that isolate fixed-prior fitting from uncertain initial-state inference, so a combined result does not conceal which change helps or hurts. Inspect disagreement rather than selecting only examples where the posterior replacement wins. +Pass this gate only when the candidate produces trustworthy, decision-relevant predictions within the declared compute budget. +If the sensor-only model fails that gate, evaluate an explicit discrepancy model before proceeding to live posterior use. ### Stage C: use the posterior in planning -Route parameter sweeps and exploration ensembles through the new result object. -Keep the existing execution observation estimate for this comparison and label it as an interim approximation. -Then add conditional state sampling to rollouts and compare it against plugging in the smoothed mean. -This separates the benefit of parameter inference from the benefit of handling initial-state uncertainty. +First run the new planning reports in shadow mode on saved decision points without exposing them to the acting agent. +Then route parameter sweeps through the new result object in matched live development runs, retaining plan-risk rules, warnings, and execution observations. +Change exploration ensembles in a separate comparison because they can change the data the agent chooses to collect. +Keep the existing execution observation estimate and explicitly report the approximation described in section 5. +This configuration is eligible as the completed parameter-inference replacement after Stage E; conditional state sampling is not a prerequisite. +Optionally add conditional state sampling to rollouts in a separate comparison against plugging in the existing state estimate. +This separates handling uncertain recording starts during fitting from integrating current-state uncertainty during planning. +Log changes in accepted plans, probe triggers, simulated failure cases, and actual actions in addition to final performance. -### Stage D: replace the execution smoother +### Stage D (optional): evaluate a replacement execution smoother +Stage C may proceed directly to Stage E while retaining the existing execution state estimator. Compare the conditional filter against the existing rest-window estimator on recorded prefixes, then in live runs. Measure tracking error, motion lag, hidden-state accuracy where evaluable, subgoal false positives and negatives, and latency. +Exercise incomplete models, impossible observations, particle collapse, and recovery-budget exhaustion before enabling it in live runs. +Report how frequently the degraded observation-only mode is used and whether it causes extra steps, resets, or missed subgoals. Ensure filtering has no access to future observations and that full-data smoothing results never enter an earlier online decision. Keep parameter publication explicit as described above. ### Stage E: decide what can be retired Run matched development experiments over multiple seeds in all five noisy domains. -Use the same model, prompts, tools, task distributions, and environment budgets for each estimator comparison. -Report solve rates, failures, environment steps, resets, and inference cost, including domain-level results rather than only an aggregate. +Hold the LLM version, initial simulator program, prompts, tool interfaces, task distributions, and environment budgets fixed for each estimator comparison. +Learned programs and action histories may subsequently diverge; that divergence is part of the estimator's closed-loop effect and must be recorded. +Pair task and observation-noise seeds, and record conversation randomness and infrastructure differences. +Allow additional simulator work when it improves environment sample efficiency, with an explicit per-fit and per-run compute budget and observed latency reported. +Report solve rates, failures, environment steps, resets, and inference cost by domain. +Average steps only over whole-run successful seeds with qualifying counts, while reporting all-run outcomes and successful/failed run costs separately so survivor selection cannot masquerade as efficiency. +Keep infrastructure failures outside agent success/failure denominators and report incomplete comparisons as incomplete. A few successful seeds do not establish unchanged performance. -Choose acceptable regression margins before the comparison and report uncertainty in the differences. +Before launching the comparison, record numerical non-inferiority margins for solve rate and resets, an efficiency target, the seed count or sequential stopping rule, and the uncertainty calculation. +Size that comparison to the claimed margins; repeating the original three seeds per domain is a smoke test, not a tight non-regression result. +Treat inconclusive evidence as a reason to retain the incumbent default. +An aggregate efficiency gain must not hide an unacceptable regression in one domain. Use separate development runs for selecting the implementation, then freeze it for the final continual evaluation. This does not prohibit learning during a test level: the agent can still use its allowed step experience as required by the protocol. It prohibits choosing the research implementation based on repeated inspection of the final evaluation outcomes. -Retire the old safeguards only where the replacement passes these checks. +Retire the old production parameter fitter only after the selected replacement configuration passes these checks across all five domains. +Retire the existing execution estimator only if the optional Stage D replacement also demonstrates a benefit and passes its validation checks. +Retain reproducible historical source and result artifacts for comparison. If the posterior model fails systematically on contact-rich recordings, revisit its state and discrepancy assumptions before adding back a collection of unrelated thresholds. ## First concrete change I would make -Build the fixed-prior batch inference path and a common posterior result object, initially used for offline replay comparisons. -Include uncertain initial states from the start so perceptual error is not forced into dynamics parameters. -Keep the current agent running on the existing implementation until those comparisons establish a credible replacement. +Make the first implementation chunk the legacy result adapter and state/replay contract with parity checks. +Make the second chunk the fixed-prior batch inference prototype with uncertain initial states, restricted to offline comparisons. +Keep the current agent running on the existing implementation throughout those stages. -The first reviewable implementation should contain the likelihood and prior definitions, numerical reference tests, results on saved five-domain recordings, and a report explaining discrepancies with the current fitter. -It should not claim that a full Bayesian smoother or a closed-loop performance improvement has already been achieved. +Before a live estimator comparison, provide the domain inventories, exact-conditioning construction, likelihood and prior definitions, numerical reference results, saved five-domain prediction comparisons, and an explanation of disagreements with the current fitter. +Improved closed-loop performance remains an experimental claim to establish after those deliverables. diff --git a/docs/uncertainty/stochastic-future-integration.md b/docs/uncertainty/stochastic-future-integration.md new file mode 100644 index 000000000..091c7f3a8 --- /dev/null +++ b/docs/uncertainty/stochastic-future-integration.md @@ -0,0 +1,94 @@ +# Stochastic future density integration + +This is an offline component of Stage B of the [simplification proposal](simplification-proposal.md). +It does not change the acting agent, and no physical posterior has passed the required numerical and predictive gates through this component. + +## Generation and scoring + +A stochastic forecast generator draws future joint and velocity transitions without consulting future observations. +A density evaluator instead integrates over latent transitions conditional on the observations being scored, retaining the associated joint and speed densities. +Using exact equality against a finite set of unconditional draws would assign zero likelihood to almost every continuous reading even when the model assigns a positive density. +These two operations therefore need separate implementations. + +The new `inference_path_integral` component averages complete conditional-path densities using a stable log scale. +The caller must include every target/proposal correction and use independent paths from the same normalized sampling law. +It preserves zero contributions and propagates numerical or replay errors instead of deleting failed paths. +Its output includes the empirical relative standard error and the effective number of contributing paths. +Those diagnostics concern Monte Carlo integration, not posterior particle weights or physical model adequacy. +No sampled support yields an undefined error estimate, not a claim that the model is inconsistent. + +For the current Balloons model, each scored future path conditions the joint transitions on the evaluated joint readings and the velocity law on the evaluated speed. +The joint Gaussian density and radial speed density or rest mass are retained exactly once. +Conditional directions are sampled and propagated through the native dynamics, including their later contact and event consequences. +The remaining output-observation likelihood conditions its temporal error process on the supported prefix. +The prefix transition factors are not counted again in the future score. +This construction is a conditional likelihood evaluation; its observation-guided paths must never be reused as unconditional generated forecasts or acting states. + +## Checks and native diagnostic + +Compute job `22653160` passed 40 functional tests, focused mypy and pylint, and pinned formatter checks. +The new tests compare a conditional speed and downstream observation integral with independent one-dimensional quadrature, including both rest and positive-speed cases. +They also check large log scales, zero contributions, an unseen rare event, deterministic random seeds, and error propagation. +Checked source hashes and outputs are in `logs/uncertainty_path_integral_checks_20260912`. + +Native job `22653178` completed in 8 minutes 53 seconds on `node1381`, using the frozen historical recording runtime and the offline modules. +It reproduced the full 235-action reference factor exactly as `-33314.92152710342` before evaluating any futures. +It then evaluated a 32-action future following a fixed 64-action prefix, for both an archived generated observation history and the recorded history. +The two independent integration seeds each use 64 complete paths per history, with the 8-path estimate obtained from the same sequence's first eight paths. +The first path of every case repeats exactly, and every reconstructed prefix equals the original reference prefix. +The diagnostic performed 25,195 native environment steps in total. + +| Evaluated future | Paths per integration seed | Relative standard error, seed 811 | Relative standard error, seed 812 | Between-seed log-density difference | Declared diagnostic | +| --- | ---: | ---: | ---: | ---: | --- | +| Generated history 701 | 8 | 1.0000 | 0.9989 | 10.3770 | Fail | +| Generated history 701 | 64 | 1.0000 | 0.9967 | 0.9607 | Fail | +| Recorded history | 8 | 0.9914 | 1.0000 | 13.2596 | Fail | +| Recorded history | 64 | 1.0000 | 1.0000 | 5.4757 | Fail | + +The predeclared limits were relative standard error at most 0.2 and between-seed log-density difference at most 0.2. +All four comparisons fail. +At 64 paths the effective number of contributions ranges from approximately 1.0000 to 1.0065, despite retaining all paths. +These density estimates are not numerically adequate for comparing inference methods. +Increasing from eight to 64 paths did not resolve the concentration. + +In generated-history seed 811, the transition log factors have standard deviation 4.99 while the remaining output log factors have standard deviation 81.25. +This motivates attributing the concentration to individual output factors before choosing an integration strategy. +It does not justify dropping those observations, inflating their declared noise, or accepting the largest sampled path as the density. + +The fixed physical witness and its parameters were selected using the complete training trajectory. +This diagnostic therefore establishes neither a causal prefix-fitted forecast comparison nor an agent result. +Its purpose is to test density accounting and expose numerical problems before posterior integration. +The complete contributions, input hashes, native paths, runtime controls and failed diagnostics remain in `logs/uncertainty_balloons_future_density_20260912`. + +## Factor attribution + +Jobs `22653470` and `22653543` reconstructed the generated-history paths at output-score ranks 0, 16, 32, 48 and 63 for each integration seed. +Every recomputed transition and output score matched its archived value exactly, and the separately scored factors summed to the complete output score within `1e-8`. +Each audit used 1,195 native actions, including the 235-action reference check. +The first audit grouped factors; the second split the original sensor by feature while retaining the checked finger source/readout pair as one factor. +The original attempt `22653400` failed because an audit variable overwrote the native object-key set; its corrected successors retain that failure record and make no core simulator changes. + +Across these ten selected paths, only the Cartesian readings of the box and balloon 0 vary in output log likelihood. +Balloon 0 is attached at the prefix boundary and through the evaluated suffix. +Robot Cartesian and Euler factors, the other objects' readings, and all exact event/readout factors remain identical. +The largest feature ranges are: + +| Feature | Log-factor range, seed 811 | Log-factor range, seed 812 | +| --- | ---: | ---: | +| Box y | 180.34 | 182.76 | +| Attached balloon y | 165.83 | 171.62 | +| Attached balloon x | 33.57 | 34.58 | +| Box x | 25.09 | 26.73 | + +These ranges describe the declared rank-selected paths, not all 64 draws or a population variance decomposition. +The result identifies observation mismatch along the moving attached assembly as the driver in this audit; the tightly modeled robot output channels do not explain this concentration. +It supports investigating position-guided proposals or sequential reweighting of conditional velocity directions while retaining all observations and their density factors. +It does not establish that either method will meet the numerical or latency gate. +The split audit archives the ten complete native paths for further analysis in `logs/uncertainty_balloons_future_attribution_v3_20260912`. + +## Remaining work + +Evaluate a position-guided or sequential integration method with explicit density corrections and independent numerical references. +Any proposed method must preserve the joint future law, exact observations, native replay contract and numerical failure reporting. +A reliable conditional-path integral still needs integration over an assessed joint parameter and prefix-state posterior. +Matched legacy predictions, initial-state and prior ablations, saved planning decisions and the live-agent validation gates remain required before replacing the incumbent. diff --git a/docs/uncertainty/transition-discrepancy.md b/docs/uncertainty/transition-discrepancy.md new file mode 100644 index 000000000..1bfff5d73 --- /dev/null +++ b/docs/uncertainty/transition-discrepancy.md @@ -0,0 +1,155 @@ +# Explicit transition-discrepancy reference + +September 12, 2026. +This is an offline model extension under the [simplification proposal](simplification-proposal.md), separate from the deterministic sensor-only reference and the production agent. +The deterministic contact diagnostics found reproducible predictions, occasional exact first-output matches, and unresolved later exact outputs. +They do not prove that every deterministic explanation is impossible. +They do justify evaluating a specified discrepancy model rather than treating a root solver's convergence flag as a conditional posterior. + +## Velocity law + +The new `VelocityDiscrepancy` class defines a post-transition velocity distribution around the simulator's predicted linear velocity `mu`: + +$$ +v \sim \rho\,\delta_0 + (1-\rho)\,\mathcal{N}(\mu,\sigma_v^2 I_3). +$$ + +The rest mass `rho` and correction scale `sigma_v` are declared model hyperparameters. +The correction scale has velocity units per declared transition; it is neither sensor variance nor a numerical solver tolerance. +This model allows an abrupt rest event and otherwise applies an isotropic velocity correction. +Whether that is an adequate representation of missing forces or contact errors must be tested; the mathematical construction does not establish physical adequacy. +Changing transition frequency requires a new interpretation of its hyperparameters. + +For exactly observed speed `r=0`, the rest component is selected and contributes probability mass `rho`. +Without a rest atom, conditioning at zero remains unsupported by this construction. +For positive speed, the moving component has the noncentral chi radial density in three dimensions. +Writing `m = norm(mu)`, its density for `m>0` is: + +$$ +f_R(r) = \frac{r}{\sqrt{2\pi}\sigma_v m} +\exp\left[-\frac{(r-m)^2}{2\sigma_v^2}\right] +\left(1-\exp[-2rm/\sigma_v^2]\right). +$$ + +The retained transition factor is `(1-rho) * f_R(r)`. +At `m=0`, this reduces to the Maxwell density already used by the central initial-velocity prior. +These factors use the mixed speed measure consisting of an atom at zero and Lebesgue measure on positive speeds. +They are not Cartesian velocity densities. + +Given positive speed, velocity direction follows a von Mises-Fisher distribution centered on `mu` with concentration `r*m/sigma_v**2`. +Two unit-uniform coordinates sample that normalized conditional direction law. +The resulting direction remains part of the candidate history because it changes subsequent positions and contacts. +Replacing it with the simulator's original direction, or with its conditional mean, would change the model. +The implementation reports floating-point reconstruction error separately from the likelihood. +It does not accept an arbitrary band around an exact speed observation. + +### Unconditional future transitions + +`VelocityDiscrepancy.sample` generates the original rest/Gaussian law around the predicted velocity using an explicit local random-number generator. +It takes no observed speed or conditional direction coordinate. +The rest branch returns zero velocity; the moving branch draws all three Cartesian components from the declared Gaussian. +Nonfinite predictions and overflowing draws raise errors rather than being clipped. +The caller applies the correction at the same physical boundary as fitting and retains the native angular velocity. +Extracting the existing diagnostic branch into this method preserves its random draws and generator state exactly. + +For mean velocity `mu`, the generated mixture has mean `(1-rho) * mu` and covariance `(1-rho) * sigma_v**2 * I + rho * (1-rho) * mu * mu.T`. +The tests check these moments, the rest frequency, and speed probabilities against an independent noncentral chi-squared reference, including central, shifted and pure-rest cases. +Compute job `22652447` passed all 25 transition/conditioning tests, two-file mypy and pylint, and pinned formatting checks. +The frozen sources and reports are in `logs/uncertainty_transition_generation_checks_20260912`. + +Unconditional generation does not replace the exact-speed density used to evaluate a future recording. +A finite collection of unconditional velocity draws almost surely misses any specified positive speed exactly. +Treating those samples as exact-output equality components would incorrectly assign zero predictive density to supported observations. +Future-density evaluation must retain the radial density and integrate conditional directions, together with the Gaussian joint-position transition factors and subsequent physical history. +Sampling future observations and evaluating their density therefore have distinct computational paths under this mixed continuous/discrete transition model. + +## Recorded Balloons diagnostic + +The current frozen diagnostic bundle is `logs/uncertainty_balloons_transition_v3_20260912`. +Its program, noisy public observations, and initial-scene law come from the earlier [original non-hatch Balloons reference](balloons-initial-scene.md). +No evaluator-only state supplies an initial candidate. +Two complete candidate roots are drawn using the same first-observation conditional law and geometric rejection policy. + +The diagnostic declares corrections after each native environment action and before reading the corrected observation: + +1. Each of the nine observed controlled joint positions receives an independent Gaussian correction with standard deviation 0.001 in that joint's coordinate units. +2. Box linear velocity receives the rest/Gaussian mixture correction, with rest mass 0.1 and three separately evaluated scales: 0.0001, 0.001, and 0.01 m/s. +3. Native angular velocities and joint velocities are retained. + Other body states, discrete events, attachments and program memory continue through the candidate simulation. + +These are explicit alternative transition models, not parameter fits or selected deployment settings. +Joint corrections and box-velocity corrections are conditionally independent given the native prediction in this declared model. +Joint positions can be conditioned analytically on their exact readings, retaining the Gaussian transition density at those readings. +Exact box speed uses the radial construction above, retaining uncertainty about direction. +Every other exact output remains an explicit consistency check. +The first observation is already absorbed by the conditional root proposal and is not scored again as independent evidence. + +Each root/scale combination has two direction draws and a fresh-world repeat. +The first 32 actions use the conditional transitions; the following 16 actions draw unconditional transitions without reading future observations. +Future observations are compared only after those states have been simulated. +This separates matching an observed speed by construction from predicting an unobserved future speed. +The report retains correction magnitudes, transition density factors, remaining exact-output disagreements, noisy prediction scores, and repeatability. + +This small collection of paths is a support and reconstruction diagnostic, not an adequately weighted approximation to a recording posterior. +Its transition factors alone are not model evidence. +Conditional root normalizers, all observation factors, path importance weights and numerical adequacy must be handled before comparing posterior distributions. + +## Acceptance limits + +The correction law preserves nonnegative speed, but it is not a contact-aware force model. +Joint position corrections can introduce geometry intersections or violate relationships with native velocity and attachment states. +Such limitations must remain visible; matching joint readings does not certify physical support. +No discrete event is corrected to make an incomplete program appear accurate. +In particular, this extension cannot explain the frozen Bridge model's missing glue transitions. + +If this representation yields feasible, reproducible paths, the next checks are parameter/state inference under the complete declared transition model, held-out predictive scoring, event predictions and repeatability as the compute budget increases. +Poor future predictions or inappropriate correction sizes are reasons to revise or reject this model, even if its conditional arithmetic is correct. +Its selection must precede matched live comparisons and must not change the incumbent's production behavior before the proposal's remaining gates pass. + +## Validation status + +Compute job `22632883` passed seventeen functional tests, two-file mypy and lint, and pinned formatting checks. +The numerical tests integrate the Gaussian density over spherical shells independently, check total radial mass and second moments, and verify conditional directional quantiles. +They also cover the central-prior limit, the rest atom, rotation, concentrated directions, malformed inputs and explicit numerical failure. +This verifies the component's conditional arithmetic, not a physical posterior or a live agent result. + +The first component check attempt passed functional tests and mypy but found a long line and a test closure lint issue; both were corrected in the frozen v2 checks. +The first physical diagnostic attempt failed while serializing a NumPy boolean to JSON. +That report-writing error was corrected in v2 without changing the transition model, and job `22632884` is the replacement diagnostic. +These are setup failures, not failed agent seeds or evidence against a stochastic model. + +The completed v2 diagnostic contains twelve exactly repeatable paths, with all 384 conditioned speeds reconstructed within the reported floating-point bound. +Every path nevertheless disagrees with the remaining Cartesian robot outputs, so it supplies no fully observation-compatible path under that observation map. +Six paths also disagree with one tie/clip transition; those event failures remain separate. + +### Preserve the native robot observation phase + +Follow-up audit `22633028` reproduces the Cartesian mismatch directly on eight recorded actions. +The unmodified cached robot reads match the recording exactly. +Both requesting `computeForwardKinematics=1` and resetting joints to their identical positions refresh the link cache and change the reported pose, while current joint positions and velocities remain unchanged. +The maximum coordinate change in this audit is 1.094 mm, and the maximum angle change is 0.00634 radians. +This is an observation-phase mismatch introduced by the diagnostic's resetting operation, not a physical correction demanded by the recorded data. + +The v3 transition model explicitly retains the native predicted cached-link readings for robot `x`, `y`, `z`, `roll`, `tilt` and `wrist`, measured before its post-step correction. +It reads the corrected joints, fingers and box speed afterward, and skips resetting joints whose correction is exactly zero. +The retained Cartesian values come from the candidate's native prediction, never from the observed Cartesian pose. +They remain exact-output checks and can still disagree with the recording. +This models the historical distinction between current joints and cached links instead of silently replacing the observation channel with fresh forward kinematics. +The production helper keeps its original default; its documentation now describes this measured behavior. + +The [v2 assessment](../../logs/uncertainty_balloons_transition_v2_20260912/assessment.json) retains its failed full-output checks, and the [cache audit](../../logs/uncertainty_robot_cache_20260912/assessment.json) identifies the causal reproduction. +Completed job `22633071` tests the corrected observation phase on the same twelve conditional paths and future continuations. +All twelve paths repeat exactly, and all 384 conditioned speeds satisfy the stated reconstruction bound. +Preserving the native link phase removes the artificial early Cartesian discrepancies: the first remaining exact-output failures are at action 18 for one root and action 22 for the other. +Those later failures include robot/contact disagreements and, for the second root, the tie/clip event. +Consequently, none of these twelve paths satisfies the complete 32-action observation set. +They cannot be normalized into a posterior for that full prefix, and the conflicting observations are not discarded or softened. + +During the sixteen unconditioned future actions, pathwise box-speed RMSE ranges from 0.0328 to 0.0657 m/s. +These are descriptive errors for a few unweighted paths, not calibrated posterior predictions or an improvement comparison. +The [v3 assessment](../../logs/uncertainty_balloons_transition_v3_20260912/assessment.json) records the failures, repeatability and all 1,152 modeled actions including repeats. +The extension now has verified conditional arithmetic and a correctly specified cached-link observation phase, but still needs a joint construction for the later contact constraints before full-recording parameter inference. + +The later [transition/output composition](balloons-composed-inference.md) adds the separately tested robot-output discrepancy while retaining conditional speed/joint factors and exact events. +It finds finite full-recording conditional path factors for one sampled root and preserves event failures for the other. +This supersedes the earlier support obstruction for that explicit extension, without turning those few paths into an adequate joint posterior or establishing deterministic-model support. diff --git a/predicators/agent_sdk/tools/synthesis.py b/predicators/agent_sdk/tools/synthesis.py index db3eeb623..2730a996c 100644 --- a/predicators/agent_sdk/tools/synthesis.py +++ b/predicators/agent_sdk/tools/synthesis.py @@ -544,9 +544,10 @@ def _evaluate_rollout_fit(rules: list, not exploratory else "Exploratory call: nothing recorded."), "", ] + _trim_cause_note(outcome.traj_rms, trim_threshold)) - fitted = outcome.fitted - applied = outcome.applied - ident_report = outcome.report + inference = outcome.inference + fitted = inference.point_estimate + applied = inference.selected_parameters + ident_report = inference.parameter_diagnostics pre_sse, post_sse = outcome.pre_sse, outcome.post_sse if not exploratory: approach._apply_identified_physical_params(applied) # pylint: disable=protected-access diff --git a/predicators/approaches/agent_sim_learning_approach.py b/predicators/approaches/agent_sim_learning_approach.py index b131ad57e..4190e92cd 100644 --- a/predicators/approaches/agent_sim_learning_approach.py +++ b/predicators/approaches/agent_sim_learning_approach.py @@ -2543,29 +2543,32 @@ def _fit_parameters_joint_rollout( self._record_sysid_diagnostics({}, physical_names, 0, len(rollouts), outcome.traj_rms) return outcome.fit_result, float("nan") + inference = outcome.inference logger.info("Identifiability (posterior/prior contraction):\n%s", - format_identifiability(outcome.report)) - log_param_changes(init_params, outcome.fitted) - self._apply_identified_physical_params(outcome.applied) - self.note_carried_posterior(outcome.applied, outcome.report) + format_identifiability(inference.parameter_diagnostics)) + log_param_changes(init_params, inference.point_estimate) + self._apply_identified_physical_params(inference.selected_parameters) + self.note_carried_posterior(inference.selected_parameters, + inference.parameter_diagnostics) if outcome.evidence is not None: self.note_fit_evidence( self._current_simulator_version or "harness", outcome.evidence) # Snapshot the cycle-level decision: this (not whatever the # agent's in-session sim.fit last applied) is what a future # INCONSISTENT verdict holds on to. - self._cycle_applied_physical = dict(outcome.applied) + self._cycle_applied_physical = dict(inference.selected_parameters) # Physics-margin points for the capture gate: the fit's posterior # widths (floored, see identifiability_report) turned into a grid # of perturbations spanning +-1 sigma of the applied values. self._identified_physical_sigma_points = self._physics_margin_points( - outcome.applied, outcome.report, physical_specs) + inference.selected_parameters, inference.parameter_diagnostics, + physical_specs) if self._identified_physical_sigma_points: logger.info("Physics-margin points for capture validation: %s", [{k: f"{v:.4f}" for k, v in pt.items()} for pt in self._identified_physical_sigma_points]) - self._record_sysid_diagnostics(outcome.report, + self._record_sysid_diagnostics(inference.parameter_diagnostics, physical_names, outcome.num_survivors, len(rollouts), outcome.traj_rms) return outcome.fit_result, outcome.post_sse diff --git a/predicators/code_sim_learning/active_experiment.py b/predicators/code_sim_learning/active_experiment.py index 2a6e14676..45253b8c2 100644 --- a/predicators/code_sim_learning/active_experiment.py +++ b/predicators/code_sim_learning/active_experiment.py @@ -37,7 +37,8 @@ from __future__ import annotations -from typing import Dict, List, Sequence, Union +import math +from typing import Dict, List, Optional, Sequence, Union import numpy as np @@ -250,27 +251,54 @@ def _bernoulli_entropy(p: float) -> float: return float(-p * np.log2(p) - (1.0 - p) * np.log2(1.0 - p)) -def mean_bernoulli_entropy(truth_matrix: np.ndarray) -> float: +def _validated_member_weights(arr: np.ndarray, + weights: Sequence[float]) -> np.ndarray: + """Validate an explicit probability measure without renormalizing it.""" + mass = np.asarray(weights, dtype=float) + if arr.ndim != 2 or not np.all(np.isfinite(arr)) or \ + np.any((arr < 0.) | (arr > 1.)): + raise ValueError("Expected a finite 2D probability matrix in [0, 1]") + if mass.ndim != 1 or mass.shape[0] != arr.shape[0] or \ + not np.all(np.isfinite(mass)) or np.any(mass < 0.) or \ + not math.isclose(math.fsum(mass), 1., rel_tol=1e-12, + abs_tol=1e-12): + raise ValueError( + "One normalized nonnegative weight per member required") + return mass + + +def mean_bernoulli_entropy(truth_matrix: np.ndarray, + *, + weights: Optional[Sequence[float]] = None) -> float: """Mean per-atom Bernoulli entropy over an ensemble. ``truth_matrix`` is a boolean ``(num_members, num_atoms)`` array: entry ``[k, m]`` is whether ensemble member ``k`` believes atom ``m`` holds in the candidate state. The score is the mean over atoms - of the binary entropy of each atom's across-member truth fraction — + of the binary entropy of each atom's across-member truth fraction - 0.0 when every member agrees on every atom (uninformative), up to 1.0 when members are evenly split (maximally informative). Returns - 0.0 for an empty matrix. + 0.0 for an empty matrix. Explicit normalized ``weights`` replace + equal member mass; they require binary entries and one weight per + row. Omitting weights preserves the incumbent arithmetic. """ arr = np.asarray(truth_matrix, dtype=float) + mass = None + if weights is not None: + mass = _validated_member_weights(arr, weights) + if np.any((arr != 0.) & (arr != 1.)): + raise ValueError("Weighted truth_matrix entries must be binary") if arr.size == 0: return 0.0 if arr.ndim != 2: raise ValueError("truth_matrix must be 2D (members x atoms)") - fracs = arr.mean(axis=0) # P(atom holds) across members + fracs = arr.mean(axis=0) if mass is None else mass @ arr return float(np.mean([_bernoulli_entropy(p) for p in fracs])) -def noisy_read_information(prob_matrix: np.ndarray) -> float: +def noisy_read_information(prob_matrix: np.ndarray, + *, + weights: Optional[Sequence[float]] = None) -> float: """Mean per-atom mutual information between the ensemble member and the atom's truth as read from a noisy observation. @@ -286,9 +314,13 @@ def noisy_read_information(prob_matrix: np.ndarray) -> float: sigma) and falls to 0 when every member reads the atom as the same coin flip (predictions within sigma of the boundary, which one observation cannot resolve). Averaged over atoms; 0 for an empty - matrix. + matrix. Explicit normalized ``weights`` replace both member means + with expectations under that same measure. This remains a mean of + per-atom information scores, not joint information across atoms. + Omitting weights preserves the incumbent arithmetic. """ arr = np.asarray(prob_matrix, dtype=float) + mass = None if weights is None else _validated_member_weights(arr, weights) if arr.size == 0: return 0.0 if arr.ndim != 2: @@ -296,7 +328,10 @@ def noisy_read_information(prob_matrix: np.ndarray) -> float: scores = [] for m in range(arr.shape[1]): col = arr[:, m] - marginal = _bernoulli_entropy(float(col.mean())) - conditional = float(np.mean([_bernoulli_entropy(p) for p in col])) + marginal = _bernoulli_entropy( + float(col.mean() if mass is None else mass @ col)) + entropies = [_bernoulli_entropy(p) for p in col] + conditional = float( + np.mean(entropies) if mass is None else mass @ entropies) scores.append(max(marginal - conditional, 0.0)) return float(np.mean(scores)) diff --git a/predicators/code_sim_learning/inference_assembly.py b/predicators/code_sim_learning/inference_assembly.py new file mode 100644 index 000000000..b22872996 --- /dev/null +++ b/predicators/code_sim_learning/inference_assembly.py @@ -0,0 +1,223 @@ +"""Offline normalized prior for a rigid assembly inside a declared free cell. + +The cell and enclosing body radii are explicit geometry assumptions, not +inferred free space or evaluator metadata. This conservative component +has fixed relative geometry and either rest or a common moving twist. It +is not a complete task prior or an articulated/contact-state prior. +""" +from __future__ import annotations + +import json +import math +from dataclasses import asdict, dataclass +from typing import Mapping, Optional, Tuple + +import numpy as np +from scipy.spatial.transform import Rotation + +from predicators.code_sim_learning.inference_data import content_digest +from predicators.code_sim_learning.inference_replay import CommandWeld, Pose, \ + Velocity +from predicators.code_sim_learning.inference_sampling import BoxPrior + +_IDENTITY: Pose = ((0., 0., 0.), (0., 0., 0., 1.)) + + +@dataclass(frozen=True) +class AssemblyBody: + """Body geometry in the root frame, bounded by a centered sphere. + + The caller must verify that radius encloses the collision geometry, + including all relevant links. Relative poses specify a fixed rigid + shape, not observed noisy offsets. The first body's pose is + identity. + """ + name: str + pose: Pose + radius: float + + def __post_init__(self) -> None: + position, orientation = (np.asarray(v, dtype=float) for v in self.pose) + if not isinstance(self.name, str) or not self.name: + raise ValueError("Assembly body needs a name") + if (position.shape != (3, ) or orientation.shape != (4, ) + or not np.isfinite(position).all() + or not np.isfinite(orientation).all() or not np.isclose( + np.linalg.norm(orientation), 1., rtol=0, atol=1e-12)): + raise ValueError("Assembly body requires a finite unit pose") + if not math.isfinite(self.radius) or self.radius <= 0: + raise ValueError("Enclosing radius must be positive and finite") + object.__setattr__(self, "pose", (tuple(position), tuple(orientation))) + + +@dataclass(frozen=True) +class AssemblyState: + """Correlated body states and consistent original weld frames.""" + poses: Mapping[str, Pose] + velocities: Mapping[str, Velocity] + welds: Tuple[CommandWeld, ...] + + +@dataclass(frozen=True) +class RigidAssemblyPrior: + """Uniform root placement, Haar orientation, and a declared twist prior. + + Root position is uniform in a cell eroded by the assembly's enclosing + radius, and orientation is uniform on SO(3). Every enclosed collision + shape therefore stays inside the supplied obstacle-free cell. + Enclosing spheres must not overlap, a sufficient but conservative + condition for internal separation. It excludes assemblies that need + interpenetrating or merely closely packed enclosing spheres. + + Both twist half-widths zero declare an atom at rest. Positive widths + declare independent uniform root linear and angular velocities in + world coordinates. These are alternative component priors, not a + mixture with an implicit case probability. A complete task prior + must specify case masses, geometry uncertainty, joints and contacts. + + support_depth optionally declares a horizontal root support face at + local z=-depth. This component rests on the cell's lower z plane, + with uniform xy and yaw, zero roll/pitch and zero twist. The caller + must establish that this is an actual lowest face of the root shape; + the radius alone does not prove that. Other bodies stay above that + plane by their enclosing spheres. Static balance is not certified. + """ + bodies: Tuple[AssemblyBody, ...] + free_cell: Tuple[Tuple[float, float], ...] + linear_half_width: float = 0. + angular_half_width: float = 0. + weld_force: float = 1000. + weld_erp: float = .2 + support_depth: Optional[float] = None + + def __post_init__(self) -> None: + bodies = tuple(self.bodies) + if (not bodies or len({b.name + for b in bodies}) != len(bodies) + or bodies[0].pose != _IDENTITY): + raise ValueError( + "Assembly needs distinct bodies and an identity root") + cell = tuple((float(lo), float(hi)) for lo, hi in self.free_cell) + if len(cell) != 3 or any( + not math.isfinite(lo) or not math.isfinite(hi) or lo >= hi + for lo, hi in cell): + raise ValueError("Free cell requires three finite intervals") + widths = (self.linear_half_width, self.angular_half_width) + if (any(not math.isfinite(v) or v < 0 for v in widths) + or ((widths[0] == 0) != (widths[1] == 0))): + raise ValueError("Twist widths must both be positive or both zero") + if (not math.isfinite(self.weld_force) or self.weld_force <= 0 + or not math.isfinite(self.weld_erp) + or not 0 <= self.weld_erp <= 1): + raise ValueError("Invalid weld settings") + for i, body in enumerate(bodies): + for other in bodies[:i]: + separation = np.linalg.norm( + np.asarray(body.pose[0]) - other.pose[0]) + if separation < body.radius + other.radius: + raise ValueError("Assembly enclosing spheres overlap") + object.__setattr__(self, "bodies", bodies) + object.__setattr__(self, "free_cell", cell) + if self.support_depth is not None: + depth = self.support_depth + if (not math.isfinite(depth) or not 0 < depth <= bodies[0].radius + or any(widths)): + raise ValueError( + "Supported case requires a valid face and rest") + if any(depth + b.pose[0][2] - b.radius < 0 for b in bodies[1:]): + raise ValueError("Attached body extends below support plane") + if depth + self.enclosing_radius > cell[2][1] - cell[2][0]: + raise ValueError("Supported assembly exceeds cell height") + # BoxPrior also rejects cells too small for any root position. + _ = self.coordinates + + @property + def enclosing_radius(self) -> float: + """Conservative assembly radius about its reference body.""" + return max( + float(np.linalg.norm(b.pose[0])) + b.radius for b in self.bodies) + + @property + def coordinates(self) -> BoxPrior: + """Normalized free-coordinate distribution, before any observations.""" + radius = self.enclosing_radius + if self.support_depth is not None: + return BoxPrior( + ("root_x", "root_y", "yaw_fraction"), + tuple((lo + radius, hi - radius) + for lo, hi in self.free_cell[:2]) + ((0., 1.), )) + names: Tuple[str, ...] = ("root_x", "root_y", "root_z", "rotation_u", + "rotation_v", "rotation_w") + bounds = tuple((lo + radius, hi - radius) for lo, hi in self.free_cell) + bounds += ((0., 1.), ) * 3 + if self.linear_half_width: + names += ("vx", "vy", "vz", "wx", "wy", "wz") + bounds += ((-self.linear_half_width, self.linear_half_width), ) * 3 + bounds += ( + (-self.angular_half_width, self.angular_half_width), ) * 3 + return BoxPrior(names, bounds) + + @property + def digest(self) -> str: + """Pin geometry, support, motion and the measure on root rotations.""" + return content_digest( + json.dumps( + { + "schema": + 1, + "family": + "free_cell_rigid_assembly", + "orientation": + "haar_so3_uniform_quaternion" + if self.support_depth is None else "uniform_yaw", + "prior": + asdict(self) + }, + sort_keys=True).encode()) + + def lift(self, coordinates: np.ndarray) -> AssemblyState: + """Map one free-coordinate point to compatible body poses and twists. + + This is a generative pushforward, with density one relative to + its normalized BoxPrior coordinates. Do not multiply Cartesian + densities for every derived body or count its pose twice. + """ + point = np.asarray(coordinates, dtype=float) + bounds = np.asarray(self.coordinates.bounds) + if (point.shape != (len(bounds), ) or not np.isfinite(point).all() + or np.any(point < bounds[:, 0]) + or np.any(point > bounds[:, 1])): + raise ValueError("Assembly coordinates outside declared support") + if self.support_depth is None: + u, v, w = point[3:6] + quaternion = (math.sqrt(1 - u) * math.sin(2 * math.pi * v), + math.sqrt(1 - u) * math.cos(2 * math.pi * v), + math.sqrt(u) * math.sin(2 * math.pi * w), + math.sqrt(u) * math.cos(2 * math.pi * w)) + root_position = point[:3] + else: + quaternion = (0., 0., math.sin(math.pi * point[2]), + math.cos(math.pi * point[2])) + root_position = np.array([ + point[0], point[1], self.free_cell[2][0] + self.support_depth + ]) + rotation = Rotation.from_quat(quaternion) + linear, angular = (point[6:9], + point[9:12]) if len(point) == 12 else (np.zeros(3), + np.zeros(3)) + poses = {} + velocities = {} + welds = [] + for index, body in enumerate(self.bodies): + offset = rotation.apply(body.pose[0]) + orientation = (rotation * + Rotation.from_quat(body.pose[1])).as_quat() + poses[body.name] = (tuple(root_position + offset), + tuple(orientation)) + velocities[body.name] = (tuple(linear + np.cross(angular, offset)), + tuple(angular)) + if index: + welds.append( + CommandWeld(self.bodies[0].name, body.name, body.pose, + _IDENTITY, self.weld_force, self.weld_erp)) + return AssemblyState(poses, velocities, tuple(welds)) diff --git a/predicators/code_sim_learning/inference_assessment.py b/predicators/code_sim_learning/inference_assessment.py new file mode 100644 index 000000000..92f417d9d --- /dev/null +++ b/predicators/code_sim_learning/inference_assessment.py @@ -0,0 +1,172 @@ +"""Offline posterior availability, separate from model predictive adequacy. + +The caller supplies an identified numerical assessment protocol and its +checks, including any repeat-run or budget comparisons that it requires. +Finishing a sampler or reporting high ESS alone does not establish +adequacy. This boundary neither publishes a fit nor approves an action. +""" +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Literal, Optional, Tuple + +from predicators.code_sim_learning.inference_data import InferenceIdentity +from predicators.code_sim_learning.inference_sampling import BatchPosterior + + +@dataclass(frozen=True) +class InferenceCheck: + """One named check, with an explanation and identified supporting report. + + Predictive names should identify the episode, feature, event or time + interval being assessed. Unevaluated checks carry an explanation of + what is missing instead of being counted as passing. + """ + name: str + status: Literal["pass", "fail", "unevaluated"] + detail: str + evidence: Optional[str] = None + + def __post_init__(self) -> None: + if not self.name or not self.detail: + raise ValueError("Checks need a name and explanation") + if self.status not in ("pass", "fail", "unevaluated"): + raise ValueError("Invalid check status") + if self.status != "unevaluated" and self.evidence is None: + raise ValueError("Evaluated checks need evidence") + if self.evidence is not None: + _validate_digest(self.evidence) + + +@dataclass(frozen=True) +class AssessmentProtocol: + """Identify declared numerical criteria, budgets and required checks. + + The source digest identifies their actual definitions, thresholds + and information boundary. Required names prevent accidentally + treating a subset of passing checks as a complete assessment. + Choosing a scientifically adequate protocol remains the caller's + responsibility; this class does not infer one from sampler output. + """ + source: str + required_numerical_checks: Tuple[str, ...] + + def __post_init__(self) -> None: + _validate_digest(self.source) + names = tuple(self.required_numerical_checks) + if not names or any(not name for name in names) or \ + len(set(names)) != len(names): + raise ValueError("Protocol needs distinct required checks") + object.__setattr__(self, "required_numerical_checks", names) + + +@dataclass(frozen=True) +class AssessedInference: + """Returned inference and diagnostics without publication side effects. + + An unavailable result has no posterior samples or intervals. Raw + sampler artifacts can be retained separately for investigation. A + predictive failure does not remove an otherwise numerically adequate + posterior. It also does not certify a decision or excuse a + contradictory exact constraint in the fitted target. + """ + identity: InferenceIdentity + protocol: AssessmentProtocol + numerical_checks: Tuple[InferenceCheck, ...] + predictive_checks: Tuple[InferenceCheck, ...] + sampler_status: str + availability: Literal["available", "numerical_failure", "unevaluated"] + posterior: Optional[BatchPosterior] + + +def assess_inference( + candidate: BatchPosterior, + protocol: AssessmentProtocol, + numerical_checks: Tuple[InferenceCheck, ...], + predictive_checks: Tuple[InferenceCheck, ...] = () +) -> AssessedInference: + """Apply explicit numerical checks without gating on prediction quality. + + Missing required checks are recorded as unevaluated. Duplicate and + undeclared numerical checks are errors rather than silently changed + protocol definitions. Prediction diagnostics remain attached on all + paths. No old fit is selected and no posterior weights are changed. + """ + numerical = tuple(numerical_checks) + predictive = tuple(predictive_checks) + for checks in (numerical, predictive): + if len({check.name for check in checks}) != len(checks): + raise ValueError("Duplicate assessment check") + required = protocol.required_numerical_checks + supplied = {check.name: check for check in numerical} + if set(supplied) - set(required): + raise ValueError("Numerical check is absent from the protocol") + ordered = tuple( + supplied.get( + name, + InferenceCheck(name, "unevaluated", + "Required check was not supplied")) + for name in required) + availability: Literal["available", "numerical_failure", "unevaluated"] + if candidate.status != "complete" or \ + any(check.status == "fail" for check in ordered): + availability = "numerical_failure" + elif any(check.status == "unevaluated" for check in ordered): + availability = "unevaluated" + else: + availability = "available" + if candidate.status == "complete": + _validate_samples(candidate) + return AssessedInference( + candidate.identity, protocol, ordered, predictive, candidate.status, + availability, candidate if availability == "available" else None) + + +def validated_posterior(result: AssessedInference) -> Optional[BatchPosterior]: + """Recheck a consumer's assessment, including manually built artifacts. + + Unavailable inference returns None while retaining its diagnostics. + Contradictory availability, identities or assessment claims raise. + This applies the declared protocol, not an implicit adequacy test. + """ + if result.availability not in ("available", "numerical_failure", + "unevaluated"): + raise ValueError("Invalid posterior availability") + if result.posterior is None: + if result.availability == "available": + raise ValueError("Available assessment needs a posterior") + return None + if result.availability != "available" or \ + result.identity != result.posterior.identity or \ + result.identity.prior != result.posterior.prior.digest: + raise ValueError("Assessment and posterior identity disagree") + checked = assess_inference(result.posterior, result.protocol, + result.numerical_checks, + result.predictive_checks) + if checked.availability != "available" or \ + checked.sampler_status != result.sampler_status: + raise ValueError("Assessment does not supply an available posterior") + return result.posterior + + +def _validate_digest(value: str) -> None: + if len(value) != 64 or any(c not in "0123456789abcdef" for c in value): + raise ValueError("Assessment identities must be SHA256 digests") + + +def _validate_samples(candidate: BatchPosterior) -> None: + """Reject malformed artifacts; this is not a statistical adequacy test.""" + if candidate.completed_temperature != 1 or not candidate.samples or \ + len(candidate.samples) != len(candidate.weights): + raise ValueError("Malformed completed posterior") + if any( + len(row) != len(candidate.prior.names) or any( + not math.isfinite(value) for value in row) + for row in candidate.samples): + raise ValueError("Invalid joint posterior samples") + if any(not math.isfinite(weight) or weight < 0 + for weight in candidate.weights) or \ + not math.isclose(math.fsum(candidate.weights), 1., + rel_tol=1e-12, abs_tol=1e-12): + raise ValueError("Posterior weights must be normalized") diff --git a/predicators/code_sim_learning/inference_checkpoint.py b/predicators/code_sim_learning/inference_checkpoint.py new file mode 100644 index 000000000..aa0753f15 --- /dev/null +++ b/predicators/code_sim_learning/inference_checkpoint.py @@ -0,0 +1,90 @@ +"""Atomic, identified continuation records for offline numerical inference. + +These records are solver state, not posterior samples or an assessment. +JSON avoids executable deserialization; a checksum detects incomplete or +accidentally modified files. Runtime compatibility is checked by the +caller. +""" +from __future__ import annotations + +import json +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict + +from predicators.code_sim_learning.inference_data import content_digest + + +@dataclass(frozen=True) +class SamplerCheckpoint: + """Immutable solver state associated with one complete run signature.""" + signature: str + state: str + + def __post_init__(self) -> None: + if len(self.signature) != 64 or any(c not in "0123456789abcdef" + for c in self.signature): + raise ValueError("Checkpoint signature must be a SHA256 digest") + value = json.loads(self.state) + if not isinstance(value, dict): + raise ValueError("Checkpoint state must be an object") + # Reject JSON extensions for NaN/infinity, including nested values. + json.dumps(value, allow_nan=False) + + def unpack(self) -> Dict[str, Any]: + """Return an owned copy so callbacks cannot alter the running + solver.""" + return dict(json.loads(self.state)) + + def save(self, path: Path) -> None: + """Atomically replace a record after flushing its complete contents. + + The parent directory must already exist. A failed write leaves + the previous record intact; callers must serialize writers for + the same run. No experiment is resumed automatically. + """ + payload = json.dumps( + { + "schema": 1, + "signature": self.signature, + "state": self.state + }, + sort_keys=True, + allow_nan=False) + envelope = json.dumps( + { + "payload": payload, + "sha256": content_digest(payload.encode("utf-8")) + }, + sort_keys=True, + allow_nan=False) + temporary = None + try: + with tempfile.NamedTemporaryFile(mode="w", + encoding="utf-8", + dir=path.parent, + prefix=path.name + ".", + delete=False) as stream: + temporary = Path(stream.name) + stream.write(envelope) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + @classmethod + def load(cls, path: Path) -> SamplerCheckpoint: + """Read a complete checksummed record without executing file + content.""" + envelope = json.loads(path.read_text(encoding="utf-8")) + payload = envelope["payload"] + if content_digest(payload.encode("utf-8")) != envelope["sha256"]: + raise ValueError("Checkpoint checksum mismatch") + value = json.loads(payload) + if value["schema"] != 1: + raise ValueError("Unsupported checkpoint schema") + return cls(value["signature"], value["state"]) diff --git a/predicators/code_sim_learning/inference_conditioning.py b/predicators/code_sim_learning/inference_conditioning.py new file mode 100644 index 000000000..b6650ba58 --- /dev/null +++ b/predicators/code_sim_learning/inference_conditioning.py @@ -0,0 +1,322 @@ +"""Offline change-of-variables reference for exact affine observations. + +This is a conditional-coordinate construction, not a contact simulator +constraint solver or a replacement for the production fitter. For free +coordinates u and eliminated coordinates z, the declared observation is +y = A(u) z + b(u). A must be square and nonsingular at the evaluated u. +The conditional density in u is proportional to p(u, z(u)) / |det A(u)|. +Callers must supply the actual declared affine equation, not a local +linearization of a nonlinear simulator output. +""" +from __future__ import annotations + +import json +import math +from dataclasses import dataclass +from typing import Tuple + +import numpy as np + +from predicators.code_sim_learning.inference_data import content_digest +from predicators.code_sim_learning.inference_sampling import BoxPrior + + +class UnsupportedConditioning(ValueError): + """This elimination chart cannot represent the requested constraint. + + A singular chart does not establish that the model is inconsistent; + another coordinate choice or a different representation may work. + """ + + +class ConditioningNumericalError(ValueError): + """Linear algebra failed to resolve this otherwise declared chart.""" + + +@dataclass(frozen=True) +class ConditionedGaussianCoordinate: + """A scalar conditional law and the density of its observed reading. + + sigma zero is an eliminated exactly observed coordinate. Otherwise + the remaining law is Gaussian. log_observation_factor retains the + marginal observation density under the original prior; it is needed + when prior parameters or alternative cases are also inferred. + """ + mean: float + sigma: float + log_observation_factor: float + + +def condition_gaussian_coordinate( + prior_mean: float, prior_sigma: float, observed: float, + sensor_sigma: float) -> ConditionedGaussianCoordinate: + """Condition a Gaussian coordinate on one additive Gaussian reading. + + The prior is fixed before observing the recording. Using the returned + distribution as a proposal absorbs this reading exactly once, with + its marginal density retained as the base weight. Do not score the + same reading again or use the result as the prior in a repeated fit + of identical data. A genuinely new independent reading can be added + sequentially under this same Gaussian model. + + This is a scalar real-valued coordinate, not a wrapped angle law, + collision-conditioned scene or posterior over dynamics. It can + propose noisy initial positions without treating them as truth. + Exact sensor sigma zero eliminates the coordinate and returns its + original prior density at the observation. No artificial noise floor + or empirical sampling-based normalizer is introduced. + """ + if not all( + math.isfinite(v) + for v in (prior_mean, prior_sigma, observed, sensor_sigma)): + raise ValueError("Gaussian conditioning requires finite inputs") + if prior_sigma <= 0 or sensor_sigma < 0: + raise ValueError("Prior sigma must be positive and sensor sigma " + "nonnegative") + total_sigma = math.hypot(prior_sigma, sensor_sigma) + if not math.isfinite(total_sigma): + raise ConditioningNumericalError("Gaussian marginal scale overflow") + prior_fraction = prior_sigma / total_sigma + sensor_fraction = sensor_sigma / total_sigma + mean = prior_mean * sensor_fraction**2 + observed * prior_fraction**2 + sigma = prior_sigma * sensor_fraction + # Divide before subtracting when the raw difference overflows, but + # retain the more accurate direct difference at ordinary scales. + difference = observed - prior_mean + residual = (difference / total_sigma if math.isfinite(difference) else + observed / total_sigma - prior_mean / total_sigma) + squared = residual * residual + factor = -.5 * squared - math.log(total_sigma) - .5 * math.log(2 * math.pi) + if not all(math.isfinite(v) for v in (mean, sigma, factor)) or \ + (sensor_sigma > 0 and sigma == 0): + raise ConditioningNumericalError( + "Gaussian conditional law exceeds floating-point range") + return ConditionedGaussianCoordinate(mean, sigma, factor) + + +@dataclass(frozen=True) +class ConditionedVelocity: + """A velocity consistent with an exact speed under a declared prior. + + The log factor is an observation mass at zero and a radial density + at positive speeds, with respect to delta-zero plus Lebesgue measure + on the nonnegative speed axis. It is not a density in Cartesian + velocity coordinates. Directions use uniform coordinates on a unit + square; their Jacobian is already included in the radial factor. + """ + velocity: Tuple[float, float, float] + free_dimensions: int + log_observation_factor: float + speed_residual: float + + +@dataclass(frozen=True) +class RestOrGaussianVelocityPrior: + """Explicit prior atom at rest plus isotropic Gaussian moving velocity. + + This is a candidate modeling assumption, not a reset guarantee or a + prior learned from missing recording metadata. The moving component + has zero mean and the same standard deviation on three Cartesian + axes. Geometry, joints, angular velocity and attachment consistency + require separate priors. A complete physical prior must define their + dependencies rather than multiply this component in by convenience. + """ + rest_probability: float + moving_sigma: float + + def __post_init__(self) -> None: + if not math.isfinite(self.rest_probability) or \ + not 0 <= self.rest_probability <= 1: + raise ValueError("Rest probability must lie in [0, 1]") + if not math.isfinite(self.moving_sigma) or self.moving_sigma <= 0: + raise ValueError( + "Moving velocity sigma must be finite and positive") + + @property + def digest(self) -> str: + """Identify normalized prior components and conditioning semantics.""" + return content_digest( + json.dumps( + { + "schema": 1, + "family": "rest_atom_isotropic_gaussian_velocity", + "rest_probability": float(self.rest_probability), + "moving_sigma": float(self.moving_sigma), + "speed_measure": "delta_zero_plus_positive_lebesgue", + "direction_map": "uniform_cos_polar_and_azimuth" + }, + sort_keys=True).encode("utf-8")) + + def condition_on_speed( + self, speed: float, direction: Tuple[float, + ...] = ()) -> ConditionedVelocity: + """Lift uniform direction coordinates and retain speed evidence. + + Zero speed selects the declared atom and has no free direction. + A zero observation with no atom needs a separately specified + conditional extension at this zero-density boundary; it is not + silently assigned a posterior here. At positive speed, the + radial Gaussian density is Maxwell, including the speed-squared + factor. No tolerance turns a small positive speed into the rest + event. + """ + if not math.isfinite(speed) or speed < 0: + raise ValueError("Speed must be finite and nonnegative") + if speed == 0: + if direction: + raise ValueError("Rest has no free direction coordinates") + if self.rest_probability == 0: + raise UnsupportedConditioning( + "Zero speed without a rest atom requires a conditional " + "extension") + return ConditionedVelocity((0., 0., 0.), 0, + math.log(self.rest_probability), 0.) + if len(direction) != 2 or any(not math.isfinite(v) or not 0 <= v <= 1 + for v in direction): + raise ValueError( + "Positive speed requires two unit-square coordinates") + cosine = 2 * direction[0] - 1 + azimuth = 2 * math.pi * direction[1] + radial = math.sqrt(max(0., 1 - cosine * cosine)) + velocity = (speed * radial * math.cos(azimuth), + speed * radial * math.sin(azimuth), speed * cosine) + if self.rest_probability == 1: + log_factor = -math.inf + else: + ratio = speed / self.moving_sigma + log_factor = (math.log1p(-self.rest_probability) + + .5 * math.log(2 / math.pi) + 2 * math.log(speed) - + 3 * math.log(self.moving_sigma) - .5 * ratio * ratio) + if not math.isfinite(log_factor): + raise ConditioningNumericalError( + "Speed log density exceeds floating-point range") + # hypot avoids squaring extreme Cartesian components unnecessarily. + residual = abs(math.hypot(*velocity) - speed) + return ConditionedVelocity(velocity, 2, log_factor, residual) + + +@dataclass(frozen=True) +class ConditionalPoint: + """Lifted coordinates and a base importance factor, without noisy data. + + The weight is relative to a uniform proposal on the original free + coordinate box. It is unnormalized: a single point neither defines a + posterior nor proves that the entire constraint has support. + Negative infinity means this point is outside the original prior. + """ + joint: Tuple[float, ...] + log_base_weight: float + max_constraint_residual: float + numerical_residual_bound: float + + +@dataclass(frozen=True) +class AffineConditioning: + """Eliminate observed coordinates under an immutable original box prior. + + The equation identity must hash the source and closure that + construct A(u) and b(u); these values are evaluated by the caller. + This module does not verify runtime closure. Observations and + eliminated-coordinate order are included in the digest. No first + noisy reading defines a new prior. Discrete cases and nonlinear or + redundant constraints need separate representations. + """ + prior: BoxPrior + eliminated: Tuple[str, ...] + observed: Tuple[float, ...] + equation_identity: str + + def __post_init__(self) -> None: + eliminated = tuple(self.eliminated) + observed = tuple(float(v) for v in self.observed) + if not eliminated or len(set(eliminated)) != len(eliminated) or \ + not set(eliminated) <= set(self.prior.names): + raise ValueError("Eliminate distinct coordinates in the prior") + if len(observed) != len(eliminated) or not all( + math.isfinite(v) for v in observed): + raise ValueError( + "One finite observation per eliminated coordinate") + if len(self.equation_identity) != 64 or any( + c not in "0123456789abcdef" for c in self.equation_identity): + raise ValueError("Equation identity must be a SHA256 digest") + object.__setattr__(self, "eliminated", eliminated) + object.__setattr__(self, "observed", observed) + + @property + def free_names(self) -> Tuple[str, ...]: + """Original order with exactly conditioned coordinates removed.""" + return tuple(n for n in self.prior.names if n not in self.eliminated) + + @property + def free_bounds(self) -> Tuple[Tuple[float, float], ...]: + """Proposal bounds, not a claim of uniform conditional density.""" + return tuple(b for n, b in zip(self.prior.names, self.prior.bounds) + if n not in self.eliminated) + + @property + def digest(self) -> str: + """Identify the original prior, equation, observation and chart.""" + return content_digest( + json.dumps( + { + "schema": 1, + "family": "affine_elimination_box", + "prior": self.prior.digest, + "eliminated": self.eliminated, + "observed": self.observed, + "equation": self.equation_identity, + "arithmetic": "float64_solve_backward_error_64eps" + }, + sort_keys=True).encode("utf-8")) + + def lift(self, free: np.ndarray, matrix: np.ndarray, + offset: np.ndarray) -> ConditionalPoint: + """Solve the declared equation and retain its density correction. + + A backward-error bound detects numerical failures only. It does + not admit an epsilon-wide observation band or change sensor + noise. Residuals are returned for audit, never used as noisy + likelihoods. A caller must not score the eliminated equality a + second time. + """ + free = np.asarray(free, dtype=np.float64) + matrix = np.asarray(matrix, dtype=np.float64) + offset = np.asarray(offset, dtype=np.float64) + size = len(self.eliminated) + if free.shape != (len(self.free_names), ) or \ + matrix.shape != (size, size) or offset.shape != (size, ): + raise ValueError( + "Affine chart coordinate or equation shape mismatch") + if not all(np.all(np.isfinite(v)) for v in (free, matrix, offset)): + raise ValueError("Affine chart requires finite inputs") + sign, log_det = np.linalg.slogdet(matrix) + if sign == 0: + raise UnsupportedConditioning("Singular affine elimination chart") + rhs = np.asarray(self.observed) - offset + try: + solved = np.linalg.solve(matrix, rhs) + except np.linalg.LinAlgError as err: + raise ConditioningNumericalError("Affine solve failed") from err + if not np.all(np.isfinite(solved)) or not math.isfinite(log_det): + raise ConditioningNumericalError("Nonfinite affine solution") + residual = float(np.max(np.abs(matrix @ solved - rhs))) + bound = float( + 64 * np.finfo(np.float64).eps * + (np.linalg.norm(matrix, ord=np.inf) * np.linalg.norm( + solved, ord=np.inf) + np.linalg.norm(rhs, ord=np.inf))) + if not math.isfinite(residual) or not math.isfinite(bound) or \ + residual > bound: + raise ConditioningNumericalError("Affine backward error exceeded") + values = dict(zip(self.free_names, free)) + values.update(zip(self.eliminated, solved)) + joint = tuple(float(values[n]) for n in self.prior.names) + # p(u,z)/q(u) cancels the free-coordinate uniform widths. Retain the + # eliminated widths and the observation-to-coordinate Jacobian. + log_weight = -float(log_det) - sum( + math.log(hi - lo) + for name, (lo, hi) in zip(self.prior.names, self.prior.bounds) + if name in self.eliminated) + if any(v < lo or v > hi + for v, (lo, hi) in zip(joint, self.prior.bounds)): + log_weight = -math.inf + return ConditionalPoint(joint, log_weight, residual, bound) diff --git a/predicators/code_sim_learning/inference_data.py b/predicators/code_sim_learning/inference_data.py new file mode 100644 index 000000000..1bb8481d9 --- /dev/null +++ b/predicators/code_sim_learning/inference_data.py @@ -0,0 +1,274 @@ +"""Immutable, offline observation ledger and declared sensor likelihood. + +This module does not read evaluator state, infer missing values, filter +observations, or alter the incumbent fitter. Callers identify actual +reset episodes and steps; repeated reads at a step are one measurement. +""" +from __future__ import annotations + +import hashlib +import json +import math +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Dict, Iterable, List, Mapping, Sequence, \ + Tuple + +if TYPE_CHECKING: + from predicators.observation_noise import ObservationNoise + from predicators.structs import State + +FeatureKey = Tuple[str, str, str] # object name, type name, feature name + + +def content_digest(payload: bytes) -> str: + """Hash exact artifact bytes; callers must include runtime dependencies.""" + return hashlib.sha256(payload).hexdigest() + + +def _digest(value: object) -> str: + return content_digest( + json.dumps(value, + sort_keys=True, + allow_nan=False, + separators=(",", ":")).encode("utf-8")) + + +@dataclass(frozen=True, order=True) +class SensorFeature: + """A measured scalar, optionally an explicitly conditioned exact input.""" + key: FeatureKey + sigma: float + conditioned: bool = False + + def __post_init__(self) -> None: + if not isinstance(self.key, tuple) or len(self.key) != 3 or not all( + isinstance(part, str) and part for part in self.key): + raise ValueError("Feature keys require three nonempty strings") + if not math.isfinite(self.sigma) or self.sigma < 0: + raise ValueError("Sensor sigma must be finite and nonnegative") + if self.conditioned and self.sigma != 0: + raise ValueError("Only exact inputs may be conditioned") + + +@dataclass(frozen=True) +class SensorModel: + """Independent, additive, unwrapped and unclipped Gaussian observations. + + Sigma zero denotes an exact constraint, with no numerical noise + floor. Conditioned fields are retained as inputs, not scored or + overwritten. A simulator adapter must explicitly consume those + inputs. + """ + features: Tuple[SensorFeature, ...] + + def __post_init__(self) -> None: + features = tuple(sorted(self.features)) + if len({feature.key for feature in features}) != len(features): + raise ValueError("Duplicate sensor feature") + object.__setattr__(self, "features", features) + + @classmethod + def from_state( + cls, + state: State, + noise: ObservationNoise, + conditioned: Iterable[FeatureKey] = () + ) -> SensorModel: + """Freeze public feature semantics using the injector's classification. + + This adapter includes object feature arrays only. Joint metadata + or other observations require explicitly declared additional + features; latent memory and simulator metadata are never + silently evidence. Undeclared sensor noise requires a different + inference contract. + """ + if not noise.declared: + raise ValueError( + "Offline reference requires declared sensor noise") + if any(not math.isfinite(s) or s < 0 + for s in (noise.position, noise.orientation, noise.scalar)): + raise ValueError("Invalid declared sensor noise") + keys = frozenset(conditioned) + features = tuple( + SensorFeature((obj.name, obj.type.name, + feat), noise.feature_sigma(obj.type, feat), ( + obj.name, obj.type.name, feat) in keys) + for obj in state for feat in obj.type.feature_names) + if not keys <= {feature.key for feature in features}: + raise ValueError("Unknown conditioned feature") + return cls(features) + + @property + def digest(self) -> str: + """Identity includes measurement and conditioning semantics.""" + return _digest({ + "schema": 1, + "channel": "additive_gaussian_exact", + "features": asdict(self) + }) + + def log_likelihood(self, observation: Observation, + prediction: Mapping[FeatureKey, float]) -> float: + """Score available measurements once; missing measurements add no term. + + Missing/nonfinite predictions are errors, not ignored residuals. + Exact contradictions give zero likelihood. Raw angles are never + wrapped: the current injector perturbs the stored angle directly. + """ + schema = {feature.key: feature for feature in self.features} + terms: List[float] = [] + for key, value in observation.values: + if key not in schema: + raise ValueError(f"Unknown observed feature: {key}") + feature = schema[key] + if feature.conditioned: + continue + if key not in prediction or not math.isfinite(prediction[key]): + raise ValueError(f"Missing or nonfinite prediction: {key}") + residual = value - prediction[key] + if feature.sigma == 0: + terms.append(0.0 if residual == 0 else -math.inf) + else: + scaled = residual / feature.sigma + terms.append(-0.5 * scaled * scaled - math.log(feature.sigma) - + 0.5 * math.log(2 * math.pi)) + return math.fsum(terms) + + +@dataclass(frozen=True) +class Observation: + """An immutable raw measurement at a primitive step within an episode. + + Missing components are absent entries, never NaN or imputed values. + The caller supplies masking explicitly before constructing this + object. + """ + step: int + values: Tuple[Tuple[FeatureKey, float], ...] + + def __post_init__(self) -> None: + if not isinstance(self.step, int) or self.step < 0: + raise ValueError("Observation step must be a nonnegative integer") + values = tuple( + sorted((key, float(value)) for key, value in self.values)) + if len({key for key, _ in values}) != len(values): + raise ValueError("Duplicate measurement feature") + for key, value in values: + SensorFeature(key, 0.0) + if not math.isfinite(value): + raise ValueError( + "Measurements must be finite; omit missing ones") + object.__setattr__(self, "values", values) + + @classmethod + def from_state(cls, step: int, state: State) -> Observation: + """Copy object feature values only, without inferred or privileged + data.""" + return cls( + step, + tuple( + ((obj.name, obj.type.name, feat), float(state.get(obj, feat))) + for obj in state for feat in obj.type.feature_names)) + + +@dataclass(frozen=True) +class EpisodeData: + """Actions and unique observations since an actual reset. + + Action t advances state t to t+1. Sparse observation times are + allowed, but the primitive action history cannot omit intervening + steps. A level change without reset must remain in the same episode. + """ + episode_id: str + actions: Tuple[Tuple[float, ...], ...] + observations: Tuple[Observation, ...] + + def __post_init__(self) -> None: + if not self.episode_id: + raise ValueError("A globally unique reset episode ID is required") + actions = tuple( + tuple(float(v) for v in action) for action in self.actions) + if any(not math.isfinite(v) for action in actions for v in action): + raise ValueError("Actions must be finite") + if len({len(action) for action in actions}) > 1: + raise ValueError( + "Primitive action dimension changed within episode") + unique: Dict[int, Observation] = {} + for observation in self.observations: + if observation.step > len(actions): + raise ValueError("Observation exceeds recorded action history") + previous = unique.setdefault(observation.step, observation) + if previous != observation: + raise ValueError("Conflicting reads of the same observation") + object.__setattr__(self, "actions", actions) + object.__setattr__(self, "observations", + tuple(unique[step] for step in sorted(unique))) + + +@dataclass(frozen=True) +class InferenceData: + """A canonical batch ledger; repeated fits reuse its original evidence.""" + episodes: Tuple[EpisodeData, ...] + + def __post_init__(self) -> None: + episodes = tuple(sorted(self.episodes, key=lambda e: e.episode_id)) + if len({episode.episode_id for episode in episodes}) != len(episodes): + raise ValueError("Duplicate reset episode ID") + object.__setattr__(self, "episodes", episodes) + + @property + def digest(self) -> str: + """Content identity includes masks, step coordinates and all + actions.""" + return _digest({"schema": 1, "data": asdict(self)}) + + def log_likelihood( + self, sensor: SensorModel, + predictions: Mapping[str, Sequence[Mapping[FeatureKey, + float]]]) -> float: + """Score a complete replay for exactly these reset episodes. + + Replay index zero is the initial state. All intermediate + predictions must be present even when an observation is missing. + No held-out suffix can enter through a mismatched trajectory + length. + """ + if set(predictions) != {e.episode_id for e in self.episodes}: + raise ValueError("Prediction episodes do not match the ledger") + terms: List[float] = [] + for episode in self.episodes: + states = predictions[episode.episode_id] + if len(states) != len(episode.actions) + 1: + raise ValueError( + "Prediction length does not match action history") + terms.extend( + sensor.log_likelihood(obs, states[obs.step]) + for obs in episode.observations) + return math.fsum(terms) + + +@dataclass(frozen=True) +class InferenceIdentity: + """Separate statistical identity from numerical sampler settings. + + The program digest must cover source and its parameter definitions; + runtime covers simulator dependencies, layout and configuration. + These are caller-supplied artifact digests, not automatic dependency + discovery. + """ + data: str + sensor: str + program: str + prior: str + runtime: str + + def __post_init__(self) -> None: + for value in asdict(self).values(): + if len(value) != 64 or any(c not in "0123456789abcdef" + for c in value): + raise ValueError("Identity fields must be SHA256 hex digests") + + @property + def digest(self) -> str: + """Identity changes after any recorded statistical input changes.""" + return _digest({"schema": 1, "inputs": asdict(self)}) diff --git a/predicators/code_sim_learning/inference_discrepancy.py b/predicators/code_sim_learning/inference_discrepancy.py new file mode 100644 index 000000000..f8606759d --- /dev/null +++ b/predicators/code_sim_learning/inference_discrepancy.py @@ -0,0 +1,190 @@ +"""An explicit stochastic velocity transition for offline model diagnostics. + +This is a different dynamics model, not sensor-noise inflation or a +numerical tolerance. A predicted velocity is followed by a declared +mixture of rest and an isotropic Gaussian correction. Exact speed can +then be conditioned analytically while direction remains uncertain. It +is not installed in the acting agent or the deterministic reference. +""" +from __future__ import annotations + +import json +import math +from dataclasses import asdict, dataclass +from typing import Tuple + +import numpy as np + +from predicators.code_sim_learning.inference_conditioning import \ + ConditionedVelocity, ConditioningNumericalError, UnsupportedConditioning +from predicators.code_sim_learning.inference_data import content_digest + + +@dataclass(frozen=True) +class VelocityDiscrepancy: + """Post-transition rest mass plus Gaussian velocity around a prediction. + + Conditional on the moving case, v_next = v_predicted + sigma * N(0,I). + The alternative rest case sets v_next to zero with its stated mass. + sigma is in velocity units per modeled transition, not observation + units or per-second diffusion units. Changing the time resolution + requires a separately specified model. The caller must declare where + the correction occurs relative to contacts, events and observations. + + Hyperparameters are model assumptions to evaluate or infer with an + identified original prior. They must not be selected to manufacture + support for each candidate or changed after inspecting a residual. + """ + rest_probability: float + sigma: float + + def __post_init__(self) -> None: + if not math.isfinite(self.rest_probability) or \ + not 0 <= self.rest_probability <= 1: + raise ValueError("Rest probability must lie in [0, 1]") + if not math.isfinite(self.sigma) or self.sigma <= 0: + raise ValueError("Correction sigma must be finite and positive") + + @property + def digest(self) -> str: + """Identify the transition law, conditional measure and units.""" + return content_digest( + json.dumps( + { + "schema": 1, + "family": "rest_or_gaussian_velocity_transition", + "parameters": asdict(self), + "speed_measure": "delta_zero_plus_positive_lebesgue", + "direction": "von_mises_fisher_unit_square", + "time_units": "per_declared_transition" + }, + sort_keys=True).encode("utf-8")) + + def condition_on_speed( + self, + predicted: Tuple[float, float, float], + speed: float, + direction: Tuple[float, ...] = ()) -> ConditionedVelocity: + """Condition a transition and retain its speed mass or radial density. + + Positive speed has a noncentral chi radial density (three + dimensions). Its conditional direction is von Mises-Fisher, + centered on the predicted direction with concentration + speed * norm(predicted) / sigma**2. Two independent unit uniforms + map to this normalized direction law, so no additional proposal + weight is required. At zero speed the rest component is selected. + + A retained direction affects subsequent positions and contacts; + it cannot be replaced by its mean without changing the model. + The reported speed residual is floating-point reconstruction + error, not an observation acceptance threshold. + """ + if len(predicted) != 3 or any(not math.isfinite(v) for v in predicted): + raise ValueError("Predicted velocity needs three finite values") + if not math.isfinite(speed) or speed < 0: + raise ValueError("Speed must be finite and nonnegative") + if speed == 0: + if direction: + raise ValueError("Rest has no direction coordinates") + if self.rest_probability == 0: + raise UnsupportedConditioning( + "Zero speed without a rest atom needs another " + "representation") + return ConditionedVelocity((0., 0., 0.), 0, + math.log(self.rest_probability), 0.) + if len(direction) != 2 or any(not math.isfinite(v) or not 0 <= v <= 1 + for v in direction): + raise ValueError( + "Moving speed requires two unit-square coordinates") + magnitude = math.hypot(*predicted) + if not math.isfinite(magnitude): + raise ConditioningNumericalError("Predicted speed overflow") + concentration, log_radial = _radial_law(speed, magnitude, self.sigma) + cosine = _direction_cosine(direction[0], concentration) + azimuth = 2 * math.pi * direction[1] + radius = math.sqrt(max(0., (1 - cosine) * (1 + cosine))) + if magnitude == 0: + unit = np.array([ + radius * math.cos(azimuth), radius * math.sin(azimuth), cosine + ]) + else: + axis = np.asarray(predicted) / magnitude + reference = np.zeros(3) + reference[int(np.argmin(np.abs(axis)))] = 1. + tangent = np.cross(axis, reference) + tangent /= math.hypot(*tangent) + bitangent = np.cross(axis, tangent) + unit = ( + cosine * axis + radius * + (math.cos(azimuth) * tangent + math.sin(azimuth) * bitangent)) + velocity = (float(speed * unit[0]), float(speed * unit[1]), + float(speed * unit[2])) + if any(not math.isfinite(v) for v in velocity): + raise ConditioningNumericalError("Conditional velocity overflow") + log_factor = (-math.inf if self.rest_probability == 1 else + math.log1p(-self.rest_probability) + log_radial) + return ConditionedVelocity(velocity, 2, log_factor, + abs(math.hypot(*velocity) - speed)) + + def sample(self, predicted: Tuple[float, float, float], + rng: np.random.Generator) -> Tuple[float, float, float]: + """Draw the original transition law without observing future speed. + + The rest branch sets velocity to zero; the moving branch adds + isotropic Gaussian noise around the native prediction. The + caller supplies a local generator and applies the draw at the + declared physical transition boundary, retaining any angular + velocity. This does not score exact speed or condition on a + future reading. + """ + if len(predicted) != 3 or any(not math.isfinite(v) for v in predicted): + raise ValueError("Predicted velocity needs three finite values") + if rng.random() < self.rest_probability: + return (0., 0., 0.) + values = rng.normal(predicted, self.sigma) + if any(not math.isfinite(v) for v in values): + raise ConditioningNumericalError("Sampled velocity overflow") + return (float(values[0]), float(values[1]), float(values[2])) + + +def _radial_law(speed: float, mean: float, + sigma: float) -> Tuple[float, float]: + """Stable noncentral chi density without exponentiating a large square.""" + scaled_speed, scaled_mean = speed / sigma, mean / sigma + concentration = scaled_speed * scaled_mean + if not math.isfinite(concentration): + raise ConditioningNumericalError("Directional concentration overflow") + if concentration < 1e-4: + # log(sinh(k)/k) = k**2/6 - k**4/180 + O(k**6). + # The omitted term is below 4e-28 at this branch boundary. + log_radial = ( + .5 * math.log(2 / math.pi) + 2 * math.log(speed) - + 3 * math.log(sigma) - .5 * + (scaled_speed * scaled_speed + scaled_mean * scaled_mean) + + concentration**2 / 6 - concentration**4 / 180) + else: + difference = (speed - mean) / sigma + log_radial = (math.log(speed) - math.log(mean) - math.log(sigma) - + .5 * math.log(2 * math.pi) - + .5 * difference * difference + + math.log(-math.expm1(-2 * concentration))) + if not math.isfinite(log_radial): + raise ConditioningNumericalError("Radial log density overflow") + return concentration, log_radial + + +def _direction_cosine(uniform: float, concentration: float) -> float: + """Invert the normalized axial CDF, retaining antipodal endpoints.""" + if concentration == 0: + return 2 * uniform - 1 + if uniform == 0: + return -1. + if uniform == 1: + return 1. + if concentration < .5: + return math.log1p(uniform * math.expm1(2 * concentration)) / \ + concentration - 1 + log_mixture = float( + np.logaddexp(math.log(uniform), + math.log1p(-uniform) - 2 * concentration)) + return 1 + log_mixture / concentration diff --git a/predicators/code_sim_learning/inference_feasibility.py b/predicators/code_sim_learning/inference_feasibility.py new file mode 100644 index 000000000..8aa0e71c3 --- /dev/null +++ b/predicators/code_sim_learning/inference_feasibility.py @@ -0,0 +1,199 @@ +"""Offline whole-candidate rejection for a declared conditional prior. + +Drawing a complete base candidate and accepting exactly when C holds +samples p0(x) I[C(x)] / Z, where Z is its acceptance probability. This +procedure does not estimate a normalized density or model evidence. The +same construction across mixture cases changes their accepted masses in +proportion to feasibility. Resampling only failed parts or normalizing +each case separately would define a different prior. +""" +from __future__ import annotations + +import json +import math +from dataclasses import dataclass +from typing import Callable, Generic, List, Literal, Optional, Tuple, TypeVar + +import numpy as np + +from predicators.code_sim_learning.inference_data import content_digest +from predicators.code_sim_learning.inference_sampling import \ + ConditionedPrior, PriorPoint + +T = TypeVar("T") + + +@dataclass(frozen=True) +class FeasibleConditioning: + """Compose exact conditioning and geometric support without losing mass. + + global_joint declares p0(theta, s) I[C(theta, s)] / Z, with one + constant Z for the full joint distribution. conditional_state instead + preserves p0(theta) and normalizes the state law separately for each + theta. It requires the original, pre-observation log acceptance + probability log Z(theta). The two laws generally give different + parameter posteriors, even with identical feasible sets. + + The base map retains exact-observation density and proposal factors. + A support check operates on its complete lifted candidate. It must + not independently redraw parts or recondition on noisy observations. + Dependencies, geometric tolerances and callback source/closure must + be covered by the supplied identities. This is an offline contract, + not automatic normalization or a physical scene implementation. + """ + base: ConditionedPrior + support: str + normalization: Literal["global_joint", "conditional_state"] + normalizer_identity: Optional[str] = None + + def __post_init__(self) -> None: + if self.normalization not in ("global_joint", "conditional_state"): + raise ValueError("Declare global_joint or conditional_state") + if (self.normalization == "conditional_state") != \ + (self.normalizer_identity is not None): + raise ValueError("Conditional state support needs a normalizer " + "identity; global joint support must omit it") + for digest in (self.support, self.normalizer_identity): + if digest is not None and (len(digest) != 64 + or any(c not in "0123456789abcdef" + for c in digest)): + raise ValueError("Support identities must be SHA256 digests") + + @property + def prior(self) -> ConditionedPrior: + """Identify the supported original law separately from observations. + + Changing data in the base conditioning leaves original_prior + unchanged. Changing the support or normalization changes that + original law. The omitted global constant permits posterior + ratios for this fixed target, never model-evidence comparisons. + """ + original = content_digest( + json.dumps( + { + "schema": 1, + "family": "feasible_original_prior", + "original_prior": self.base.original_prior, + "support": self.support, + "normalization": self.normalization, + "normalizer": self.normalizer_identity + }, + sort_keys=True).encode()) + conditioning = content_digest( + json.dumps( + { + "schema": 1, + "base_conditioning": self.base.conditioning, + "supported_prior": original + }, + sort_keys=True).encode()) + return ConditionedPrior(self.base.names, original, conditioning, + self.base.proposal) + + def lift( + self, + free: np.ndarray, + condition: Callable[[np.ndarray], PriorPoint], + feasible: Callable[[np.ndarray], bool], + *, + log_normalizer: Optional[Callable[[np.ndarray], float]] = None + ) -> PriorPoint: + """Return the full conditional-base weight before likelihood tempering. + + conditional_state requires a deterministic declared normalizer, + not an observed finite rejection rate. Its callback may depend + only on variables retained by the original conditional state + law, such as theta, not the sampled state or current data. + Missing normalization is unsupported, never silently set to one. + Exceptions remain setup/numerical failures, not zero likelihood. + Each callback receives an owned array to prevent shared + mutation. + """ + if (self.normalization == "conditional_state") != \ + (log_normalizer is not None): + raise ValueError("Supply a log normalizer exactly for " + "conditional_state") + point = condition(np.array(free, dtype=float, copy=True)) + joint = np.asarray(point.joint, dtype=float) + if joint.shape != (len(self.base.names), ) or \ + not np.isfinite(joint).all(): + raise ValueError("Base map returned invalid joint values") + weight = float(point.log_weight) + if math.isnan(weight) or weight == math.inf: + raise ValueError("Base map returned invalid log weight") + values = tuple(float(v) for v in joint) + if weight == -math.inf: + return PriorPoint(values, weight) + valid = feasible(joint.copy()) + if not isinstance(valid, (bool, np.bool_)): + raise TypeError("Feasibility predicate must return a boolean") + if not valid: + return PriorPoint(values, -math.inf) + if log_normalizer is not None: + normalizer = float(log_normalizer(joint.copy())) + if not math.isfinite(normalizer) or normalizer > 0: + raise ValueError("Log support probability must be finite " + "and nonpositive on feasible candidates") + weight -= normalizer + if not math.isfinite(weight): + raise ArithmeticError("Supported base weight overflow") + return PriorPoint(values, weight) + + +@dataclass(frozen=True) +class FeasibleDraws(Generic[T]): + """Complete prior draws or an explicit exhausted rejection budget. + + accepted counts successes seen even when fewer than requested; no + partial batch is exposed as a completed result. A failed search does + not prove empty support. These are prior, not posterior draws. + """ + original_prior: str + support: str + seed: int + requested: int + max_draws: int + draws: int + accepted: int + samples: Tuple[T, ...] + status: Literal["complete", "budget_exhausted"] + + +def draw_feasible(original_prior: str, support: str, + draw: Callable[[np.random.Generator], + T], feasible: Callable[[T], bool], *, + count: int, max_draws: int, seed: int) -> FeasibleDraws[T]: + """Sample whole candidates with a fixed, explicitly identified predicate. + + draw must return independently drawn owned values from the declared + normalized base prior. The predicate must be deterministic and its + geometry, runtime and numerical policy must be included in support. + Errors propagate instead of being classified as collision rejection. + + The acceptance rate is not an exact normalizer. Fixed-target + posterior ratios can omit a common Z, but changing parameters or + mixture-specific normalization may change Z and must not silently + omit it. This function makes no parameter-independence assertion. + """ + for digest in (original_prior, support): + if len(digest) != 64 or any(c not in "0123456789abcdef" + for c in digest): + raise ValueError("Feasibility identities must be SHA256 digests") + if any(not isinstance(v, int) or v <= 0 for v in (count, max_draws)): + raise ValueError("Rejection counts must be positive integers") + rng = np.random.default_rng(seed) + accepted: List[T] = [] + attempts = 0 + while attempts < max_draws and len(accepted) < count: + candidate = draw(rng) + attempts += 1 + valid = feasible(candidate) + if not isinstance(valid, (bool, np.bool_)): + raise TypeError("Feasibility predicate must return a boolean") + if valid: + accepted.append(candidate) + complete = len(accepted) == count + return FeasibleDraws(original_prior, support, seed, count, max_draws, + attempts, len(accepted), + tuple(accepted) if complete else (), + "complete" if complete else "budget_exhausted") diff --git a/predicators/code_sim_learning/inference_joints.py b/predicators/code_sim_learning/inference_joints.py new file mode 100644 index 000000000..26570d1a4 --- /dev/null +++ b/predicators/code_sim_learning/inference_joints.py @@ -0,0 +1,377 @@ +"""Explicit offline joint priors, conditioned on exact initial positions. + +Joint bounds and motion assumptions belong to the declared model. They +are not estimated from the observed extrema or filled from evaluator +metadata. This component does not establish robot/scene collision +feasibility or solve exact constraints at subsequent trajectory steps. +""" +from __future__ import annotations + +import json +import math +from dataclasses import asdict, dataclass +from statistics import NormalDist +from typing import Mapping, Optional, Tuple, Union + +import numpy as np + +from predicators.code_sim_learning.inference_data import content_digest +from predicators.code_sim_learning.inference_sampling import BoxPrior + + +class IncompatibleJointObservation(ValueError): + """An exact initial measurement lies outside this component's support. + + This is a prior-specific contradiction, not a finite sampler miss or + proof that all possible initial-state models are inconsistent. + """ + + +class JointCoordinateBoundary(ValueError): + """A zero-measure coordinate boundary has no supported conditional lift.""" + + +@dataclass(frozen=True) +class GaussianJointPosition: + """A declared Gaussian over reset angles/positions, without wrapping. + + This describes a simulator initialization law, not an ideal hard + mechanical limit. Its parameters must be fixed independently of the + readings used for a fit. It does not certify collision feasibility. + """ + mean: float + sigma: float + + def __post_init__(self) -> None: + if not math.isfinite(self.mean) or not math.isfinite( + self.sigma) or self.sigma <= 0: + raise ValueError( + "Gaussian position needs a finite mean and positive scale") + + def log_density(self, value: float) -> float: + """Retain exact-position information under the original reset law.""" + ratio = (value - self.mean) / self.sigma + result = -.5 * ratio * ratio - math.log( + self.sigma) - .5 * math.log(2 * math.pi) + if not math.isfinite(result): + raise ArithmeticError( + "Gaussian position density is not representable") + return result + + def quantile(self, unit: float) -> float: + """Push uniform open-unit coordinates to the whole real line.""" + if not 0 < unit < 1: + raise JointCoordinateBoundary( + "Gaussian quantiles require an interior coordinate") + result = self.mean + self.sigma * NormalDist().inv_cdf(unit) + if not math.isfinite(result): + raise ArithmeticError( + "Gaussian position quantile is not representable") + return result + + +PositionPrior = Union[Tuple[float, float], GaussianJointPosition] + + +@dataclass(frozen=True) +class JointStatePrior: + """Independent position priors and explicit rest/motion choices. + + None denotes a mechanically fixed joint with state (0, 0). Movable + joints have either finite uniform bounds or Gaussian reset + positions. Finite winding support and Gaussian tails are explicit + assumptions, not mechanical limits or angle-wrapping rules. Positive + velocity half-widths give normalized uniform velocities; zero + denotes a prior atom at zero velocity. Dependencies and mixture + masses require a separately specified full initial-state model. + """ + names: Tuple[str, ...] + position_priors: Tuple[Optional[PositionPrior], ...] + velocity_half_widths: Tuple[float, ...] + + def __post_init__(self) -> None: + names = tuple(self.names) + bounds = tuple( + b if b is None or isinstance(b, GaussianJointPosition) else tuple( + float(v) for v in b) for b in self.position_priors) + widths = tuple(float(v) for v in self.velocity_half_widths) + if (not names or len(set(names)) != len(names) + or any(not isinstance(n, str) or not n for n in names) + or len(bounds) != len(names) or len(widths) != len(names)): + raise ValueError( + "Joint prior requires distinct names and matching fields") + for bound, width in zip(bounds, widths): + if not math.isfinite(width) or width < 0 or not math.isfinite( + 2 * width): + raise ValueError( + "Joint velocity width must be finite and nonnegative") + if bound is None: + if width != 0: + raise ValueError( + "Fixed joint cannot have uncertain velocity") + elif isinstance(bound, GaussianJointPosition): + continue + elif (len(bound) != 2 or not all(math.isfinite(v) for v in bound) + or bound[0] >= bound[1] + or not math.isfinite(bound[1] - bound[0])): + raise ValueError( + "Movable joint requires finite position bounds") + object.__setattr__(self, "names", names) + object.__setattr__(self, "position_priors", bounds) + object.__setattr__(self, "velocity_half_widths", widths) + + @property + def digest(self) -> str: + """Identify mechanical reductions and normalized component measures.""" + return content_digest( + json.dumps( + { + "schema": 2, + "family": + "joint_position_priors_and_uniform_or_rest_velocities", + "prior": asdict(self) + }, + sort_keys=True).encode()) + + def condition_positions( + self, observations: Mapping[str, float]) -> ConditionedJointPrior: + """Eliminate measured initial coordinates and retain their density. + + The exact measurements must be initial positions, not future + values injected into a rollout. Movable positions contribute + their original position density. A fixed joint's zero + contributes unit mass. Out-of-support observations raise a + distinct error. + """ + return ConditionedJointPrior(self, tuple(observations.items())) + + +@dataclass(frozen=True) +class ConditionedJointPrior: + """A fixed-prior conditional component, retaining all URDF joint states. + + Construct via JointStatePrior.condition_positions. An empty free + coordinate space is a deterministic conditional, not a fake + interval. Its observation factor remains available even when no + sampling is needed. This result does not assert feasibility of later + outputs. + """ + prior: JointStatePrior + observations: Tuple[Tuple[str, float], ...] + + def __post_init__(self) -> None: + values = tuple(sorted((n, float(v)) for n, v in self.observations)) + names = [n for n, _ in values] + if len(set(names)) != len(names) or not set(names) <= set( + self.prior.names): + raise ValueError("Unknown or repeated observed joint") + for name, value in values: + if not math.isfinite(value): + raise ValueError("Joint observation must be finite") + bound = self.prior.position_priors[self.prior.names.index(name)] + if (bound is None + and value != 0) or (isinstance(bound, tuple) + and not bound[0] <= value <= bound[1]): + raise IncompatibleJointObservation( + f"Exact position for {name} is outside the declared prior") + object.__setattr__(self, "observations", values) + + @property + def coordinates(self) -> Optional[BoxPrior]: + """Normalized free position/velocity coordinates, or a point mass.""" + measured = dict(self.observations) + names = [] + bounds = [] + for name, bound in zip(self.prior.names, self.prior.position_priors): + if bound is not None and name not in measured: + names.append(name + ".position") + bounds.append(( + 0., + 1.) if isinstance(bound, GaussianJointPosition) else bound) + for name, width in zip(self.prior.names, + self.prior.velocity_half_widths): + if width: + names.append(name + ".velocity") + bounds.append((-width, width)) + return BoxPrior(tuple(names), tuple(bounds)) if names else None + + @property + def log_observation_factor(self) -> float: + """Density/mass of the conditioned readings under the original + prior.""" + measured = dict(self.observations) + return sum( + bound.log_density(measured[name]) if isinstance( + bound, GaussianJointPosition) else -math.log(bound[1] - + bound[0]) for + name, bound in zip(self.prior.names, self.prior.position_priors) + if name in measured and bound is not None) + + @property + def digest(self) -> str: + """Bind the original prior and exact initial conditioning values.""" + return content_digest( + json.dumps( + { + "schema": 1, + "prior": self.prior.digest, + "initial_positions": self.observations + }, + sort_keys=True).encode()) + + def lift(self, point: np.ndarray) -> Tuple[Tuple[float, float], ...]: + """Return position/velocity for every joint in declared URDF order.""" + space = self.coordinates + values = np.asarray(point, dtype=float) + size = 0 if space is None else len(space.names) + if values.shape != (size, ) or not np.isfinite(values).all(): + raise ValueError("Invalid free joint coordinates") + free = {} + if space is not None: + bounds = np.asarray(space.bounds) + if np.any(values < bounds[:, 0]) or np.any(values > bounds[:, 1]): + raise ValueError( + "Free joint coordinates outside prior support") + free = dict(zip(space.names, values)) + measured = dict(self.observations) + joints = [] + for name, bound in zip(self.prior.names, self.prior.position_priors): + position = measured.get(name, free.get(name + ".position", 0.)) + if isinstance(bound, + GaussianJointPosition) and name not in measured: + position = bound.quantile(position) + velocity = free.get(name + ".velocity", 0.) + joints.append((float(position) if bound is not None else 0., + float(velocity))) + return tuple(joints) + + +@dataclass(frozen=True) +class RestingJointPrior: + """A declared rest/motion law for an articulated joint. + + Rest mass is divided equally between two declared controller poses, + with zero velocity. The remaining mass has independent uniform + position and velocity. This engineering prior is not implied by a + Boolean reading or by the absence of recorded joint motion. + """ + name: str + lower: float + upper: float + rest_positions: Tuple[float, float] + rest_probability: float + velocity_half_width: float + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or not self.name or any( + not math.isfinite(value) for value in ( + self.lower, self.upper, self.rest_probability, + self.velocity_half_width)) or self.lower >= self.upper or \ + not math.isfinite(self.upper - self.lower) or \ + not 0 <= self.rest_probability <= 1 or \ + self.velocity_half_width <= 0: + raise ValueError("Invalid resting joint prior") + poses = tuple(float(value) for value in self.rest_positions) + if len(poses) != 2 or not self.lower <= poses[0] < poses[1] <= \ + self.upper: + raise ValueError("Rest poses must lie inside the joint support") + object.__setattr__(self, "rest_positions", poses) + + @property + def digest(self) -> str: + """Identify controller-pose atoms and the normalized continuous + component.""" + return content_digest( + json.dumps( + { + "family": "controller_rest_uniform_motion", + "schema": 1, + "prior": asdict(self) + }, + sort_keys=True).encode("utf-8")) + + def condition_above(self, threshold: float, + observed: bool) -> ThresholdJointPrior: + """Condition on position > threshold without selecting one angle.""" + return ThresholdJointPrior(self, threshold, observed) + + +@dataclass(frozen=True) +class ThresholdJointPrior: + """Conditional position/motion with the Boolean observation mass retained. + + The two unit coordinates encode the rest/moving mixture and + velocity. They are auxiliary uniforms in each rest case, which has + no continuous physical dimensions; the moving case has two. + """ + prior: RestingJointPrior + threshold: float + observed: bool + + def __post_init__(self) -> None: + if not isinstance(self.observed, bool) or not \ + self.prior.lower < self.threshold < self.prior.upper: + raise ValueError("Threshold must be interior and reading Boolean") + if self.prior.rest_probability == 1 and not self._rest_positions: + raise IncompatibleJointObservation("No rest pose matches reading") + + @property + def _rest_positions(self) -> Tuple[float, ...]: + return tuple(q for q in self.prior.rest_positions + if (q > self.threshold) == self.observed) + + @property + def _interval(self) -> Tuple[float, float]: + if self.observed: + return self.threshold, self.prior.upper + return self.prior.lower, self.threshold + + @property + def log_observation_factor(self) -> float: + """Probability mass, including any parameter-dependent event cut.""" + lower, upper = self._interval + rest = self.prior.rest_probability + atom = math.log(rest) + math.log(len(self._rest_positions)) - \ + math.log(2) if rest and self._rest_positions else -math.inf + motion = math.log1p(-rest) + math.log(upper - lower) - \ + math.log(self.prior.upper - self.prior.lower) if rest < 1 else \ + -math.inf + larger, smaller = max(atom, motion), min(atom, motion) + return larger + math.log1p(math.exp(smaller - larger)) + + @property + def rest_probability(self) -> float: + """Posterior mass on the compatible resting controller poses.""" + rest = self.prior.rest_probability + return math.exp( + math.log(rest) + math.log(len(self._rest_positions)) - math.log(2) + - self.log_observation_factor) if rest and \ + self._rest_positions else 0. + + @property + def coordinates(self) -> BoxPrior: + """Normalized proposal coordinates with unused rest-case + auxiliaries.""" + return BoxPrior((self.prior.name + ".position_mixture", + self.prior.name + ".velocity"), ((0., 1.), ) * 2) + + def lift(self, point: np.ndarray) -> Tuple[float, float]: + """Draw from the conditional measure; never project an invalid draw.""" + values = np.asarray(point, dtype=float) + if values.shape != (2, ) or not np.isfinite(values).all(): + raise ValueError("Invalid conditional joint coordinates") + if np.any(values <= 0) or np.any(values >= 1): + raise JointCoordinateBoundary( + "Threshold joint coordinates must be interior") + endpoint = self.rest_probability + for index, position in enumerate(self._rest_positions): + if values[0] < endpoint * (index + 1) / len(self._rest_positions): + return position, 0. + lower, upper = self._interval + position = lower + (upper - lower) * \ + (float(values[0]) - endpoint) / (1 - endpoint) + if not lower < position < upper: + raise JointCoordinateBoundary( + "Interior position rounded to a conditional boundary") + velocity = self.prior.velocity_half_width * (2 * float(values[1]) - 1) + return position, velocity diff --git a/predicators/code_sim_learning/inference_observation.py b/predicators/code_sim_learning/inference_observation.py new file mode 100644 index 000000000..8110e3d5a --- /dev/null +++ b/predicators/code_sim_learning/inference_observation.py @@ -0,0 +1,338 @@ +"""Compose an identified full observation likelihood for offline inference. + +Each measurement belongs to one declared factor or the original sensor +likelihood. Checked readouts retain their source factor. Unsupported +partial coupled readings are errors, and unmodeled exact contradictions +remain zero likelihood. No production fitting or execution path changes. +""" +from __future__ import annotations + +import json +import math +from dataclasses import dataclass +from typing import Callable, Tuple + +import numpy as np +import pybullet as p + +from predicators.code_sim_learning.inference_conditioning import \ + ConditioningNumericalError, UnsupportedConditioning +from predicators.code_sim_learning.inference_data import FeatureKey, \ + Observation, SensorModel, content_digest +from predicators.code_sim_learning.inference_orientation import \ + QuaternionOutputError +from predicators.code_sim_learning.inference_output_error import \ + GaussianOutputError, output_error_likelihood +from predicators.code_sim_learning.inference_readout import ExactReadout, \ + reduce_exact_readout + + +@dataclass(frozen=True) +class ScalarOutputFactor: + """An explicitly selected real-valued discrepancy channel.""" + key: FeatureKey + process: GaussianOutputError + + +@dataclass(frozen=True) +class EulerOutputFactor: + """An exact (roll, pitch, yaw) readout with coupled output discrepancy. + + The Gaussian center is the representative quaternion constructed + from the native predicted Euler triple, not hidden recorded state. + The declared mixture retains both quaternion signs. This choice is + part of the model and loses any information absent from that triple. + The inference runtime identity must capture the PyBullet conversion. + """ + keys: Tuple[FeatureKey, FeatureKey, FeatureKey] + process: QuaternionOutputError + + +@dataclass(frozen=True) +class CheckedReadoutFactor: + """A reviewed parameter-independent readout and its identified callback.""" + declaration: ExactReadout + evaluate: Callable[[float], float] + + +@dataclass(frozen=True) +class OutputObservationModel: + """A complete measurement partition, conditional on native predictions. + + Discrepancy processes are independent across declared factors, + conditional on the physical candidate and their hyperparameters. + Within a scalar factor the full temporal error history is integrated. + Every unassigned measurement uses the unchanged sensor model. + In particular, event errors do not disappear when continuous outputs + obtain an explicit discrepancy law. + + This model can be called from a joint parameter/initial-state target, + but does not itself provide a physical prior, simulator replay, or + evidence that the numerical posterior has been explored adequately. + """ + sensor: SensorModel + scalars: Tuple[ScalarOutputFactor, ...] = () + eulers: Tuple[EulerOutputFactor, ...] = () + readouts: Tuple[CheckedReadoutFactor, ...] = () + + def __post_init__(self) -> None: + schema = {f.key: f for f in self.sensor.features} + assigned = [f.key for f in self.scalars] + for factor in self.eulers: + if len(factor.keys) != 3: + raise ValueError("A coupled Euler factor requires three keys") + assigned.extend(factor.keys) + if any(k not in schema or schema[k].sigma != 0 + for k in factor.keys): + raise UnsupportedConditioning( + "Euler discrepancy currently requires exact readings") + outputs = [r.declaration.output for r in self.readouts] + assigned.extend(outputs) + if len(set(assigned)) != len(assigned): + raise ValueError("A measurement cannot have multiple factors") + if any(k not in schema or schema[k].conditioned for k in assigned): + raise ValueError("Factors require predicted sensor fields") + for readout in self.readouts: + declaration = readout.declaration + if declaration.source in outputs: + raise UnsupportedConditioning( + "Chained readouts require an explicit joint reduction") + # Validate the declared map/schema even in an empty episode. + reduce_exact_readout(Observation(0, ()), self.sensor, declaration, + readout.evaluate) + + @property + def digest(self) -> str: + """Identify all factors and the representative-quaternion + convention.""" + return content_digest( + json.dumps( + { + "schema": 1, + "sensor": self.sensor.digest, + "scalars": [(f.key, f.process.digest) + for f in self.scalars], + "eulers": [(f.keys, f.process.digest) + for f in self.eulers], + "readouts": [r.declaration.digest for r in self.readouts], + "orientation_center": + "pybullet_quaternion_from_predicted_euler", + "cross_factor_dependence": "conditionally_independent", + "unassigned_fields": "original_sensor_likelihood" + }, + sort_keys=True).encode("utf-8")) + + def log_likelihood(self, predictions: Tuple[Observation, ...], + observations: Tuple[Observation, ...]) -> float: + """Score a complete reset episode, including its initial observation. + + Missing readings use empty/partial Observation values, with + every primitive step represented. A repeated full-data fit + starts the original discrepancy laws again. The caller must + account for an observation-informed initial-state proposal + separately; this routine never removes the first frame to hide + double counting. + """ + return self._log_likelihood_from(predictions, observations, 0) + + def log_future_likelihood( + self, predictions: Tuple[Observation, ...], + observed_prefix: Tuple[Observation, + ...], observed_future: Tuple[Observation, + ...]) -> float: + """Score a future history conditional on its supported observed prefix. + + This is the output factor conditional on a supplied physical + history, not an integral over uncertain physical transitions. + For generated forecasts that history must not use future + readings. A conditional integration caller may supply an + observation-guided path, but must account for its transition + densities and proposal correction separately. Prefix fitting + must never use future readings. Temporal output errors condition + on earlier readings through the chain rule. Summing suffix + factors directly avoids subtracting large full-history scores. + + An impossible prefix has no conditional distribution and raises; + an impossible future has zero density and returns negative + infinity. An empty future has log density zero, provided that + the prefix is supported. No parameter or particle weight changes. + """ + if observed_prefix: + prefix_score = self.log_likelihood( + predictions[:len(observed_prefix)], observed_prefix) + if prefix_score == -math.inf: + raise UnsupportedConditioning( + "Cannot score a future from a zero-likelihood prefix") + if not math.isfinite(prefix_score): + raise ConditioningNumericalError("Nonfinite prefix likelihood") + return self._log_likelihood_from(predictions, + observed_prefix + observed_future, + len(observed_prefix)) + + def _log_likelihood_from(self, predictions: Tuple[Observation, ...], + observations: Tuple[Observation, ...], + first_step: int) -> float: + """Condition on a prefix, accumulating only factors at or after + start.""" + if not predictions or len(predictions) != len(observations): + raise ValueError("Matching nonempty histories required") + expected = list(range(len(predictions))) + if [o.step for o in observations] != expected or \ + [o.step for o in predictions] != expected: + raise ValueError( + "Histories must contain each step starting at zero") + declared = {feature.key for feature in self.sensor.features} + if any(key not in declared for observation in observations + for key, _ in observation.values): + raise ValueError("Unknown observed feature") + observed_values = [] + for observation in observations: + for readout in self.readouts: + reduced = reduce_exact_readout(observation, self.sensor, + readout.declaration, + readout.evaluate) + if reduced.observation is None: + return -math.inf + observation = reduced.observation + observed_values.append(dict(observation.values)) + predicted_values = [dict(o.values) for o in predictions] + sensor = {f.key: f for f in self.sensor.features} + claimed = {f.key for f in self.scalars} + claimed.update(k for f in self.eulers for k in f.keys) + terms = [] + # All other readings retain their original likelihood, including + # exact events, and unknown measurement keys still cause an error. + for step in range(first_step, len(observed_values)): + values = observed_values[step] + residual = Observation( + step, + tuple((k, v) for k, v in values.items() if k not in claimed)) + terms.append( + self.sensor.log_likelihood(residual, predicted_values[step])) + for scalar in self.scalars: + if any(scalar.key not in values for values in predicted_values): + raise ValueError("Missing scalar prediction") + result = output_error_likelihood( + scalar.process, + tuple(values[scalar.key] for values in predicted_values), + tuple(values.get(scalar.key) for values in observed_values), + sensor[scalar.key].sigma) + if first_step == 0: + terms.append(result.log_likelihood) + elif result.status == "exact_contradiction": + return -math.inf + else: + terms.append( + math.fsum(step.log_observation_factor + for step in result.steps[first_step:] + if step.log_observation_factor is not None)) + for euler in self.eulers: + for observed, predicted in zip(observed_values[first_step:], + predicted_values[first_step:]): + present = sum(k in observed for k in euler.keys) + if not present: + continue + if present != 3: + raise UnsupportedConditioning( + "Partial Euler readings need a marginal likelihood") + if any(k not in predicted for k in euler.keys): + raise ValueError("Missing coupled orientation prediction") + angles = [predicted[k] for k in euler.keys] + mean = p.getQuaternionFromEuler(angles) + roll, pitch, yaw = euler.keys + reading = (observed[roll], observed[pitch], observed[yaw]) + terms.append(euler.process.log_density(mean, reading)) + return math.fsum(terms) + + def sample_future(self, predictions: Tuple[Observation, ...], + observed_prefix: Tuple[Observation, ...], + rng: np.random.Generator) -> Tuple[Observation, ...]: + """Draw a joint future observation history using only a fitted prefix. + + Predictions include the initial frame and every future primitive + step. They must come from causal simulator continuation, without + using future readings to correct physical state. This method + receives no future observations and never changes predictions. + + Scalar discrepancy is filtered on the prefix, then sampled as a + correlated error history. Sensor noise remains independent and + separate. Euler errors use the declared raw quaternion mixture, + and checked displays are derived from their sampled sources. + Conditioned inputs must be supplied in the future prediction + frames; they are copied as given inputs, not assigned a density. + + An empty prefix draws from the original output-error law. A + zero-likelihood prefix has no conditional forecast under this + supplied physical history. This is an output-model sampler, not + a posterior over physical parameters or current execution state. + """ + count = len(observed_prefix) + if not predictions or count > len(predictions) or \ + [o.step for o in predictions] != list(range(len(predictions))): + raise ValueError("Predictions must contain each step from zero") + if count: + score = self.log_likelihood(predictions[:count], observed_prefix) + if score == -math.inf: + raise UnsupportedConditioning( + "No conditional forecast for a zero-likelihood prefix") + if not math.isfinite(score): + raise ConditioningNumericalError("Nonfinite prefix score") + if any(f.process.pole_threshold != .99999 for f in self.eulers): + raise UnsupportedConditioning( + "Native Euler sampling requires pole threshold .99999") + if count == len(predictions): + return () + predicted = [dict(o.values) for o in predictions] + observed = [dict(o.values) for o in observed_prefix] + sensor = {f.key: f for f in self.sensor.features} + displays = {r.declaration.output for r in self.readouts} + for values in predicted[count:]: + if any(key not in values for key in sensor if key not in displays): + raise ValueError( + "Missing future prediction or conditioned input") + # One boundary draw per scalar retains temporal dependence within + # the suffix, unlike drawing each filtered marginal independently. + errors = {} + for scalar in self.scalars: + process = scalar.process + mean, sigma = 0., process.initial_sigma + if count: + filtered = output_error_likelihood( + process, + tuple(row[scalar.key] for row in predicted[:count]), + tuple(row.get(scalar.key) for row in observed), + sensor[scalar.key].sigma) + mean = filtered.steps[-1].filtered_mean + sigma = filtered.steps[-1].filtered_sigma + errors[scalar.key] = float(rng.normal(mean, sigma)) + draws = [] + for index in range(count, len(predictions)): + values = { + key: predicted[index][key] + for key in sensor if key not in displays + } + for scalar in self.scalars: + process = scalar.process + if index: + errors[scalar.key] = float( + process.persistence * errors[scalar.key] + + rng.normal(0., process.innovation_sigma)) + values[scalar.key] += errors[scalar.key] + for euler in self.eulers: + angles = [predicted[index][key] for key in euler.keys] + mean_quaternion = np.asarray(p.getQuaternionFromEuler(angles)) + sign = 1. if rng.integers(2) else -1. + raw = sign * mean_quaternion + rng.normal( + 0., euler.process.sigma, size=4) + # Do not normalize: the likelihood models native Euler + # readout of raw quaternion components, including poles. + values.update(zip(euler.keys, p.getEulerFromQuaternion(raw))) + for key, feature in sensor.items(): + if feature.sigma > 0 and not feature.conditioned: + values[key] += float(rng.normal(0., feature.sigma)) + for readout in self.readouts: + declaration = readout.declaration + values[declaration.output] = readout.evaluate( + values[declaration.source]) + draws.append(Observation(index, tuple(values.items()))) + return tuple(draws) diff --git a/predicators/code_sim_learning/inference_orientation.py b/predicators/code_sim_learning/inference_orientation.py new file mode 100644 index 000000000..5e659d354 --- /dev/null +++ b/predicators/code_sim_learning/inference_orientation.py @@ -0,0 +1,239 @@ +"""Marginalize explicit quaternion-output error through the native Euler map. + +The latent readout input is an antipodal mixture of four-dimensional +Gaussians, not a normalized rotation or a physical state correction. The +Euler map has a continuous ordinary branch and two collapsed pole +branches. Their densities use different dimensions of one mixed measure. +Only exact complete Euler readings are supported here. No production +estimator uses this separately declared discrepancy model. +""" +from __future__ import annotations + +import json +import math +from dataclasses import asdict, dataclass +from typing import Callable, Tuple + +import numpy as np +from scipy.integrate import quad +from scipy.optimize import minimize_scalar +# SciPy exports log_ndtr through a compiled ufunc. +# pylint: disable=no-name-in-module +from scipy.special import log_ndtr, logsumexp + +# pylint: enable=no-name-in-module +from predicators.code_sim_learning.inference_conditioning import \ + ConditioningNumericalError +from predicators.code_sim_learning.inference_data import content_digest + +Quaternion = Tuple[float, float, float, float] +Euler = Tuple[float, float, float] + + +@dataclass(frozen=True) +class QuaternionOutputError: + """Independent latent readout inputs, with a fixed original scale law. + + Q ~ .5 Normal(mu, sigma**2 I_4) + .5 Normal(-mu, sigma**2 I_4). + Observation = native_euler(Q), with no added sensor noise. + + Q is deliberately unnormalized: the native readout uses its raw + products for the pitch test and asin. Normalizing Q changes this + probability model. Antipodal mixing accounts for an unmodeled + quaternion sign without dropping the observed native yaw branch. + This is not isotropic angular noise on SO(3), a transition model, + or an exact model of float32 quantization. + """ + sigma: float + pole_threshold: float = .99999 + + def __post_init__(self) -> None: + if not math.isfinite(self.sigma) or self.sigma <= 0: + raise ValueError("Quaternion discrepancy scale must be positive") + if not math.isfinite(self.pole_threshold) or \ + not 0 < self.pole_threshold < 1: + raise ValueError("Pole threshold must lie strictly in (0, 1)") + + @property + def digest(self) -> str: + """Identify the mixture, readout convention and observation measure.""" + return content_digest( + json.dumps( + { + "schema": 1, + "family": "raw_quaternion_gaussian_euler_output_error", + "parameters": asdict(self), + "antipodal_weights": [.5, .5], + "time": "independent_given_native_predictions", + "measure": "ordinary_dr_dp_dy_plus_two_pole_dy_branches" + }, + sort_keys=True).encode("utf-8")) + + def log_density(self, + mean: Quaternion, + observed: Euler, + relative_tolerance: float = 1e-7) -> float: + """Retain the density induced by an exact three-field reading. + + Ordinary outputs integrate over unknown quaternion radius and + both lifts of a rotation. A pole output integrates its unknown + x/y radius and analytically integrates the z/w half-space. + Native pole yaw spans [-2*pi, 2*pi]; it is never wrapped here. + Invalid support gives zero density, while numerical failure is + explicit and must not be interpreted as a model contradiction. + """ + if len(mean) != 4 or len(observed) != 3 or any( + not math.isfinite(x) for x in (*mean, *observed)): + raise ValueError("Finite quaternion and complete Euler required") + if not math.isfinite(relative_tolerance) or \ + not 1e-11 <= relative_tolerance <= 1e-3: + raise ValueError("Quadrature relative tolerance out of range") + roll, pitch, yaw = observed + if pitch in (-math.pi / 2, math.pi / 2): + if roll != 0 or not -2 * math.pi <= yaw <= 2 * math.pi: + return -math.inf + return self._pole_density(mean, pitch, yaw, relative_tolerance) + if not -math.pi / 2 < pitch < math.pi / 2 or \ + abs(math.sin(pitch)) >= self.pole_threshold or \ + not -math.pi <= roll <= math.pi or \ + not -math.pi <= yaw <= math.pi: + return -math.inf + return self._ordinary_density(mean, observed, relative_tolerance) + + def _ordinary_density(self, mean: Quaternion, observed: Euler, + tolerance: float) -> float: + roll, pitch, yaw = observed + sine = math.sin(pitch) + center = math.hypot(*mean) + constant = -2 * math.log(2 * math.pi) - 4 * math.log(self.sigma) + log_jacobian = math.log(math.cos(pitch) / 8) + cr, sr = math.cos(roll / 2), math.sin(roll / 2) + cy, sy = math.cos(yaw / 2), math.sin(yaw / 2) + radial_lower = math.sqrt(abs(sine)) + + def integrand(transverse: float) -> float: + if transverse <= 0: + return -math.inf + # r^2=abs(sin(pitch))+u^2 makes r dr=u du. Retaining u + # avoids subtracting nearly equal rounded squared radii. + radius = math.hypot(radial_lower, transverse) + cosine_part = transverse * math.sqrt(transverse * transverse + + 2 * abs(sine)) + beta = math.atan2(sine, cosine_part) + cp, sp = math.cos(beta / 2), math.sin(beta / 2) + unit = (sr * cp * cy - cr * sp * sy, cr * sp * cy + sr * cp * sy, + cr * cp * sy - sr * sp * cy, cr * cp * cy + sr * sp * sy) + exponents = [ + -.5 * math.fsum(((radius * q - sign * m) / self.sigma)**2 + for q, m in zip(unit, mean)) + for sign in (-1, 1) + ] + # Sum the two lifts of the antipodal mixture: no extra .5. + return (math.log(transverse) + log_jacobian + constant + + _log_sum_pair(*exponents)) + + tail_constant = log_jacobian + constant + math.log(2) + return _log_radial_integral(integrand, center, self.sigma, + tail_constant, tolerance, abs(sine)) + + def _pole_density(self, mean: Quaternion, pitch: float, yaw: float, + tolerance: float) -> float: + theta = yaw / 2 + sine, cosine = math.sin(theta), math.cos(theta) + sign = 1 if pitch > 0 else -1 + direction = (-sign * sine, sign * cosine) + center = math.hypot(mean[0], mean[1]) + projection = sine * mean[2] + cosine * mean[3] + constant = -math.log(2 * math.pi) - 2 * math.log(self.sigma) + + def integrand(radius: float) -> float: + if radius <= 0: + return -math.inf + exponents = [ + -.5 * math.fsum( + ((radius * q - lift * m) / self.sigma)**2 + for q, m in zip(direction, mean[:2])) + float( + log_ndtr((lift * projection - self.pole_threshold / + (2 * radius)) / self.sigma)) + for lift in (-1, 1) + ] + # Half for theta=yaw/2, and half for the antipodal mixture. + return (math.log(radius) + constant - math.log(4) + + _log_sum_pair(*exponents)) + + return _log_radial_integral(integrand, center, self.sigma, + constant - math.log(2), tolerance) + + +def _log_sum_pair(first: float, second: float) -> float: + """Sum two log densities without allocating arrays inside quadrature.""" + larger, smaller = max(first, second), min(first, second) + if smaller == -math.inf: + return larger + return larger + math.log1p(math.exp(smaller - larger)) + + +def _log_radial_integral(evaluate: Callable[[float], float], + center: float, + sigma: float, + tail_constant: float, + tolerance: float, + squared_radius_offset: float = 0.) -> float: + """Scale adaptive quadrature and bound the omitted Gaussian radial tail. + + The bound is constant * integral r exp(-(r-center)^2/(2 sigma^2)) dr. + Quadrature error estimates are numerical diagnostics, not proofs + against undiscovered modes. Independent reference checks remain + necessary before accepting an inference configuration. + """ + radial_lower = math.sqrt(squared_radius_offset) + radial_upper = max(center, radial_lower) + 8 * sigma + upper = math.sqrt( + (radial_upper - radial_lower) * (radial_upper + radial_lower)) + for _ in range(12): + knots = np.linspace(0., upper, 9).tolist() + candidates = [x for x in knots if x > 0] + for left, right in zip(knots[:-1], knots[1:]): + optimum = minimize_scalar(lambda r: -evaluate(float(r)), + bounds=(left, right), + method="bounded", + options={"xatol": sigma * 1e-5}) + candidates.append(float(optimum.x)) + mode = max(candidates, key=evaluate) + scale = evaluate(mode) + if not math.isfinite(scale): + raise ConditioningNumericalError("Quaternion density overflow") + points = sorted({ + x + for x in [*knots, mode - sigma, mode, mode + sigma] + if 0 < x < upper + }) + + def scaled_density(value: float, log_scale: float = scale) -> float: + return math.exp(evaluate(value) - log_scale) + + result = quad(scaled_density, + 0., + upper, + points=points, + epsabs=0., + epsrel=tolerance / 4, + limit=250, + full_output=1) + value, error = result[:2] + if len(result) != 3 or not math.isfinite(value) or value <= 0 or \ + error > tolerance * value: + raise ConditioningNumericalError( + "Quaternion marginal quadrature did not converge") + answer = scale + math.log(value) + distance = (math.hypot(radial_lower, upper) - center) / sigma + tail_terms = [2 * math.log(sigma) - .5 * distance**2] + if center > 0: + tail_terms.append( + math.log(center * sigma) + .5 * math.log(2 * math.pi) + + float(log_ndtr(-distance))) + log_tail = tail_constant + float(logsumexp(tail_terms)) + if log_tail <= answer + math.log(tolerance / 4): + return answer + upper += max(8 * sigma, upper / 2) + raise ConditioningNumericalError("Quaternion marginal tail unresolved") diff --git a/predicators/code_sim_learning/inference_output_error.py b/predicators/code_sim_learning/inference_output_error.py new file mode 100644 index 000000000..bf89cf06f --- /dev/null +++ b/predicators/code_sim_learning/inference_output_error.py @@ -0,0 +1,166 @@ +"""Analytically marginalized scalar output discrepancy for offline inference. + +This is a declared statistical discrepancy model, not a physical state +correction or extra sensor noise. It is suitable only for explicitly +chosen real-valued outputs. Events, bounded quantities and coupled +kinematic constraints need their own observation models. No production +fitter or execution estimator uses this module. +""" +from __future__ import annotations + +import json +import math +from dataclasses import asdict, dataclass +from typing import List, Literal, Optional, Tuple + +from predicators.code_sim_learning.inference_conditioning import \ + ConditioningNumericalError, condition_gaussian_coordinate +from predicators.code_sim_learning.inference_data import content_digest + + +@dataclass(frozen=True) +class GaussianOutputError: + """A zero-mean initial error followed by scalar AR(1) transitions. + + b_0 ~ Normal(0, initial_sigma**2) + b_t = persistence * b_(t-1) + innovation_sigma * Normal(0, 1) + o_t = simulator_output_t + b_t + declared_sensor_noise_t + + Parameters describe one primitive time step. Zero scales represent + deterministic quantities, not tiny Gaussian approximations. The + caller fixes these parameters or gives them an identified original + prior when fitting; a previous error estimate is not a new prior. + """ + persistence: float + innovation_sigma: float + initial_sigma: float = 0. + + def __post_init__(self) -> None: + if not math.isfinite(self.persistence) or \ + not -1 <= self.persistence <= 1: + raise ValueError("Persistence must lie in [-1, 1]") + if any(not math.isfinite(value) or value < 0 + for value in (self.innovation_sigma, self.initial_sigma)): + raise ValueError("Error scales must be finite and nonnegative") + + @property + def digest(self) -> str: + """Identify the normalized process and primitive-step convention.""" + return content_digest( + json.dumps( + { + "schema": 1, + "family": "scalar_gaussian_ar1_output_discrepancy", + "parameters": + {key: float(value) + for key, value in asdict(self).items()}, + "step": "one_primitive_action", + "sensor": "separate_declared_additive_gaussian" + }, + sort_keys=True).encode("utf-8")) + + +@dataclass(frozen=True) +class ErrorFilterStep: + """Causal error moments before and after this step's optional reading. + + These moments describe discrepancy, not a reconstructed physical + state or a smoothed history. Before-reading moments supply an honest + predictive distribution for that reading. + """ + predicted_mean: float + predicted_sigma: float + filtered_mean: float + filtered_sigma: float + log_observation_factor: Optional[float] + + +@dataclass(frozen=True) +class OutputErrorLikelihood: + """Marginal likelihood with the entire stochastic error history integrated. + + The returned steps are filtering marginals. They are not independent + samples of the joint error history. An exact contradiction stops the + calculation before inventing a conditional distribution at that + step. A contradiction concerns this supplied prediction history, not + every parameter or initial state of the simulator program. + """ + process: str + status: Literal["complete", "exact_contradiction"] + log_likelihood: float + steps: Tuple[ErrorFilterStep, ...] + failed_step: Optional[int] + + +def output_error_likelihood(process: GaussianOutputError, + predictions: Tuple[float, ...], + observations: Tuple[Optional[float], ...], + sensor_sigma: float) -> OutputErrorLikelihood: + """Score one contiguous reset episode or forecast its unobserved suffix. + + Index zero is the initial frame. Every later index is one primitive + transition; missing observations are None and still advance the error + process. A new call starts from the same original error law. Carrying + a fitted error mean into a repeated full-data fit would double-count + evidence and is intentionally not an argument to this function. + + With positive innovation variance, exactly observed continuous + outputs condition a Gaussian latent error and retain its density. + Zero sensor sigma remains exact: it eliminates error uncertainty at + that frame. The simulator prediction is never changed or scored as + though its discrepancy were measurement noise. + """ + if not predictions or len(predictions) != len(observations): + raise ValueError( + "Matching nonempty prediction and observation histories required") + if any(not math.isfinite(value) for value in predictions) or \ + any(value is not None and not math.isfinite(value) + for value in observations): + raise ValueError( + "History values must be finite; missing readings are None") + if not math.isfinite(sensor_sigma) or sensor_sigma < 0: + raise ValueError("Sensor sigma must be finite and nonnegative") + mean, sigma = 0., process.initial_sigma + steps: List[ErrorFilterStep] = [] + factors: List[float] = [] + for index, (prediction, + observed) in enumerate(zip(predictions, observations)): + if index: + mean *= process.persistence + sigma = math.hypot(process.persistence * sigma, + process.innovation_sigma) + if not math.isfinite(mean) or not math.isfinite(sigma): + raise ConditioningNumericalError( + "Output-error prediction overflow") + before_mean, before_sigma = mean, sigma + factor = None + if observed is not None: + residual = observed - prediction + if not math.isfinite(residual): + raise ConditioningNumericalError("Output residual overflow") + if sigma > 0: + conditional = condition_gaussian_coordinate( + mean, sigma, residual, sensor_sigma) + mean, sigma = conditional.mean, conditional.sigma + factor = conditional.log_observation_factor + elif sensor_sigma > 0: + scaled = (residual - mean) / sensor_sigma + factor = (-.5 * scaled * scaled - math.log(sensor_sigma) - + .5 * math.log(2 * math.pi)) + if not math.isfinite(factor): + raise ConditioningNumericalError( + "Observation log density overflow") + elif residual != mean: + return OutputErrorLikelihood(process.digest, + "exact_contradiction", -math.inf, + tuple(steps), index) + else: + factor = 0. + factors.append(factor) + steps.append( + ErrorFilterStep(before_mean, before_sigma, mean, sigma, factor)) + total = math.fsum(factors) + if not math.isfinite(total): + raise ConditioningNumericalError("Marginal log likelihood overflow") + return OutputErrorLikelihood(process.digest, "complete", total, + tuple(steps), None) diff --git a/predicators/code_sim_learning/inference_parameters.py b/predicators/code_sim_learning/inference_parameters.py new file mode 100644 index 000000000..61b151830 --- /dev/null +++ b/predicators/code_sim_learning/inference_parameters.py @@ -0,0 +1,201 @@ +"""Parameter summaries and ensembles from one assessed joint posterior. + +Projection preserves particle weights and parameter correlations. It +neither refits marginal widths nor publishes parameters or approves a +plan. Initial-state inference and execution-state estimation remain +separate from this parameter-only consumer view. +""" +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Dict, Literal, Optional, Sequence, Tuple + +import numpy as np + +from predicators.code_sim_learning.active_experiment import \ + noisy_read_information +from predicators.code_sim_learning.inference_assessment import \ + AssessedInference, InferenceCheck, validated_posterior +from predicators.code_sim_learning.inference_data import InferenceIdentity +from predicators.code_sim_learning.inference_sampling import BatchPosterior + + +class UnavailableParameterPosterior(ValueError): + """Numerical assessment does not supply usable posterior parameters.""" + + +@dataclass(frozen=True) +class ParameterEnsemble: + """Weighted joint parameter rows with source-particle provenance. + + Resampled rows have equal weights and an explicit sampling seed; + they add Monte Carlo error to the source approximation. Neither + source weights nor equal resampling weights are stress-test weights. + """ + + identity: InferenceIdentity + assessment_protocol: str + predictive_checks: Tuple[InferenceCheck, ...] + names: Tuple[str, ...] + source_coordinates: Tuple[str, ...] + values: Tuple[Tuple[float, ...], ...] + weights: Tuple[float, ...] + source_indices: Tuple[int, ...] + resampling_seed: Optional[int] = None + + def __post_init__(self) -> None: + names = tuple(self.names) + coordinates = tuple(self.source_coordinates) + values = tuple( + tuple(float(value) for value in row) for row in self.values) + weights = tuple(float(weight) for weight in self.weights) + indices = tuple(self.source_indices) + if len(set(names)) != len(names) or any( + not isinstance(name, str) or not name for name in names): + raise ValueError("Ensemble names must be distinct strings") + if len(coordinates) != len(names) or any( + not isinstance(name, str) or not name for name in coordinates): + raise ValueError("One source coordinate per parameter required") + if not values or len(weights) != len(values) or \ + len(indices) != len(values) or any( + len(row) != len(names) or any(not math.isfinite(value) + for value in row) + for row in values): + raise ValueError("Invalid ensemble dimensions or values") + if any(not math.isfinite(weight) or weight < 0 for weight in weights) \ + or not math.isclose(math.fsum(weights), 1., rel_tol=1e-12, + abs_tol=1e-12): + raise ValueError("Ensemble weights must be normalized") + if any(not isinstance(index, int) or isinstance(index, bool) + or index < 0 for index in indices): + raise ValueError("Invalid source particle index") + if self.resampling_seed is not None and ( + not isinstance(self.resampling_seed, int) or isinstance( + self.resampling_seed, bool) or self.resampling_seed < 0): + raise ValueError("Invalid resampling seed") + object.__setattr__(self, "names", names) + object.__setattr__(self, "source_coordinates", coordinates) + object.__setattr__(self, "values", values) + object.__setattr__(self, "weights", weights) + object.__setattr__(self, "source_indices", indices) + object.__setattr__(self, "predictive_checks", + tuple(self.predictive_checks)) + + def as_dicts(self) -> Tuple[Dict[str, float], ...]: + """Return owned parameter maps for simulator consumers.""" + return tuple(dict(zip(self.names, row)) for row in self.values) + + def expectation(self, outcomes: Sequence[float]) -> float: + """Weight one finite outcome per row without changing decision rules. + + Boolean outcomes yield a represented success probability, not a + guarantee, calibration statement or action approval. + """ + values = tuple(float(value) for value in outcomes) + if len(values) != len(self.values) or any(not math.isfinite(value) + for value in values): + raise ValueError("One finite outcome per ensemble row required") + return math.fsum(weight * value + for weight, value in zip(self.weights, values)) + + def atom_information(self, read_probabilities: np.ndarray) -> float: + """Score noisy atom reads using this ensemble's posterior weights. + + Supply one row per parameter map and one column per atom, with + each entry the probability of reading that atom true under the + declared observation channel. Binary entries describe exact + reads. This uses the incumbent per-atom information criterion; + it does not approve a probe or erase predictive failures. + """ + return noisy_read_information(read_probabilities, weights=self.weights) + + +@dataclass(frozen=True) +class ParameterPosterior: + """An explicit parameter projection of an assessed inference result. + + Unavailable results retain their assessment but expose no samples or + quantiles. Predictive failures remain visible without suppressing an + otherwise numerically adequate posterior. Parameter names are an + explicit subset of joint coordinates, never inferred by position. + """ + + assessment: AssessedInference + names: Tuple[str, ...] + coordinates: Tuple[str, ...] = () + schema_version: Literal[1] = 1 + uncertainty_kind: Literal["joint_posterior"] = "joint_posterior" + + def __post_init__(self) -> None: + names = tuple(self.names) + if len(set(names)) != len(names) or any( + not isinstance(name, str) or not name for name in names): + raise ValueError("Parameter names must be distinct strings") + object.__setattr__(self, "names", names) + coordinates = tuple(self.coordinates) if self.coordinates else names + if len(coordinates) != len(names) or any( + not isinstance(name, str) or not name for name in coordinates): + raise ValueError("One joint coordinate per parameter required") + object.__setattr__(self, "coordinates", coordinates) + posterior = validated_posterior(self.assessment) + if posterior is None: + return + if not set(coordinates) <= set(posterior.prior.names): + raise ValueError("Unknown joint posterior parameter") + + def _posterior(self) -> BatchPosterior: + result = self.assessment + if result.availability != "available" or result.posterior is None: + raise UnavailableParameterPosterior("Parameter posterior is " + + result.availability) + return result.posterior + + def marginal_quantiles( + self, probabilities: Tuple[float, ...] = (.05, .5, .95) + ) -> Dict[str, Tuple[float, ...]]: + """Use the same weighted empirical CDF as the joint approximation.""" + posterior = self._posterior() + if not probabilities or any(not math.isfinite(p) or p < 0 or p > 1 + for p in probabilities): + raise ValueError("Quantile probabilities must lie in [0, 1]") + return { + name: posterior.marginal_quantiles(coordinate, probabilities) + for name, coordinate in zip(self.names, self.coordinates) + } + + def weighted_samples(self) -> ParameterEnsemble: + """Project positive-weight rows, retaining weights and source + indices.""" + posterior = self._posterior() + columns = tuple( + posterior.prior.names.index(name) for name in self.coordinates) + indices = tuple(i for i, weight in enumerate(posterior.weights) + if weight > 0) + return ParameterEnsemble( + posterior.identity, self.assessment.protocol.source, + self.assessment.predictive_checks, self.names, self.coordinates, + tuple( + tuple(posterior.samples[i][column] + for column in columns) for i in indices), + tuple(posterior.weights[i] for i in indices), indices) + + def resample(self, count: int, seed: int) -> ParameterEnsemble: + """Resample complete parameter rows; never combine marginal draws.""" + if not isinstance(count, int) or isinstance(count, bool) or count <= 0: + raise ValueError("Resampling count must be a positive integer") + if not isinstance(seed, int) or isinstance(seed, bool) or seed < 0: + raise ValueError("Resampling seed must be a nonnegative integer") + weighted = self.weighted_samples() + probabilities = np.asarray(weighted.weights, dtype=float) + probabilities /= math.fsum(weighted.weights) + indices = tuple( + int(i) for i in np.random.default_rng(seed).choice( + len(weighted.values), size=count, p=probabilities)) + return ParameterEnsemble( + weighted.identity, weighted.assessment_protocol, + weighted.predictive_checks, weighted.names, + weighted.source_coordinates, + tuple(weighted.values[index] + for index in indices), (1. / count, ) * count, + tuple(weighted.source_indices[index] for index in indices), seed) diff --git a/predicators/code_sim_learning/inference_path_integral.py b/predicators/code_sim_learning/inference_path_integral.py new file mode 100644 index 000000000..10f04c5fe --- /dev/null +++ b/predicators/code_sim_learning/inference_path_integral.py @@ -0,0 +1,89 @@ +"""Monte Carlo density integration over explicitly conditioned future paths. + +The caller supplies independent complete-path log integrands, including +all retained observation densities and any proposal correction. Exact +conditioning belongs in that path construction, not in a tolerance band +or equality test against unconditional draws. This module summarizes the +integral; it does not certify support coverage or numerical adequacy. +""" +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Callable, Literal, Optional, Tuple + +import numpy as np + +from predicators.code_sim_learning.inference_conditioning import \ + ConditioningNumericalError + + +@dataclass(frozen=True) +class PathIntegral: + """A density estimate and empirical diagnostics, not posterior weights. + + Relative standard error assumes independent draws with finite second + moment. Zero empirical error does not exclude unsampled rare paths. + No sampled support leaves the error undefined, not falsely zero. + Effective terms describe concentration of sampled contributions + only. + """ + log_factors: Tuple[float, ...] + log_density: float + relative_standard_error: Optional[float] + effective_terms: float + status: Literal["finite_estimate", "no_sample_support"] + + +def summarize_path_integral(log_factors: Tuple[float, ...]) -> PathIntegral: + """Average complete-path densities, retaining zero-support draws. + + These are log target/proposal factors from the same normalized + sampling law, not normalized posterior weights or log densities from + independent time-step mixtures. At least two draws are needed for + the empirical error calculation. NaN and positive infinity are + numerical errors; negative infinity is a legitimate zero integrand. + """ + factors = tuple(float(value) for value in log_factors) + if len(factors) < 2: + raise ValueError("At least two independent path draws required") + if any(math.isnan(value) or value == math.inf for value in factors): + raise ConditioningNumericalError("Invalid path log integrand") + peak = max(factors) + if peak == -math.inf: + return PathIntegral(factors, -math.inf, None, 0., "no_sample_support") + scaled = tuple(math.exp(value - peak) for value in factors) + total = math.fsum(scaled) + count = len(scaled) + mean = total / count + squares = math.fsum(value * value for value in scaled) + centered = math.fsum((value - mean)**2 for value in scaled) + error = math.sqrt(centered / (count * (count - 1))) / mean + return PathIntegral(factors, peak + math.log(mean), error, + total * total / squares, "finite_estimate") + + +def integrate_conditional_paths(log_integrand: Callable[[np.random.Generator], + float], count: int, + seed: int) -> PathIntegral: + """Draw independent conditional paths from an explicit local generator. + + Each call must rebuild the same supported prefix and sample one new + future history. Evaluated future observations may guide a normalized + conditional proposal only when its density accounting is retained. + This scoring operation must remain separate from future generation. + Callback exceptions abort the computation; no failed path is + dropped. + """ + if not isinstance(count, int) or isinstance(count, bool) or count < 2: + raise ValueError("At least two independent path draws required") + if not isinstance(seed, int) or isinstance(seed, bool) or seed < 0: + raise ValueError("Nonnegative integer integration seed required") + rng = np.random.default_rng(seed) + factors = [] + for _ in range(count): + value = float(log_integrand(rng)) + if math.isnan(value) or value == math.inf: + raise ConditioningNumericalError("Invalid path log integrand") + factors.append(value) + return summarize_path_integral(tuple(factors)) diff --git a/predicators/code_sim_learning/inference_prediction.py b/predicators/code_sim_learning/inference_prediction.py new file mode 100644 index 000000000..f7613bf55 --- /dev/null +++ b/predicators/code_sim_learning/inference_prediction.py @@ -0,0 +1,167 @@ +"""Offline future observations from complete, assessed joint posterior rows. + +Replay receives the fitted episode and requested actions, never future +readings. One joint particle generates an entire future history. The +output model is fixed across particles; stochastic physical transitions +need separate integration and are not supplied by this deterministic +replay adapter. No production planner or parameter publisher uses it. +""" +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Callable, Dict, Tuple + +import numpy as np + +from predicators.code_sim_learning.inference_assessment import \ + AssessedInference, validated_posterior +from predicators.code_sim_learning.inference_conditioning import \ + ConditioningNumericalError, UnsupportedConditioning +from predicators.code_sim_learning.inference_data import EpisodeData, \ + InferenceData, Observation +from predicators.code_sim_learning.inference_observation import \ + OutputObservationModel +from predicators.code_sim_learning.inference_sampling import BatchPosterior + +Actions = Tuple[Tuple[float, ...], ...] +History = Tuple[Observation, ...] +JointReplay = Callable[[Dict[str, float], EpisodeData, Actions], History] + + +def _forecast_context( + assessment: AssessedInference, data: InferenceData, episode_id: str, + model: OutputObservationModel, future_actions: Actions +) -> Tuple[BatchPosterior, EpisodeData, History, Actions]: + """Validate provenance and construct the complete primitive-step prefix.""" + posterior = validated_posterior(assessment) + if posterior is None: + raise ValueError("Joint posterior is " + assessment.availability) + if data.digest != posterior.identity.data or \ + model.digest != posterior.identity.sensor: + raise ValueError("Forecast data or output-model identity differs") + episodes = {episode.episode_id: episode for episode in data.episodes} + if episode_id not in episodes: + raise ValueError("Forecast requires a fitted reset episode") + episode = episodes[episode_id] + # Reuse the ledger's validation for finite, fixed-dimension actions. + combined = EpisodeData(episode_id, episode.actions + tuple(future_actions), + episode.observations) + actions = combined.actions[len(episode.actions):] + observed = {o.step: o for o in episode.observations} + prefix = tuple( + observed.get(i, Observation(i, ())) + for i in range(len(episode.actions) + 1)) + return posterior, episode, prefix, actions + + +@dataclass(frozen=True) +class JointForecast: + """A weighted forecast with fitting provenance and retained diagnostics. + + Histories correspond to positive-weight source rows in their + original order. A failed replay cannot be removed and its mass + renormalized. Construction validates a supported prefix for every + retained history; it does not prove that the replay callback + implemented the stated physics, or that the declared assessment + protocol was sufficient. + """ + assessment: AssessedInference + data: InferenceData + episode_id: str + model: OutputObservationModel + future_actions: Actions + histories: Tuple[History, ...] + + def __post_init__(self) -> None: + posterior, _, prefix, actions = _forecast_context( + self.assessment, self.data, self.episode_id, self.model, + self.future_actions) + histories = tuple(tuple(history) for history in self.histories) + indices = tuple(i for i, w in enumerate(posterior.weights) if w > 0) + if len(histories) != len(indices): + raise ValueError( + "One history per positive-weight joint row needed") + expected = list(range(len(prefix) + len(actions))) + for history in histories: + if [o.step for o in history] != expected: + raise ValueError("Forecast history must cover every action") + score = self.model.log_likelihood(history[:len(prefix)], prefix) + if score == -math.inf: + raise UnsupportedConditioning( + "Positive-weight forecast has a zero-likelihood prefix") + if not math.isfinite(score): + raise ConditioningNumericalError("Nonfinite forecast prefix") + object.__setattr__(self, "future_actions", actions) + object.__setattr__(self, "histories", histories) + + @classmethod + def replay(cls, assessment: AssessedInference, data: InferenceData, + episode_id: str, model: OutputObservationModel, + future_actions: Actions, replay: JointReplay) -> JointForecast: + """Replay every positive-mass row, preserving all joint coordinates. + + Each callback receives an owned coordinate dictionary, the + fitted reset episode, and only requested future actions. It must + create a fresh candidate world and dispose it, retain inferred + memory and initial-state dependence, and return initial plus all + later frames. Callback exceptions abort construction without + changing weights. + """ + posterior, episode, _, actions = _forecast_context( + assessment, data, episode_id, model, future_actions) + histories = tuple( + replay(dict(zip(posterior.prior.names, row)), episode, actions) + for row, weight in zip(posterior.samples, posterior.weights) + if weight > 0) + return cls(assessment, data, episode_id, model, actions, histories) + + def log_likelihood(self, future: History) -> float: + """Mix complete-history densities using the prefix-fitted weights. + + This is log(sum_i w_i p(future | prefix, joint_row_i)), not an + average log score or independent per-time mixture. The + observation model's common reference measure must apply to every + component. + """ + posterior, _, prefix, _ = _forecast_context(self.assessment, self.data, + self.episode_id, + self.model, + self.future_actions) + weights = tuple(w for w in posterior.weights if w > 0) + terms = tuple( + math.log(weight) + + self.model.log_future_likelihood(history, prefix, future) + for weight, history in zip(weights, self.histories)) + peak = max(terms) + if peak == -math.inf: + return -math.inf + if not math.isfinite(peak): + raise ConditioningNumericalError("Nonfinite predictive density") + return peak + math.log(math.fsum(math.exp(t - peak) for t in terms)) \ + - math.log(math.fsum(weights)) + + def sample(self, count: int, seed: int) -> Tuple[Tuple[int, History], ...]: + """Draw entire histories with source indices and reproducible noise. + + One particle is selected per history, never separately per step + or feature. Sampling adds Monte Carlo error; it does not add new + fitted particles or change their assessment or predictive + checks. + """ + if not isinstance(count, int) or isinstance(count, bool) or count <= 0: + raise ValueError("Positive integer forecast count required") + if not isinstance(seed, int) or isinstance(seed, bool) or seed < 0: + raise ValueError("Nonnegative integer forecast seed required") + posterior, _, prefix, _ = _forecast_context(self.assessment, self.data, + self.episode_id, + self.model, + self.future_actions) + indices = tuple(i for i, w in enumerate(posterior.weights) if w > 0) + weights = np.array([posterior.weights[i] for i in indices]) + weights /= math.fsum(weights) + rng = np.random.default_rng(seed) + selected = rng.choice(len(indices), count, p=weights) + return tuple((indices[i], + self.model.sample_future(self.histories[i], prefix, rng)) + for i in selected) diff --git a/predicators/code_sim_learning/inference_readout.py b/predicators/code_sim_learning/inference_readout.py new file mode 100644 index 000000000..f31b25c61 --- /dev/null +++ b/predicators/code_sim_learning/inference_readout.py @@ -0,0 +1,121 @@ +"""Checked reduction of a deterministic readout of an exactly observed source. + +This preserves the source observation's likelihood and verifies the +additional readout instead of treating it as independent evidence. The +declared map must be parameter-independent and match the actual runtime +precision and observation phase. No simulator prediction is overwritten. +""" +from __future__ import annotations + +import json +import math +from dataclasses import asdict, dataclass +from typing import Callable, Literal, Optional + +from predicators.code_sim_learning.inference_conditioning import \ + UnsupportedConditioning +from predicators.code_sim_learning.inference_data import FeatureKey, \ + Observation, SensorFeature, SensorModel, content_digest + + +@dataclass(frozen=True) +class ExactReadout: + """An identified, reviewed measurement map output = g(source). + + The mapping identity must cover its implementation, constants, + precision, runtime dependencies and justification. In particular, + empirical agreement alone does not prove parameter-independence. + This class does not discover or prove the callback's dependencies. + Different source or sensor semantics require a new declaration. + + The joint observation measure is the source measure followed by a + deterministic readout. The source supplies the density; a compatible + extra readout supplies conditional mass one, not another continuous + density or a Jacobian from treating it as a second source coordinate. + """ + source: FeatureKey + output: FeatureKey + sensor: str + mapping: str + + def __post_init__(self) -> None: + SensorFeature(self.source, 0.) + SensorFeature(self.output, 0.) + if self.source == self.output: + raise ValueError("Readout output must differ from its source") + for digest in (self.sensor, self.mapping): + if len(digest) != 64 or any(c not in "0123456789abcdef" + for c in digest): + raise ValueError("Readout identities must be SHA256 digests") + + @property + def digest(self) -> str: + """Identify the source-coordinate observation measure and its map.""" + return content_digest( + json.dumps( + { + "schema": 1, + "kind": "exact_deterministic_readout", + "declaration": asdict(self) + }, + sort_keys=True).encode("utf-8")) + + +@dataclass(frozen=True) +class ReadoutReduction: + """A checked observation view; the original ledger is never changed. + + The mapping identity belongs in the complete inference identity. + Contradictions supply no reduced view, preventing their accidental + removal by a caller that forgets to combine a negative-infinite + factor. A missing source requires another conditional construction. + """ + mapping: str + status: Literal["verified", "not_observed", "exact_contradiction"] + observation: Optional[Observation] + + @property + def log_factor(self) -> float: + """The source likelihood is still required after this check.""" + return -math.inf if self.status == "exact_contradiction" else 0. + + +def reduce_exact_readout( + observation: Observation, sensor: SensorModel, + declaration: ExactReadout, + evaluate: Callable[[float], float]) -> ReadoutReduction: + """Verify a known source/readout relation before reducing the observation. + + Both fields must be exact, independently of whether the source is an + explicitly conditioned external input or a predicted quantity. For a + predicted source, its likelihood must still be evaluated by the + caller, including any latent discrepancy model it has declared. A + noisy source cannot substitute for its unknown true value. Missing + sources also leave the readout informative and are not dropped. + """ + if sensor.digest != declaration.sensor: + raise ValueError("Readout declaration does not match sensor model") + schema = {feature.key: feature for feature in sensor.features} + for key in (declaration.source, declaration.output): + if key not in schema or schema[key].sigma != 0: + raise UnsupportedConditioning( + "Readout reduction requires exact source and output fields") + if schema[declaration.output].conditioned: + raise ValueError("A derived readout cannot also be an external input") + values = dict(observation.values) + if declaration.output not in values: + return ReadoutReduction(declaration.digest, "not_observed", + observation) + if declaration.source not in values: + raise UnsupportedConditioning( + "An observed readout with a missing source needs marginalization") + expected = evaluate(values[declaration.source]) + if not math.isfinite(expected): + raise ValueError("Readout callback returned a nonfinite value") + if expected != values[declaration.output]: + return ReadoutReduction(declaration.digest, "exact_contradiction", + None) + del values[declaration.output] + return ReadoutReduction( + declaration.digest, "verified", + Observation(observation.step, tuple(values.items()))) diff --git a/predicators/code_sim_learning/inference_recording.py b/predicators/code_sim_learning/inference_recording.py new file mode 100644 index 000000000..b4d6c2a6e --- /dev/null +++ b/predicators/code_sim_learning/inference_recording.py @@ -0,0 +1,399 @@ +"""Read-only projection of flushed continual recordings for offline inference. + +Continual recordings contain sanitized truth for replay, not the noisy +frames the agent received. This adapter reconstructs that observation +channel using explicit run/level coordinates. It does not choose data +splits, create environments, infer hidden state, or modify recordings. +""" +from __future__ import annotations + +import json +import pickle +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, FrozenSet, Iterable, List, \ + Mapping, Optional, Set, Tuple + +import numpy as np + +from predicators.code_sim_learning.inference_data import EpisodeData, \ + FeatureKey, InferenceData, Observation, SensorFeature, SensorModel, \ + content_digest +from predicators.observation_noise import ObservationNoise, step_rng +from predicators.utils import PyBulletState + +if TYPE_CHECKING: + from predicators.structs import Object, State + + +@dataclass(frozen=True) +class SourceArtifact: + """Owned bytes of one named source input, independent of later file + edits.""" + name: str + content: bytes + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or not self.name: + raise ValueError("Artifact name must be nonempty") + if not isinstance(self.content, bytes): + raise ValueError("Artifact content must be immutable bytes") + + @classmethod + def read(cls, name: str, path: Path) -> SourceArtifact: + """Read explicit input bytes once; paths are not content identities.""" + return cls(name, path.read_bytes()) + + @property + def digest(self) -> str: + """Digest of the actual snapshot, not a subsequently reread file.""" + return content_digest(self.content) + + +@dataclass(frozen=True) +class ArtifactBundle: + """Named source snapshots with an immutable content manifest. + + Callers must enumerate all relevant imports, parameter definitions + and runtime inputs. This freezes that declaration, not automatic + dependency discovery. Manifest digest can identify a program or a + runtime bundle. + """ + artifacts: Tuple[SourceArtifact, ...] + + def __post_init__(self) -> None: + artifacts = tuple(sorted(self.artifacts, key=lambda a: a.name)) + if not artifacts or len({a.name for a in artifacts}) != len(artifacts): + raise ValueError("Bundle needs distinct named artifacts") + object.__setattr__(self, "artifacts", artifacts) + + @property + def manifest(self) -> bytes: + """Canonical mapping of logical names to byte identities.""" + return json.dumps( + { + "schema": 1, + "artifacts": [(a.name, a.digest) for a in self.artifacts] + }, + sort_keys=True, + separators=(",", ":")).encode("utf-8") + + @property + def digest(self) -> str: + """The artifact names and their contents both determine identity.""" + return content_digest(self.manifest) + + def save(self, directory: Path) -> Path: + """Persist content-addressed bytes without overwriting other snapshots. + + Names are manifest labels, never interpreted as output paths. An + existing blob must contain exactly the expected bytes. + """ + directory.mkdir(parents=True, exist_ok=True) + entries = [(a.digest, a.content) for a in self.artifacts] + entries.append((self.digest + ".json", self.manifest)) + for filename, content in entries: + path = directory / filename + try: + with path.open("xb") as stream: + stream.write(content) + except FileExistsError: + if path.read_bytes() != content: + raise ValueError(f"Artifact content mismatch: {path}") + return directory / (self.digest + ".json") + + +@dataclass(frozen=True) +class RecordingProjection: + """Explicit interpretation of public fields and excluded metadata. + + Object features, joint positions and mobile base pose are measured. + Extra metadata requires a stated exclusion reason. In particular, + raw body velocities and command welds are not silently used as + inferred state or silently declared sensor evidence. The exact-input + choices also become part of the recorded projection identity. + """ + conditioned: Tuple[FeatureKey, ...] = () + excluded_metadata: Tuple[Tuple[str, str], ...] = () + require_joints: bool = True + + def __post_init__(self) -> None: + conditioned = tuple(sorted(set(self.conditioned))) + for key in conditioned: + SensorFeature(key, 0.0, True) + excluded = tuple( + sorted((key, reason) for key, reason in self.excluded_metadata)) + if len({key for key, _ in excluded}) != len(excluded): + raise ValueError("Duplicate metadata exclusion") + if any(not key or not reason.strip() for key, reason in excluded): + raise ValueError("Every metadata exclusion needs a reason") + if {key for key, _ in excluded} & {"joint_positions", "base_pose"}: + raise ValueError("Public robot observations cannot be excluded") + object.__setattr__(self, "conditioned", conditioned) + object.__setattr__(self, "excluded_metadata", excluded) + + @property + def artifact(self) -> SourceArtifact: + """Freeze projection decisions alongside the original recording.""" + return SourceArtifact( + "observation_projection", + json.dumps( + { + "schema": 1, + "conditioned": self.conditioned, + "excluded_metadata": self.excluded_metadata, + "require_joints": self.require_joints, + }, + sort_keys=True, + separators=(",", ":")).encode("utf-8")) + + def observe(self, step: int, state: State) -> Observation: + """Copy recorded public features and exact robot proprioception.""" + if state.privileged is not None or state.latent is not None: + raise ValueError( + "Expected a sanitized recording, not inferred state") + values = list(Observation.from_state(step, state).values) + sim = state.simulator_state + if sim is None: + metadata: Mapping[str, Any] = {} + elif isinstance(sim, dict): + metadata = sim + else: + # Historical PyBulletState stores just the controlled joint array. + metadata = {"joint_positions": sim} + extra = set(metadata) - {"joint_positions", "base_pose"} + if extra - {key for key, _ in self.excluded_metadata}: + raise ValueError("Unclassified recording metadata: " + + str(sorted(extra))) + if self.require_joints and "joint_positions" not in metadata: + raise ValueError("Missing public joint positions") + if "joint_positions" in metadata: + positions = tuple(float(v) for v in metadata["joint_positions"]) + if not positions: + raise ValueError("Empty public joint positions") + values.extend( + (("__proprioception__", "joint_positions", str(i)), v) + for i, v in enumerate(positions)) + if "base_pose" in metadata: + pose = metadata["base_pose"] + if len(pose) != 2 or len(pose[0]) != 3 or len(pose[1]) != 4: + raise ValueError("Base pose requires position and quaternion") + for label, coordinates in zip(("base_position", "base_quaternion"), + pose): + values.extend((("__proprioception__", label, str(i)), float(v)) + for i, v in enumerate(coordinates)) + return Observation(step, tuple(values)) + + def to_state(self, observation: Observation, + objects: Iterable[Object]) -> State: + """Rebuild public values for legacy fitting without private recordings. + + Object handles must belong to the supplied model world and match + the measured schema exactly. Arrays are newly owned and object + insertion order is canonical. No poses are denoised, velocities + inferred or hidden memory restored. This observation frame is + not a feasible physical candidate for joint initial-state + inference. + """ + values = dict(observation.values) + data = {} + seen = set() + for obj in sorted(objects, key=lambda o: (o.name, o.type.name)): + identity = (obj.name, obj.type.name) + if identity in seen or obj.name == "__proprioception__": + raise ValueError("Duplicate or reserved model object") + seen.add(identity) + keys = [(obj.name, obj.type.name, f) + for f in obj.type.feature_names] + if not keys or any(key not in values for key in keys): + raise ValueError("Missing measured object feature") + data[obj] = np.array([values.pop(key) for key in keys], + dtype=float) + coordinates: Dict[str, Dict[int, float]] = {} + for (name, kind, index), value in values.items(): + if name != "__proprioception__" or kind not in ("joint_positions", + "base_position", + "base_quaternion"): + raise ValueError("Unknown measured feature") + if not index.isdecimal() or str(int(index)) != index: + raise ValueError("Noncanonical proprioception index") + coordinates.setdefault(kind, {})[int(index)] = value + metadata: Dict[str, Any] = {} + for kind, entries in coordinates.items(): + if set(entries) != set(range(len(entries))): + raise ValueError("Discontinuous proprioception indices") + if "joint_positions" in coordinates: + joints = coordinates["joint_positions"] + metadata["joint_positions"] = [ + joints[i] for i in range(len(joints)) + ] + if "base_position" in coordinates or "base_quaternion" in coordinates: + position = coordinates.get("base_position", {}) + quaternion = coordinates.get("base_quaternion", {}) + if len(position) != 3 or len(quaternion) != 4: + raise ValueError("Incomplete measured base pose") + metadata["base_pose"] = (tuple(position[i] for i in range(3)), + tuple(quaternion[i] for i in range(4))) + state = PyBulletState(data, simulator_state=metadata) + if self.observe(observation.step, state) != observation: + raise ValueError("Observation reconstruction changed values") + return state + + +@dataclass(frozen=True) +class RecordedLevel: + """Projected data and original bytes; no simulator or evaluator handles.""" + data: InferenceData + sensor: SensorModel + source: ArtifactBundle + + +def load_recorded_level(directory: Path, run_id: str, noise: ObservationNoise, + projection: RecordingProjection, *, + observation_seed: int, + level_index: int) -> RecordedLevel: + """Load one explicitly selected trusted level after its recording flush. + + Validate reset markers and every primitive action against + actions.jsonl. A live/unflushed or inconsistent pair of files is + rejected rather than truncating an episode or treating a + continuation as a fresh reset. The caller must select development + recordings and freeze them before fitting. The stored states are + sanitized simulator truth: recreate the same step-keyed noise as + ContinualRun._observed, rather than exposing that truth to + inference. Already-noisy exports require a different reader, not a + second draw. + """ + if not run_id: + raise ValueError("Recording run identity must be explicit") + if any(not isinstance(value, int) or isinstance(value, bool) or value < 0 + for value in (observation_seed, level_index)): + raise ValueError("Noise coordinates must be nonnegative integers") + if directory.name != f"L{level_index + 1:02d}": + raise ValueError("Level directory disagrees with noise coordinates") + channel = SourceArtifact( + "recording_observation_channel", + json.dumps( + { + "schema": 1, + "stored_states": "sanitized_simulator_truth", + "observation_seed": observation_seed, + "level_index": level_index, + "noise": asdict(noise), + }, + sort_keys=True).encode("utf-8")) + episodes_source = SourceArtifact.read("episodes.pkl", + directory / "episodes.pkl") + actions_source = SourceArtifact.read("actions.jsonl", + directory / "actions.jsonl") + # Only trusted local recordings should be unpickled, as with the existing + # LevelRecording reader. Do not instantiate that writer for read access. + payload = pickle.loads(episodes_source.content) + if not isinstance(payload, list) or not payload: + raise ValueError("Expected a nonempty flushed episode list") + logged: Dict[int, List[Tuple[float, ...]]] = {} + current: Optional[int] = None + for line in actions_source.content.decode("utf-8").splitlines(): + if not line.strip(): + continue + row = json.loads(line) + index = row["ep"] + if not isinstance(index, int) or index < 0: + raise ValueError("Invalid reset episode number") + if row.get("event") == "reset": + if index in logged: + raise ValueError("Duplicate reset marker") + logged[index] = [] + current = index + else: + if index != current or index not in logged: + raise ValueError("Action has no matching reset marker") + if row.get("event") is not None or row["i"] != len(logged[index]): + raise ValueError("Discontinuous primitive action log") + logged[index].append(tuple(float(v) for v in row["a"])) + episodes: List[EpisodeData] = [] + features: Dict[FeatureKey, SensorFeature] = {} + seen: Set[int] = set() + for episode in payload: + index = episode["episode"] + if index in seen or index not in logged: + raise ValueError("Episode has no unique reset marker") + seen.add(index) + actions = tuple( + tuple(float(v) for v in action["arr"]) + for action in episode["actions"]) + if actions != tuple(logged[index]): + raise ValueError( + "Recording actions disagree; flush before snapshot") + states = episode["states"] + if len(states) != len(actions) + 1: + raise ValueError("Recording must include every action boundary") + observations: List[Observation] = [] + keys: Optional[FrozenSet[FeatureKey]] = None + for step, state in enumerate(states): + # Validate the original record before perturb() sanitizes it, + # so unexpected metadata cannot silently disappear. + observation = projection.observe(step, state) + if noise.enabled: + view = noise.perturb( + state, step_rng(observation_seed, level_index, index, + step)) + observation = projection.observe(step, view) + observed_keys = frozenset(key for key, _ in observation.values) + if keys is not None and observed_keys != keys: + raise ValueError( + "Observed feature schema changed inside episode") + keys = observed_keys + observations.append(observation) + schema = { + f.key: f + for f in SensorModel.from_state(state, noise).features + } + for key, _ in observation.values: + feature = SensorFeature( + key, schema[key].sigma if key in schema else 0., key + in projection.conditioned) + previous = features.setdefault(key, feature) + if previous != feature: + raise ValueError( + "Sensor semantics changed within recording") + # JSON encoding avoids collisions between run/level strings containing + # path separators. A reset is established by the log, not the folder. + identifier = json.dumps((run_id, directory.name, index), + separators=(",", ":")) + episodes.append(EpisodeData(identifier, actions, tuple(observations))) + if seen != set(logged): + raise ValueError("Reset log and episode snapshot disagree") + if set(projection.conditioned) - set(features): + raise ValueError("Unknown conditioned observation") + return RecordedLevel( + InferenceData(tuple(episodes)), SensorModel(tuple(features.values())), + ArtifactBundle( + (episodes_source, actions_source, projection.artifact, channel, + SourceArtifact.read( + "observation_noise_source", + Path(__file__).parents[1] / "observation_noise.py")))) + + +def combine_recorded_levels(levels: Iterable[RecordedLevel]) -> RecordedLevel: + """Combine explicitly selected levels, rejecting incompatible semantics.""" + episodes: List[EpisodeData] = [] + features: Dict[FeatureKey, SensorFeature] = {} + artifacts: List[SourceArtifact] = [] + for level in levels: + episodes.extend(level.data.episodes) + for feature in level.sensor.features: + previous = features.setdefault(feature.key, feature) + if previous != feature: + raise ValueError("Incompatible sensor semantics across levels") + # Content-named level manifests retain original artifact mappings. + artifacts.append( + SourceArtifact(level.data.digest + ".source", + level.source.manifest)) + artifacts.extend( + SourceArtifact(a.digest, a.content) + for a in level.source.artifacts) + unique = {a.name: a for a in artifacts} + return RecordedLevel(InferenceData(tuple(episodes)), + SensorModel(tuple(features.values())), + ArtifactBundle(tuple(unique.values()))) diff --git a/predicators/code_sim_learning/inference_replay.py b/predicators/code_sim_learning/inference_replay.py new file mode 100644 index 000000000..a7ca25f61 --- /dev/null +++ b/predicators/code_sim_learning/inference_replay.py @@ -0,0 +1,389 @@ +"""Explicit initial states for offline inference, separate from legacy fits. + +Replay always owns a fresh world. Object velocities and attachments +travel by name in the portable State; all robot joints travel in URDF +order. A candidate supplies unobserved quantities rather than obtaining +them from a live task. Capture is for simulator predictions and +evaluator-only audits, not an observation channel for the acting agent. +""" +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import Callable, List, Mapping, Sequence, Tuple + +import numpy as np +import pybullet as p + +from predicators.code_sim_learning.commands import ApplyForce, ApplyTorque, \ + Attach, PhysicsCommand, SetVelocity +from predicators.code_sim_learning.model_state import has_model_state +from predicators.code_sim_learning.rollout_env import \ + _pin_all_physical_params, add_rollouts_run, dispose_env +from predicators.envs.pybullet_env import PyBulletEnv +from predicators.run.recording import sanitize_state +from predicators.structs import Action, State + +# pylint: disable=protected-access +Velocity = Tuple[Tuple[float, float, float], Tuple[float, float, float]] +Pose = Tuple[Tuple[float, float, float], Tuple[float, float, float, float]] + + +@dataclass(frozen=True) +class CommandWeld: + """A candidate's fixed constraint, by object name and original frames.""" + + parent: str + child: str + parent_frame: Pose + child_frame: Pose + max_force: float + erp: float + + +@dataclass(frozen=True) +class ArticulatedBody: + """Nonrobot joint state tied to an identical native world layout. + + Native identifiers are not portable semantic names. Restoration + checks body and joint topology and requires the same allocation + protocol; this record does not remap bodies across different worlds. + """ + + body_id: int + body_names: Tuple[str, str] + joint_layout: Tuple[Tuple[str, int, str], ...] + joints: Tuple[Tuple[float, float], ...] + + +def _capture_articulated_bodies( + env: PyBulletEnv) -> Tuple[ArticulatedBody, ...]: + """Include grouped bodies and fixtures absent from public object keys.""" + pcid = env._physics_client_id + records = [] + for index in range(p.getNumBodies(physicsClientId=pcid)): + body_id = p.getBodyUniqueId(index, physicsClientId=pcid) + count = p.getNumJoints(body_id, physicsClientId=pcid) + if body_id == env._pybullet_robot.robot_id or count == 0: + continue + names = p.getBodyInfo(body_id, physicsClientId=pcid) + layout, joints = [], [] + for joint in range(count): + info = p.getJointInfo(body_id, joint, physicsClientId=pcid) + layout.append((info[1].decode(), int(info[2]), info[12].decode())) + value = p.getJointState(body_id, joint, physicsClientId=pcid) + joints.append((float(value[0]), float(value[1]))) + records.append( + ArticulatedBody(body_id, (names[0].decode(), names[1].decode()), + tuple(layout), tuple(joints))) + return tuple(sorted(records, key=lambda record: record.body_id)) + + +@dataclass(frozen=True) +class ReplayState: + """A physical/model state and explicit robot motion at an action boundary. + + ``robot_joints`` contains (position, velocity) for EVERY URDF joint, + including passive joints. Controlled positions must agree with the + State's joint_positions. Full body orientations, original command- + weld frames, and pending next-step commands are candidate quantities + even when absent from public observations. The caller owns the + nested state; replay copies it before restoration or stepping, so + siblings cannot mutate one another. No solver warm-start state is + represented; replay error must be measured. + """ + + state: State + robot_joints: Tuple[Tuple[float, float], ...] + robot_base_velocity: Velocity + body_poses: Mapping[str, Pose] + pending_commands: Tuple[PhysicsCommand, ...] + command_welds: Tuple[CommandWeld, ...] + articulated_bodies: Tuple[ArticulatedBody, ...] + + +def capture_replay_state(env: PyBulletEnv) -> ReplayState: + """Snapshot a simulated candidate, or evaluator state for an offline audit. + + This deliberately is not called by recording or observation code. It + preserves candidate memory and removes privileged feature payloads. + The fresh factory must use the same body layout as the candidate; + Object metadata is copied, not a cross-layout body remapping API. + """ + raw = env._get_state() + # State.copy()/sanitize_state() retain Object keys. Their sim_data is + # mutable engine metadata, so candidate ownership needs a full copy. + state = sanitize_state(copy.deepcopy(raw)) + state.latent = copy.deepcopy(raw.latent) + pcid = env._physics_client_id + robot_id = env._pybullet_robot.robot_id + joints = [] + for j in range(p.getNumJoints(robot_id, physicsClientId=pcid)): + info = p.getJointState(robot_id, j, physicsClientId=pcid) + joints.append((float(info[0]), float(info[1]))) + linear, angular = p.getBaseVelocity(robot_id, physicsClientId=pcid) + bodies = { + o.id: o.name + for o in env._objects if o.id is not None and o.type.name != "robot" + and o.type.name not in env._VIRTUAL_OBJECT_TYPES + } + poses = {} + for body_id, name in bodies.items(): + position, orientation = p.getBasePositionAndOrientation( + body_id, physicsClientId=pcid) + poses[name] = (tuple(position), tuple(orientation)) + welds = [] + for cid in env._cmd_weld_constraints.values(): + info = p.getConstraintInfo(cid, physicsClientId=pcid) + if info[4] != p.JOINT_FIXED or info[1] != -1 or info[3] != -1: + raise ValueError("Replay command weld must be a fixed base weld") + welds.append( + CommandWeld(bodies[info[0]], bodies[info[2]], + (tuple(info[6]), tuple(info[8])), + (tuple(info[7]), tuple(info[9])), float(info[10]), + float(info[14]))) + return ReplayState(state, tuple(joints), (tuple(linear), tuple(angular)), + poses, + tuple(copy.deepcopy(env._pending_residual_commands)), + tuple(welds), _capture_articulated_bodies(env)) + + +def _validate_pose(pose: Pose) -> None: + """Reject invalid physical poses before touching the candidate world.""" + position, orientation = (np.asarray(v, dtype=float) for v in pose) + if (position.shape != (3, ) or orientation.shape != (4, ) + or not np.isfinite(position).all() + or not np.isfinite(orientation).all() or not np.isclose( + np.linalg.norm(orientation), 1., rtol=0, atol=1e-6)): + raise ValueError("Replay requires finite poses with unit quaternions") + + +def _validate_commands(commands: Tuple[PhysicsCommand, ...], + names: set[str]) -> None: + """Do not silently ignore invalid actuation or unknown target objects.""" + for command in commands: + vectors = [] + if isinstance(command, Attach): + if (command.obj_a_name == command.obj_b_name + or not {command.obj_a_name, command.obj_b_name} <= names): + raise ValueError( + "Replay attachment command has invalid targets") + continue + if isinstance(command, (ApplyForce, ApplyTorque, SetVelocity)): + if command.obj_name not in names: + raise ValueError("Replay command has an unknown target") + if isinstance(command, ApplyForce): + vectors = [command.force] + elif isinstance(command, ApplyTorque): + vectors = [command.torque] + else: + vectors = [ + v for v in (command.linear, command.angular) + if v is not None + ] + else: + raise ValueError("Unsupported replay physics command") + for vector in vectors: + value = np.asarray(vector, dtype=float) + if value.shape != (3, ) or not np.isfinite(value).all(): + raise ValueError( + "Replay command requires finite three-vectors") + + +def _restore_candidate(env: PyBulletEnv, candidate: ReplayState) -> None: + """Restore supplied state and reject missing or inconsistent motion.""" + state = copy.deepcopy(candidate.state) + sim = state.simulator_state + if state.privileged is not None: + raise ValueError("Replay candidates must not contain privileged data") + if not isinstance(sim, dict) or "joint_positions" not in sim: + raise ValueError("Replay requires explicit joint_positions") + velocities = sim.get("body_velocities") + if not isinstance(velocities, dict): + raise ValueError("Replay requires explicit body_velocities") + if has_model_state(type(env)) and state.latent is None: + raise ValueError("Replay requires explicit model memory") + robot = env._pybullet_robot + pcid = env._physics_client_id + robot_id = robot.robot_id + count = p.getNumJoints(robot_id, physicsClientId=pcid) + joints = np.asarray(candidate.robot_joints, dtype=float) + if joints.shape != (count, 2) or not np.isfinite(joints).all(): + raise ValueError("Replay requires finite position/velocity for every " + "robot joint in URDF order") + controlled = np.asarray(sim["joint_positions"], dtype=float) + if (controlled.shape != (len(robot.arm_joints), ) or not np.allclose( + controlled, joints[robot.arm_joints, 0], rtol=0, atol=1e-12)): + raise ValueError("Replay robot joint positions disagree with State") + base_velocity = np.asarray(candidate.robot_base_velocity, dtype=float) + if base_velocity.shape != (2, 3) or not np.isfinite(base_velocity).all(): + raise ValueError("Replay requires finite robot base velocity") + if int(getattr(robot, "base_action_dim", + 0)) > 0 and "base_pose" not in sim: + raise ValueError("Mobile replay requires an explicit base_pose") + # Validate by domain object type. The fresh factory must reproduce + # the candidate body layout expected by the domain restore hook. + physical = [ + o for o in state if o.type.name != "robot" + and o.type.name not in env._VIRTUAL_OBJECT_TYPES + ] + for obj in physical: + value = np.asarray(velocities.get(obj.name), dtype=float) + if value.shape != (2, 3) or not np.isfinite(value).all(): + raise ValueError(f"Replay requires finite body_velocities for " + f"{obj.name}") + physical_names = {obj.name for obj in physical} + if set(candidate.body_poses) != physical_names: + raise ValueError("Replay requires a physical pose for every body") + for pose in candidate.body_poses.values(): + _validate_pose(pose) + _validate_commands(candidate.pending_commands, physical_names) + for pair in sim.get("command_welds", ()): + if (len(pair) != 2 or pair[0] == pair[1] + or not set(pair).issubset(physical_names)): + raise ValueError("Replay command welds must join two known " + "physical objects") + records = [(w.parent, w.child) for w in candidate.command_welds] + if (len({frozenset(pair) + for pair in records}) != len(records) + or sorted(records) != sorted(sim.get("command_welds", ()))): + raise ValueError("Replay requires the original frame for every weld") + for weld in candidate.command_welds: + _validate_pose(weld.parent_frame) + _validate_pose(weld.child_frame) + if (not np.isfinite([weld.max_force, weld.erp]).all() + or weld.max_force < 0 or not 0 <= weld.erp <= 1): + raise ValueError("Replay requires valid weld force and ERP") + env._set_state(state) + actual_bodies = _capture_articulated_bodies(env) + expected_bodies = candidate.articulated_bodies + actual_layout = tuple( + (b.body_id, b.body_names, b.joint_layout) for b in actual_bodies) + expected_layout = tuple( + (b.body_id, b.body_names, b.joint_layout) for b in expected_bodies) + if actual_layout != expected_layout: + raise ValueError("Replay requires the same complete articulated body " + "layout and native allocation order") + for body in expected_bodies: + values = np.asarray(body.joints, dtype=float) + if values.shape != (len(body.joint_layout), 2) or not \ + np.isfinite(values).all(): + raise ValueError("Replay requires finite position/velocity for " + "every articulated body joint") + # _set_state can skip a body whose pose already matches. Restore its + # supplied motion unconditionally, including passive robot joints. + for obj in env._objects: + if obj in physical and obj.id is not None: + p.resetBasePositionAndOrientation(obj.id, + *candidate.body_poses[obj.name], + physicsClientId=pcid) + linear, angular = velocities[obj.name] + p.resetBaseVelocity(obj.id, linear, angular, physicsClientId=pcid) + for j, (position, velocity) in enumerate(candidate.robot_joints): + p.resetJointState(robot_id, + j, + position, + targetVelocity=velocity, + physicsClientId=pcid) + p.resetBaseVelocity(robot_id, + *candidate.robot_base_velocity, + physicsClientId=pcid) + for body in expected_bodies: + for joint, (position, velocity) in enumerate(body.joints): + p.resetJointState(body.body_id, + joint, + position, + targetVelocity=velocity, + physicsClientId=pcid) + # Restoring current poses cannot reconstruct the frame at attachment + # creation. Preserve that frame, including any constraint deflection. + env._clear_commanded_attachments() + ids = { + obj.name: obj.id + for obj in env._objects if obj.name in physical_names + } + for weld in candidate.command_welds: + cid = p.createConstraint(ids[weld.parent], + -1, + ids[weld.child], + -1, + p.JOINT_FIXED, (0., 0., 0.), + weld.parent_frame[0], + weld.child_frame[0], + weld.parent_frame[1], + weld.child_frame[1], + physicsClientId=pcid) + p.changeConstraint(cid, + maxForce=weld.max_force, + erp=weld.erp, + physicsClientId=pcid) + env._cmd_weld_constraints[frozenset((weld.parent, weld.child))] = cid + # Commands emitted after the preceding step belong to this boundary. + # Do not re-run the model here: that would advance its memory twice. + env.queue_residual_commands(copy.deepcopy(candidate.pending_commands)) + env._current_observation = env._get_state() + + +def replay_candidate(factory: Callable[[], PyBulletEnv], + initial: ReplayState, + actions: List[Action], + parameters: Mapping[str, float], + *, + prefix: Sequence[Action] = ()) -> List[ReplayState]: + """Replay a candidate without the legacy fitter's rest-start assumption. + + With no prefix, return the reconstructed initial state followed by + every post-action state. With a prefix, replay those actions in the + SAME world before returning the boundary state and requested suffix. + This retains engine history, native constraints, and domain-private + memory without a mid-trajectory restore. It costs the full prefix on + every call. The root must still be a valid candidate initialization, + not an observed noisy pose or a claimed exact engine checkpoint. + Memory and constraints evolve in the candidate subclass; no + observations are injected during the rollout. This is an offline API + and changes no production fit or observation path. + """ + return replay_initialized_candidate( + factory, + lambda env: _restore_candidate(env, initial), + actions, + parameters, + prefix=prefix) + + +def replay_initialized_candidate( + factory: Callable[[], PyBulletEnv], + initialize: Callable[[PyBulletEnv], None], + actions: Sequence[Action], + parameters: Mapping[str, float], + *, + prefix: Sequence[Action] = ()) -> List[ReplayState]: + """Replay from an explicit, reproducible candidate initialization protocol. + + The initializer receives a fresh world with parameters already applied. + It must create the candidate root, including any initialization history + the engine needs. Parameters are reapplied after initialization, as in + legacy rollout plumbing. Prefix and suffix run without state restoration + between them. No reset/task selection is implicit in this API. + + An inference caller must initialize from its declared prior and allowed + conditioned inputs, never from evaluator-private state. An offline + mechanical audit may instead use the evaluator reset protocol to isolate + engine reproducibility from initial-state reconstruction. The initializer, + its inputs, and runtime belong in the experiment's artifact identity. + """ + env = factory() + add_rollouts_run(1) + try: + _pin_all_physical_params(env, dict(parameters)) + initialize(env) + _pin_all_physical_params(env, dict(parameters)) + for action in prefix: + env.step(Action(action.arr.copy())) + states = [capture_replay_state(env)] + for action in actions: + env.step(Action(action.arr.copy())) + states.append(capture_replay_state(env)) + return states + finally: + dispose_env(env) diff --git a/predicators/code_sim_learning/inference_result.py b/predicators/code_sim_learning/inference_result.py new file mode 100644 index 000000000..fbf2f098b --- /dev/null +++ b/predicators/code_sim_learning/inference_result.py @@ -0,0 +1,50 @@ +"""Versioned inference summaries, initially adapting the incumbent fitter. + +This interface does not change an estimator or publish parameters. In +particular, legacy landscape widths are not marginal credible intervals, +and optimizer candidates are not weighted joint posterior samples. +""" +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Dict, Literal + +if TYPE_CHECKING: + from predicators.code_sim_learning.orchestrator import SysIdOutcome + + +@dataclass(frozen=True) +class LegacyInferenceResult: + """An owned summary of legacy inference after parameter selection. + + Nested dictionaries are independent of the fit and its caches. The + caller still decides whether this candidate is diagnostic or + published; selected parameters alone do not certify publication or + model validity. The original FitResult remains available on + SysIdOutcome for incumbent numerical consumers and checkpoint + compatibility. + """ + + point_estimate: Dict[str, float] + selected_parameters: Dict[str, float] + parameter_diagnostics: Dict[str, Dict[str, Any]] + num_segments: int + num_survivors: int + schema_version: Literal[1] = 1 + estimator: Literal["legacy_rollout_sysid"] = "legacy_rollout_sysid" + uncertainty_kind: Literal["legacy_widths"] = "legacy_widths" + + @classmethod + def from_outcome(cls, outcome: SysIdOutcome) -> LegacyInferenceResult: + """Adapt without fitting, sampling, changing verdicts, or publishing. + + No-survivor outcomes retain their pinned point summary and empty + selection. Consumers must preserve the existing refusal + handling. + """ + return cls(point_estimate=dict(outcome.fitted), + selected_parameters=dict(outcome.applied), + parameter_diagnostics=copy.deepcopy(outcome.report), + num_segments=outcome.num_segments, + num_survivors=outcome.num_survivors) diff --git a/predicators/code_sim_learning/inference_runtime.py b/predicators/code_sim_learning/inference_runtime.py new file mode 100644 index 000000000..a5d078fa5 --- /dev/null +++ b/predicators/code_sim_learning/inference_runtime.py @@ -0,0 +1,165 @@ +"""Explicit working-directory inputs for isolated offline replay workers. + +Freeze optional-file absence as well as contents, and supply the +complete child environment explicitly. These inputs are one component of +runtime identity. The interpreter, imports, native libraries, assets, +command and configuration must also be captured by the caller. This is +not automatic dependency discovery, a filesystem sandbox, or a +production agent change. +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Dict, Optional, Tuple + +from predicators.code_sim_learning.inference_recording import ArtifactBundle, \ + SourceArtifact + + +@dataclass(frozen=True) +class RuntimeFile: + """Owned file bytes, or explicit absence, at a relative worker path.""" + path: str + content: Optional[bytes] + + def __post_init__(self) -> None: + path = PurePosixPath(self.path) + if not self.path or path.is_absolute() or ".." in path.parts or \ + str(path) != self.path or self.path == "." or \ + "\\" in self.path or "\0" in self.path: + raise ValueError("Runtime files need canonical relative paths") + if self.content is not None and not isinstance(self.content, bytes): + raise ValueError("Runtime file contents must be immutable bytes") + + @classmethod + def read(cls, path: str, source: Path) -> RuntimeFile: + """Snapshot a present regular file; absence must be declared + explicitly. + + A typo or missing source must not silently become a claim that + an optional dependency was absent. Symlinks require resolving + and identifying their actual source before constructing this + object. + """ + if source.is_symlink() or not source.is_file(): + raise ValueError("Runtime source must be a present regular file") + return cls(path, source.read_bytes()) + + +@dataclass(frozen=True) +class RuntimeInputs: + """Immutable inputs to a fresh worker directory and explicit environment. + + Use separate processes, not temporary chdir/environ mutations in + concurrent sampler threads. The worker receives environment_dict as + its entire environment; do not merge it with an ambient environment. + Declared inputs are not writable output locations. Verification + after a run catches persistent changes, not transient writes or + external accesses. Programs that require either need a broader + runtime contract. + """ + files: Tuple[RuntimeFile, ...] + environment: Tuple[Tuple[str, str], ...] = () + + def __post_init__(self) -> None: + files = tuple(sorted(self.files, key=lambda f: f.path)) + names = {f.path for f in files} + if len(names) != len(files): + raise ValueError("Duplicate runtime file path") + if any( + str(parent) in names for name in names + for parent in PurePosixPath(name).parents): + raise ValueError("Runtime file paths cannot contain one another") + environment = tuple(sorted(self.environment)) + if len({key for key, _ in environment}) != len(environment): + raise ValueError("Duplicate runtime environment key") + if any(not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key) + or not isinstance(value, str) or "\0" in value + for key, value in environment): + raise ValueError("Invalid runtime environment entry") + object.__setattr__(self, "files", files) + object.__setattr__(self, "environment", environment) + + @property + def environment_dict(self) -> Dict[str, str]: + """An owned complete child environment, with no implicit + inheritance.""" + return dict(self.environment) + + @property + def artifacts(self) -> ArtifactBundle: + """Persist the policy, including absent paths, with the owned bytes.""" + policy = json.dumps( + { + "schema": + 1, + "kind": + "isolated_working_directory_inputs", + "files": + [(f.path, "absent" if f.content is None else "present") + for f in self.files], + "complete_child_environment": + self.environment + }, + sort_keys=True).encode("utf-8") + return ArtifactBundle( + (SourceArtifact("runtime-inputs.json", policy), ) + tuple( + SourceArtifact("files/" + f.path, f.content) + for f in self.files if f.content is not None)) + + @property + def digest(self) -> str: + """Changed contents, absence or environment define different inputs.""" + return self.artifacts.digest + + def materialize(self, directory: Path) -> None: + """Populate a fresh directory without reading an ambient sidecar.""" + if directory.is_symlink(): + raise ValueError("Runtime directory cannot be a symlink") + directory.mkdir(parents=True, exist_ok=True) + if any(directory.iterdir()): + raise ValueError("Runtime directory must be empty") + for source in self.files: + if source.content is None: + continue + target = directory / source.path + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("xb") as stream: + stream.write(source.content) + self.verify(directory) + + def verify(self, directory: Path) -> None: + """Reject missing, changed, unexpected and symlinked worker inputs. + + Call both before execution and before accepting its result. This + comparison is not a security boundary against concurrent or + adversarial filesystem mutation. + """ + if directory.is_symlink() or not directory.is_dir(): + raise ValueError("Runtime directory must be a regular directory") + expected = { + f.path: f.content + for f in self.files if f.content is not None + } + expected_dirs = { + str(parent) + for name in expected for parent in PurePosixPath(name).parents + if str(parent) != "." + } + seen = set() + for path in directory.rglob("*"): + name = path.relative_to(directory).as_posix() + if path.is_symlink(): + raise ValueError(f"Symlinked runtime input: {name}") + if path.is_dir() and name in expected_dirs: + continue + if name not in expected or not path.is_file() or \ + path.read_bytes() != expected[name]: + raise ValueError( + f"Unexpected or changed runtime input: {name}") + seen.add(name) + if seen != set(expected): + raise ValueError("Missing runtime input") diff --git a/predicators/code_sim_learning/inference_sampling.py b/predicators/code_sim_learning/inference_sampling.py new file mode 100644 index 000000000..e118888e0 --- /dev/null +++ b/predicators/code_sim_learning/inference_sampling.py @@ -0,0 +1,528 @@ +"""Offline tempered batch sampling for small continuous reference problems. + +The box reference and the explicit conditional-base extension share the +same tempered kernel. Conditional maps must supply their correct density +factors and full joint coordinates. This does not establish a feasible +physical-state prior for the five domains and is not used by the agent. +""" +from __future__ import annotations + +import json +import math +from dataclasses import asdict, dataclass +from typing import Callable, List, Literal, Optional, Tuple, Union + +import numpy as np + +from predicators.code_sim_learning.inference_checkpoint import \ + SamplerCheckpoint +from predicators.code_sim_learning.inference_data import InferenceIdentity, \ + content_digest + + +@dataclass(frozen=True) +class BoxPrior: + """Fixed normalized independent uniforms in named coordinates. + + Names distinguish shared parameters from individual episode initial + states. Known exact values should be conditioned and excluded from + this vector. More general supports require a different prior + implementation, not rejection followed by an unrecorded change in + prior density. + """ + names: Tuple[str, ...] + bounds: Tuple[Tuple[float, float], ...] + + def __post_init__(self) -> None: + names = tuple(self.names) + bounds = tuple((float(lo), float(hi)) for lo, hi in self.bounds) + if not names or len(names) != len(bounds) or len( + set(names)) != len(names): + raise ValueError( + "Prior requires distinct names and matching bounds") + if any(not isinstance(name, str) or not name for name in names): + raise ValueError("Prior names must be nonempty strings") + if any(not math.isfinite(lo) or not math.isfinite(hi) or lo >= hi + or not math.isfinite(hi - lo) for lo, hi in bounds): + raise ValueError("Prior bounds require finite positive widths") + object.__setattr__(self, "names", names) + object.__setattr__(self, "bounds", bounds) + + @property + def digest(self) -> str: + """Include support, parameter meanings and normalized prior family.""" + return content_digest( + json.dumps( + { + "schema": 1, + "family": "independent_uniform", + "prior": asdict(self) + }, + sort_keys=True).encode("utf-8")) + + +@dataclass(frozen=True) +class SamplerConfig: + """Budget, temperature schedule and optional proposal-coordinate blocks. + + Nonempty blocks partition the proposal coordinates and are selected + uniformly for symmetric moves. Empty blocks preserve full-vector + moves. An explicit increasing schedule may replace equally spaced + temperatures, with the same stage count and final target. + """ + particles: int = 512 + temperatures: int = 32 + moves: int = 4 + proposal_scale: float = 0.05 # fraction of each prior width + max_evaluations: int = 100000 + resample_ess_fraction: float = 0.5 + proposal_blocks: Tuple[Tuple[int, ...], ...] = () + temperature_schedule: Tuple[float, ...] = () + + def __post_init__(self) -> None: + for value in (self.particles, self.temperatures, self.moves, + self.max_evaluations): + if not isinstance(value, int) or value <= 0: + raise ValueError("Sampler counts must be positive integers") + if not math.isfinite(self.proposal_scale) or self.proposal_scale <= 0: + raise ValueError("Proposal scale must be finite and positive") + if not 0 < self.resample_ess_fraction <= 1: + raise ValueError("Resampling ESS fraction must lie in (0, 1]") + blocks = tuple(tuple(block) for block in self.proposal_blocks) + members = [index for block in blocks for index in block] + if any(not block for block in blocks) or any( + not isinstance(index, int) or isinstance(index, bool) or + index < 0 for index in members) or \ + len(set(members)) != len(members): + raise ValueError( + "Proposal blocks require distinct nonnegative indices") + object.__setattr__(self, "proposal_blocks", blocks) + schedule = tuple(self.temperature_schedule) + if schedule and (len(schedule) != self.temperatures + or schedule[-1] != 1. or any( + isinstance(beta, bool) or not math.isfinite(beta) + or beta <= previous + for previous, beta in zip((0., ) + + schedule, schedule))): + raise ValueError( + "Temperature schedule must increase from zero to one " + "with the declared number of stages") + object.__setattr__(self, "temperature_schedule", schedule) + + +@dataclass(frozen=True) +class ConditionedPrior: + """A declared conditional base measure in explicit proposal coordinates. + + original_prior identifies the fixed generative prior, not a previous + fit. conditioning identifies the exact observations, coordinate map, + and density correction. The supplied map must cover the intended + support and include the density ratio relative to the normalized + uniform proposal. This declaration does not verify those properties. + names describe the full joint output, including eliminated + coordinates. + """ + names: Tuple[str, ...] + original_prior: str + conditioning: str + proposal: BoxPrior + + def __post_init__(self) -> None: + names = tuple(self.names) + if not names or len(set(names)) != len(names) or any( + not isinstance(n, str) or not n for n in names): + raise ValueError("Conditional output names must be distinct") + for digest in (self.original_prior, self.conditioning): + if len(digest) != 64 or any(c not in "0123456789abcdef" + for c in digest): + raise ValueError( + "Conditional identities must be SHA256 digests") + object.__setattr__(self, "names", names) + + @property + def digest(self) -> str: + """Pin the original prior, conditioning map and proposal + declaration.""" + return content_digest( + json.dumps( + { + "schema": 1, + "family": "conditional_base_measure", + "prior": asdict(self) + }, + sort_keys=True).encode("utf-8")) + + +@dataclass(frozen=True) +class PriorPoint: + """Full joint candidate and log conditional-base/proposal density ratio. + + The ratio includes exact-observation evidence and all coordinate or + proposal corrections, but no remaining noisy-observation likelihood. + Negative infinity rejects this point; unsupported maps must raise. + """ + joint: Tuple[float, ...] + log_weight: float + + +@dataclass(frozen=True) +class BatchPosterior: + """Versioned candidate result; never a publication or adequacy certificate. + + Samples are joint vectors, including uncertain initial states. + Failure results contain no posterior samples, even if a partial + temperature was reached. ESS is measured before each resampling; + retained weights must not disguise earlier weight collapse or + missing modes. + """ + identity: InferenceIdentity + prior: Union[BoxPrior, ConditionedPrior] + config: SamplerConfig + seed: int + status: Literal["complete", "budget_exhausted", "no_particle_support"] + samples: Tuple[Tuple[float, ...], ...] + weights: Tuple[float, ...] + evaluations: int + completed_temperature: float + initial_finite: int + effective_sample_sizes: Tuple[float, ...] + accepted_moves: int + attempted_moves: int + surviving_ancestors: int + resampling_count: int + schema_version: Literal[1] = 1 + estimator: Literal[ + "offline_tempered_smc_box", + "offline_tempered_smc_conditional"] = "offline_tempered_smc_box" + + def marginal_quantiles( + self, name: str, probabilities: Tuple[float, ...] = (.05, .5, .95) + ) -> Tuple[float, ...]: + """Summarize the same joint samples, without a separate width fit. + + These are empirical posterior quantiles, not a calibration + claim. Failed or incomplete numerical results have no credible + intervals. + """ + if self.status != "complete" or not self.samples: + raise ValueError("No completed posterior samples") + if not probabilities or any(not math.isfinite(p) or p < 0 or p > 1 + for p in probabilities): + raise ValueError("Quantile probabilities must lie in [0, 1]") + index = self.prior.names.index(name) + values = np.asarray(self.samples)[:, index] + weights = np.asarray(self.weights) + keep = weights > 0 + values, weights = values[keep], weights[keep] + order = np.argsort(values, kind="stable") + values, weights = values[order], weights[order] + cumulative = np.cumsum(weights / weights.sum()) + cumulative[-1] = 1.0 + # Inverse empirical CDF, including endpoints, with zero-mass samples + # excluded. This convention does not interpolate across mode gaps. + return tuple( + float(values[np.searchsorted(cumulative, p)]) + for p in probabilities) + + +class _BudgetExceeded(Exception): + """Internal control flow for an exhausted evaluation allowance.""" + + +def sample_batch(prior: Union[BoxPrior, ConditionedPrior], + identity: InferenceIdentity, + log_likelihood: Callable[[np.ndarray], float], + config: SamplerConfig, + seed: int, + *, + condition: Optional[Callable[[np.ndarray], + PriorPoint]] = None, + checkpoint: Optional[Callable[[SamplerCheckpoint], + None]] = None, + resume: Optional[SamplerCheckpoint] = None) -> BatchPosterior: + """Sample a fixed-prior target from scratch, without carried fit weights. + + The callable must evaluate the immutable complete dataset under the + identified simulator and sensor model. It receives an owned candidate; + it must not use future observations, mutable hidden caches, or random + transitions. Program/setup exceptions propagate rather than becoming + zero likelihood. Negative infinity is legitimate zero support, whereas + NaN and positive infinity are evaluation errors. + + Initialization draws from the actual prior. Each fixed temperature + reweights by the likelihood increment, resamples only below the declared + ESS threshold, then applies symmetric random-walk Metropolis moves on the + uniform support. Without resampling, importance weights are retained + through the target-invariant moves. Out-of-support proposals are rejected, + never clipped. Final weighted particles target beta=1, but diagnostics and + repeatability + checks remain necessary; no ESS threshold certifies undiscovered modes. + + For a ConditionedPrior, condition maps proposal coordinates to the full + joint candidate. Initial weights include its base/proposal correction + before any likelihood tempering. Metropolis ratios retain that base + factor at every temperature; only the remaining likelihood is tempered. + Both callbacks receive owned arrays. The budget counts joint target + evaluations, including rejected base points; simulator work may be less + when a prior point is rejected before invoking the likelihood. + + An optional checkpoint callback receives complete initialization and + temperature-stage boundaries. Resume restores that same run, including + its RNG and cumulative evaluation budget. Signatures require unchanged + data, model, prior, runtime, seed, NumPy version and sampler settings. + Interrupted stages are repeated from their last saved boundary; the + checkpoint is not a posterior or a numerical adequacy certificate. + Callers must identify all callback dependencies in identity.runtime. + """ + if identity.prior != prior.digest: + raise ValueError("Prior differs from immutable inference identity") + conditional = isinstance(prior, ConditionedPrior) + if conditional != (condition is not None): + raise ValueError( + "A conditional prior requires exactly one coordinate map") + proposal_prior = prior.proposal if isinstance(prior, + ConditionedPrior) else prior + if config.proposal_blocks and set( + i for block in config.proposal_blocks for i in block) != \ + set(range(len(proposal_prior.names))): + raise ValueError( + "Proposal blocks must partition all proposal coordinates") + rng = np.random.default_rng(seed) + lower, upper = np.asarray(proposal_prior.bounds).T + count = config.particles + particles = rng.uniform(lower, + upper, + size=(count, len(proposal_prior.names))) + joints = np.zeros((count, len(prior.names))) + base_weights = np.zeros(count) + ancestors = np.arange(count) + likelihoods = np.full(count, -np.inf) + weights = np.full(count, 1.0 / count) + resampling_count = 0 + evaluations = 0 + completed = 0.0 + initial_finite = 0 + ess_values: List[float] = [] + accepted = 0 + attempted = 0 + completed_stage = 0 + signature = content_digest( + json.dumps( + { + "kernel": "tempered_smc_stage_checkpoint_v1", + "identity": identity.digest, + "prior": prior.digest, + "config": asdict(config), + "seed": seed, + "numpy": np.__version__ + }, + sort_keys=True, + allow_nan=False).encode("utf-8")) + + if resume is not None: + if resume.signature != signature: + raise ValueError("Checkpoint differs from requested inference run") + state = resume.unpack() + particles = np.asarray(state["particles"], dtype=float) + joints = np.asarray(state["joints"], dtype=float) + base_weights = np.asarray(state["base_weights"], dtype=float) + likelihoods = np.asarray(state["likelihoods"], dtype=float) + weights = np.asarray(state["weights"], dtype=float) + ancestors = np.asarray(state["ancestors"], dtype=int) + if particles.shape != (count, len(proposal_prior.names)) or \ + joints.shape != (count, len(prior.names)) or any( + a.shape != (count,) for a in + (base_weights, likelihoods, weights, ancestors)): + raise ValueError("Invalid checkpoint population shape") + if not np.all(np.isfinite(particles)) or \ + not np.all(np.isfinite(joints)) or \ + np.any(particles < lower) or np.any(particles > upper) or \ + np.any(np.isnan(base_weights)) or \ + np.any(base_weights == math.inf) or \ + np.any(np.isnan(likelihoods)) or \ + np.any(likelihoods == math.inf) or \ + not np.any(np.isfinite(likelihoods)) or \ + not np.all(np.isfinite(weights)) or np.any(weights < 0) or \ + not np.isclose(weights.sum(), 1., rtol=0., atol=1e-12) or \ + np.any(ancestors < 0) or np.any(ancestors >= count): + raise ValueError("Invalid checkpoint population values") + evaluations = state["evaluations"] + initial_finite = state["initial_finite"] + completed_stage = state["completed_stage"] + accepted = state["accepted"] + attempted = state["attempted"] + resampling_count = state["resampling_count"] + ess_values = state["ess_values"] + if any(not isinstance(v, int) or isinstance(v, bool) or v < 0 for v in + (evaluations, initial_finite, completed_stage, accepted, + attempted, resampling_count)) or \ + not count <= evaluations <= config.max_evaluations or \ + not 0 < initial_finite <= count or \ + completed_stage > config.temperatures or \ + attempted != completed_stage * config.moves * count or \ + accepted > attempted or resampling_count > completed_stage or \ + len(ess_values) != completed_stage or any( + not math.isfinite(v) or v <= 0 or v > count + 1e-8 + for v in ess_values): + raise ValueError("Invalid checkpoint progress") + completed = (config.temperature_schedule[completed_stage - 1] + if config.temperature_schedule else + completed_stage / config.temperatures) \ + if completed_stage else 0. + rng.bit_generator.state = state["rng_state"] + + def emit_checkpoint() -> None: + if checkpoint is None: + return + state = { + "particles": particles.tolist(), + "joints": joints.tolist(), + # Explicit strings represent legitimate negative-infinite log + # densities without nonstandard JSON numeric extensions. + "base_weights": [str(float(v)) for v in base_weights], + "likelihoods": [str(float(v)) for v in likelihoods], + "weights": weights.tolist(), + "ancestors": ancestors.tolist(), + "evaluations": evaluations, + "initial_finite": initial_finite, + "completed_stage": completed_stage, + "accepted": accepted, + "attempted": attempted, + "resampling_count": resampling_count, + "ess_values": ess_values, + "rng_state": rng.bit_generator.state + } + checkpoint( + SamplerCheckpoint( + signature, json.dumps(state, sort_keys=True, allow_nan=False))) + + def evaluate(candidate: np.ndarray) -> Tuple[float, float, np.ndarray]: + nonlocal evaluations + if evaluations >= config.max_evaluations: + raise _BudgetExceeded + evaluations += 1 + point = (PriorPoint(tuple(candidate), 0.) + if condition is None else condition(candidate.copy())) + joint = np.asarray(point.joint, dtype=float) + base = float(point.log_weight) + if joint.shape != (len(prior.names), ) or not np.all( + np.isfinite(joint)): + raise ValueError("Coordinate map returned invalid joint values") + if math.isnan(base) or base == math.inf: + raise ValueError("Base weight returned NaN or positive infinity") + if base == -math.inf: + return -math.inf, base, joint.copy() + value = float(log_likelihood(joint.copy())) + if math.isnan(value) or value == math.inf: + raise ValueError("Likelihood returned NaN or positive infinity") + return value, base, joint.copy() + + def result( + status: Literal["complete", "budget_exhausted", "no_particle_support"] + ) -> BatchPosterior: + complete = status == "complete" + return BatchPosterior( + identity=identity, + prior=prior, + config=config, + seed=seed, + status=status, + samples=tuple(tuple(float(v) for v in row) + for row in joints) if complete else (), + weights=tuple(float(w) for w in weights) if complete else (), + evaluations=evaluations, + completed_temperature=completed, + initial_finite=initial_finite, + effective_sample_sizes=tuple(ess_values), + accepted_moves=accepted, + attempted_moves=attempted, + surviving_ancestors=len(set(ancestors.tolist())), + resampling_count=resampling_count, + estimator="offline_tempered_smc_conditional" + if conditional else "offline_tempered_smc_box") + + try: + if resume is None: + for i in range(count): + likelihoods[i], base_weights[i], joints[i] = evaluate( + particles[i]) + initial_finite += int(math.isfinite(likelihoods[i])) + if not initial_finite: + # Finite initialization may simply have missed valid support. + return result("no_particle_support") + if conditional: + weights = np.exp(base_weights - np.max(base_weights)) + weights /= weights.sum() + emit_checkpoint() + for stage in range(completed_stage + 1, config.temperatures + 1): + beta = config.temperature_schedule[stage - 1] if \ + config.temperature_schedule else stage / config.temperatures + # Center before multiplication to avoid loss of stability from + # large normalizing constants common to every candidate. + if conditional and stage == 1: + # Keep tiny base mass in log space until the first likelihood + # update: a discrete observation may select that component. + log_weights = base_weights - np.max(base_weights) + else: + log_weights = np.full(count, -np.inf) + np.log(weights, out=log_weights, where=weights > 0) + log_weights += (beta - completed) * (likelihoods - + np.max(likelihoods)) + weights = np.exp(log_weights - np.max(log_weights)) + weights /= weights.sum() + ess = float(1.0 / np.dot(weights, weights)) + ess_values.append(ess) + if ess < config.resample_ess_fraction * count: + indices = rng.choice(count, + size=count, + replace=True, + p=weights) + particles = particles[indices].copy() + likelihoods = likelihoods[indices].copy() + base_weights = base_weights[indices].copy() + joints = joints[indices].copy() + ancestors = ancestors[indices] + weights.fill(1.0 / count) + resampling_count += 1 + for _ in range(config.moves): + for i in range(count): + attempted += 1 + if config.proposal_blocks: + # A state-independent uniform mixture of symmetric + # block kernels preserves the same tempered target. + block = config.proposal_blocks[int( + rng.integers(len(config.proposal_blocks)))] + block_indices = list(block) + proposal = particles[i].copy() + proposal[block_indices] += rng.normal( + size=len(block_indices)) * \ + (upper[block_indices] - lower[block_indices]) * \ + config.proposal_scale + else: + # Preserve the original full-vector random stream. + proposal = particles[i] + rng.normal( + size=len(proposal_prior.names)) * \ + (upper - lower) * config.proposal_scale + if not np.all(np.isfinite(proposal)) or np.any( + proposal < lower) or np.any(proposal > upper): + continue + trial = evaluate(proposal) + proposed_likelihood, proposed_base, proposed_joint = trial + if proposed_likelihood == -math.inf: + continue + log_ratio = beta * (proposed_likelihood - likelihoods[i]) + if conditional: + log_ratio += proposed_base - base_weights[i] + # 1-random lies in (0, 1], so log never sees zero. + if math.log(1.0 - rng.random()) < log_ratio: + particles[i] = proposal + likelihoods[i] = proposed_likelihood + base_weights[i] = proposed_base + joints[i] = proposed_joint + accepted += 1 + completed = beta + completed_stage = stage + emit_checkpoint() + return result("complete") + except _BudgetExceeded: + return result("budget_exhausted") diff --git a/predicators/code_sim_learning/inference_support.py b/predicators/code_sim_learning/inference_support.py new file mode 100644 index 000000000..760c42313 --- /dev/null +++ b/predicators/code_sim_learning/inference_support.py @@ -0,0 +1,127 @@ +"""Offline exact-output contradictions under explicitly reviewed invariants. + +This module checks recorded evidence, not Python program semantics. The +caller must justify and version the invariant for the identified program +and runtime over all admissible initial states and parameters. Empirical +constancy on sampled rollouts is not such a justification. +""" +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from typing import Dict, List, Literal, Tuple + +from predicators.code_sim_learning.inference_data import FeatureKey, \ + InferenceData, SensorFeature, SensorModel, content_digest + + +@dataclass(frozen=True) +class ConstantOutputs: + """Reviewed within-episode invariants, tied to source and runtime. + + The review artifact must establish invariance, including the + relevant initialization and mutation paths. Different reset episodes + may start at different values. A code, runtime or proof edit + requires a new declaration. This checker never infers invariance + from the data. + """ + program: str + runtime: str + review: str + keys: Tuple[FeatureKey, ...] + + def __post_init__(self) -> None: + for digest in (self.program, self.runtime, self.review): + if len(digest) != 64 or any(c not in "0123456789abcdef" + for c in digest): + raise ValueError("Invariant identities must be SHA256 digests") + keys = tuple(sorted(self.keys)) + if not keys or len(set(keys)) != len(keys): + raise ValueError("Invariant keys must be nonempty and distinct") + for key in keys: + SensorFeature(key, 0.) + object.__setattr__(self, "keys", keys) + + @property + def digest(self) -> str: + """Identify the reviewed invariant, not an empirical fit result.""" + return content_digest( + json.dumps( + { + "schema": 1, + "kind": "constant_outputs", + "declaration": asdict(self) + }, + sort_keys=True).encode("utf-8")) + + +@dataclass(frozen=True) +class ExactContradiction: + """Two exact measurements that cannot both satisfy a constant output.""" + episode_id: str + key: FeatureKey + first_step: int + first_value: float + later_step: int + later_value: float + + +@dataclass(frozen=True) +class SupportAssessment: + """An evidence-backed negative result, never posterior samples. + + No contradiction means only not_disproved: it does not establish a + feasible continuous chart, adequate numerical support, or a + posterior. The assessment is independent of a physical prior because + the declared invariant is required to hold for all admissible + initial states. + """ + data: str + sensor: str + declaration: ConstantOutputs + contradictions: Tuple[ExactContradiction, ...] + + @property + def status(self) -> Literal["model_inconsistent", "not_disproved"]: + """Keep model contradiction distinct from finite-particle failure.""" + return "model_inconsistent" if self.contradictions else "not_disproved" + + +def audit_constant_outputs(data: InferenceData, sensor: SensorModel, + declaration: ConstantOutputs, *, + program_digest: str, + runtime_digest: str) -> SupportAssessment: + """Check exact predicted outputs without sampling or selecting a prefix. + + All supplied episodes and available observations are checked. A + witness stops further comparisons for that key in that episode, not + examination of other keys or episodes. Noisy or conditioned-input + fields cannot establish this exact-output contradiction. + """ + if (program_digest, runtime_digest) != (declaration.program, + declaration.runtime): + raise ValueError( + "Invariant declaration does not match program/runtime") + schema = {f.key: f for f in sensor.features} + for key in declaration.keys: + if key not in schema or schema[key].sigma != 0 or schema[ + key].conditioned: + raise ValueError( + "Invariant requires an exact predicted sensor field") + witnesses: List[ExactContradiction] = [] + for episode in data.episodes: + first: Dict[FeatureKey, Tuple[int, float]] = {} + contradicted = set() + for observation in episode.observations: + for key, value in observation.values: + if key not in declaration.keys or key in contradicted: + continue + step, initial = first.setdefault(key, + (observation.step, value)) + if value != initial: + witnesses.append( + ExactContradiction(episode.episode_id, key, step, + initial, observation.step, value)) + contradicted.add(key) + return SupportAssessment(data.digest, sensor.digest, declaration, + tuple(witnesses)) diff --git a/predicators/code_sim_learning/orchestrator.py b/predicators/code_sim_learning/orchestrator.py index 0885a709a..36faa9551 100644 --- a/predicators/code_sim_learning/orchestrator.py +++ b/predicators/code_sim_learning/orchestrator.py @@ -34,6 +34,8 @@ scalar_to_fit_space from predicators.code_sim_learning.identifiability import \ identifiability_report, select_trustworthy_params +from predicators.code_sim_learning.inference_result import \ + LegacyInferenceResult from predicators.code_sim_learning.physical_sysid import \ _explainability_cache_key, fit_params_rollout_trimmed from predicators.code_sim_learning.rollout_env import RolloutTrajectory, \ @@ -86,6 +88,11 @@ class SysIdOutcome: # None when off or when the fit carries no Jacobian. evidence: Optional[LaplaceEvidence] = None + @property + def inference(self) -> LegacyInferenceResult: + """A versioned, isolated view without changing saved outcome fields.""" + return LegacyInferenceResult.from_outcome(self) + @dataclass class _FitComputation: diff --git a/predicators/envs/pybullet_domino/task_generators/min_block_generation.py b/predicators/envs/pybullet_domino/task_generators/min_block_generation.py index eb2079614..2c4607dde 100644 --- a/predicators/envs/pybullet_domino/task_generators/min_block_generation.py +++ b/predicators/envs/pybullet_domino/task_generators/min_block_generation.py @@ -26,6 +26,7 @@ import numpy as np +from predicators import utils from predicators.envs.pybullet_domino import geometry from predicators.envs.pybullet_domino.task_generators import goal_text from predicators.envs.pybullet_domino.task_generators import \ @@ -34,8 +35,10 @@ _PROBE_ANCHOR, clear_probe_memo, compute_k_star, compute_turn_k_star, \ dual_valid_turn_layout_exists, heavy_dogleg_k_star, straight_span_k_star, \ swerve_k_star +from predicators.run.recording import portable_simulator_state from predicators.settings import CFG -from predicators.structs import EnvironmentTask, GroundAtom, Object, State +from predicators.structs import Array, EnvironmentTask, GroundAtom, Object, \ + State if TYPE_CHECKING: from predicators.envs.pybullet_domino.env import PyBulletDominoComposedEnv @@ -293,10 +296,13 @@ def _load_min_block_cache( tasks: List[EnvironmentTask] = [] for entry in entries: objs = {name: live_objs[name] for name, _tname in entry["objects"]} - state = State({ + data: Dict[Object, Array] = { objs[name]: np.array(vals, dtype=np.float64) for name, vals in entry["data"].items() - }) + } + sim_state = entry.get("simulator_state") + state = (State(data) if sim_state is None else utils.PyBulletState( + data, simulator_state=sim_state)) goal = { GroundAtom(pred_map[pname], [objs[oname] for oname in onames]) for pname, onames in entry["goal"] @@ -312,8 +318,10 @@ def _load_min_block_cache( } if k_star is not None else {}), early_stop_min_reward=(_early_stop_bar(float(k_star)) if k_star is not None else None)) - # Re-run the standard PyBullet conversion (joints, optional - # rendering) instead of caching simulator state. + # Bind live bodies and refresh optional rendering. New caches carry + # the exact initial joints: solving IK again can choose a different + # redundant arm configuration and change subsequent contacts. + # Older caches retain the legacy feature-only reconstruction. tasks.extend(env._add_pybullet_state_to_tasks([plain])) if num_requested is not None and len(tasks) < num_requested: logging.warning( @@ -328,7 +336,7 @@ def _load_min_block_cache( def _save_min_block_cache(path: Optional[Path], tasks: List[EnvironmentTask], num_requested: int) -> None: - """Serialize finished tasks (init data, goal, K*) to the cache. + """Serialize finished tasks (portable init state, goal, K*) to the cache. Only the offline K* is stored; the ``DominoEvaluator`` is rebuilt on load. ``num_requested`` is stored alongside so a partial set (the @@ -345,6 +353,8 @@ def _save_min_block_cache(path: Optional[Path], tasks: List[EnvironmentTask], "objects": [(o.name, o.type.name) for o in init], "data": {o.name: [float(v) for v in init.data[o]] for o in init}, + "simulator_state": + portable_simulator_state(init.simulator_state), "goal": [(a.predicate.name, [o.name for o in a.objects]) for a in env_task.goal], "goal_nl": diff --git a/predicators/pybullet_helpers/link.py b/predicators/pybullet_helpers/link.py index b8680c408..7869c415e 100644 --- a/predicators/pybullet_helpers/link.py +++ b/predicators/pybullet_helpers/link.py @@ -41,13 +41,14 @@ def get_link_state( ) -> LinkState: """Get the state of a link in a given body. - Note: it is unclear what the computeForwardKinematics flag does as we - could not reproduce any difference in the resulting Cartesian world - position or orientation of the link after setting joint positions - with both the flag set to False or True. - - The default PyBullet flag is computeForwardKinematics=False, so we - will stick to that. + Preserve PyBullet's default computeForwardKinematics=False. After + stepping physics, this can return a cached link transform that + differs from forward kinematics of the current joint positions. + Requesting fresh forward kinematics, or even resetting a joint to + its existing position, can refresh that cache and change the pose + without changing joint positions or velocities. Historical robot + observations use this cached phase; inference must preserve its + observation timing rather than assume redundant instantaneous FK. """ link_state = p.getLinkState(body, link, physicsClientId=physics_client_id) return LinkState(*link_state) diff --git a/scripts/audit_inference_replay.py b/scripts/audit_inference_replay.py new file mode 100644 index 000000000..8dafa278f --- /dev/null +++ b/scripts/audit_inference_replay.py @@ -0,0 +1,174 @@ +"""Measure portable replay error on scripted five-domain development prefixes. + +Run on a compute node. This uses evaluator dynamics to isolate +restoration from learned-program error. It produces no agent scorecards +and makes no solve-rate claim. Longer recorded interactions remain a +separate gate. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any, Dict, List, cast + +import numpy as np + +from predicators import utils +from predicators.code_sim_learning.inference_replay import ReplayState, \ + capture_replay_state, replay_candidate +from predicators.code_sim_learning.rollout_env import _pin_all_physical_params +from predicators.envs import create_new_env +from predicators.envs.pybullet_env import PyBulletEnv +from predicators.structs import Action + + +def _errors(expected: List[ReplayState], + actual: List[ReplayState]) -> Dict[str, Any]: + """Return raw per-feature errors, without interpreting them as noise.""" + assert len(expected) == len(actual) + features: Dict[str, float] = {} + joint_position_error = 0.0 + joint_velocity_error = 0.0 + linear_velocity_error = 0.0 + angular_velocity_error = 0.0 + memory_matches = True + attachments_match = True + for left, right in zip(expected, actual): + assert set(left.state) == set(right.state) + for obj in left.state: + for name in obj.type.feature_names: + key = f"{obj.type.name}.{name}" + error = abs( + left.state.get(obj, name) - right.state.get(obj, name)) + if not np.isfinite(error): + raise ValueError(f"Non-finite replay feature: {key}") + features[key] = max(features.get(key, 0.0), float(error)) + joint_errors = np.abs( + np.array(left.robot_joints) - np.array(right.robot_joints)) + if not np.isfinite(joint_errors).all(): + raise ValueError("Non-finite replay joint state") + joint_position_error = max(joint_position_error, + float(np.max(joint_errors[:, 0]))) + joint_velocity_error = max(joint_velocity_error, + float(np.max(joint_errors[:, 1]))) + left_sim, right_sim = (left.state.simulator_state, + right.state.simulator_state) + assert isinstance(left_sim, dict) and isinstance(right_sim, dict) + for name, value in left_sim["body_velocities"].items(): + velocity_errors = np.abs( + np.array(value) - np.array(right_sim["body_velocities"][name])) + if not np.isfinite(velocity_errors).all(): + raise ValueError(f"Non-finite replay velocity: {name}") + linear_velocity_error = max(linear_velocity_error, + float(np.max(velocity_errors[0]))) + angular_velocity_error = max(angular_velocity_error, + float(np.max(velocity_errors[1]))) + memory_matches &= left.state.latent == right.state.latent + attachments_match &= (left_sim.get("command_welds", + []) == right_sim.get( + "command_welds", [])) + return { + "max_feature_errors": features, + "max_robot_joint_position_error": joint_position_error, + "max_robot_joint_velocity_error": joint_velocity_error, + "max_body_linear_velocity_error": linear_velocity_error, + "max_body_angular_velocity_error": angular_velocity_error, + "model_memory_equal": memory_matches, + "command_attachments_equal": attachments_match + } + + +def audit(domain: str, seed: int, steps: int) -> Dict[str, Any]: + """Compare fresh replays and a resumed prefix with the source world.""" + name = f"pybullet_{domain}" + utils.reset_config({ + "env": name, + "seed": seed, + "num_train_tasks": 1, + "num_test_tasks": 0, + "skill_phase_use_motion_planning": False + }) + + def factory() -> PyBulletEnv: + return cast(PyBulletEnv, create_new_env(name, do_cache=False)) + + env = factory() + try: + _pin_all_physical_params(env, {}) + env.reset("train", 0) + _pin_all_physical_params(env, {}) + initial = capture_replay_state(env) + sim_state = initial.state.simulator_state + assert isinstance(sim_state, dict) + hold = np.array(sim_state["joint_positions"], dtype=np.float32) + extra = env.action_space.shape[0] - len(hold) + action = Action( + np.concatenate([hold, np.zeros(extra, dtype=np.float32)])) + recorded = [initial] + for _ in range(steps): + env.step(action) + recorded.append(capture_replay_state(env)) + actions = [action] * steps + first = replay_candidate(factory, initial, actions, {}) + repeat = replay_candidate(factory, initial, actions, {}) + prefix = min(3, steps - 1) + resumed = replay_candidate(factory, recorded[prefix], actions[prefix:], + {}) + return { + "domain": domain, + "seed": seed, + "actions": steps, + "resume_prefix": prefix, + "restoration": _errors(recorded[:1], first[:1]), + "source_replay": _errors(recorded, first), + "repeatability": _errors(first, repeat), + "prefix_replay": _errors(recorded[prefix:], resumed) + } + finally: + env.dispose() + + +def main() -> None: + """Write a development audit, including explicit setup failures.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument( + "--domains", + nargs="+", + default=["bridge", "fan", "domino", "boil", "balloons"]) + parser.add_argument("--seed", type=int, default=0) + parser.add_argument("--steps", type=int, default=30) + args = parser.parse_args() + if args.steps < 2: + parser.error("--steps must be at least 2") + results = [] + for domain in args.domains: + try: + result = audit(domain, args.seed, args.steps) + except Exception as error: # pylint: disable=broad-except + result = { + "domain": domain, + "seed": args.seed, + "setup_or_replay_error": f"{type(error).__name__}: {error}" + } + results.append(result) + print(json.dumps(result, sort_keys=True), flush=True) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps( + { + "kind": "mechanical_replay_audit", + "agent_results": False, + "dynamics": + "evaluator program; registry defaults pinned; offline only", + "results": results, + }, + indent=2, + sort_keys=True), + encoding="utf-8") + if any("setup_or_replay_error" in result for result in results): + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/code_sim_learning/test_inference_assembly.py b/tests/code_sim_learning/test_inference_assembly.py new file mode 100644 index 000000000..9aff0c101 --- /dev/null +++ b/tests/code_sim_learning/test_inference_assembly.py @@ -0,0 +1,163 @@ +"""Physical support and motion checks for an explicit assembly prior.""" +from dataclasses import replace + +import numpy as np +import pybullet as p +import pytest +from scipy.spatial.transform import Rotation + +from predicators.code_sim_learning.inference_assembly import AssemblyBody, \ + RigidAssemblyPrior + + +def _prior(moving=False): + return RigidAssemblyPrior( + (AssemblyBody("box", ((0., 0., 0.), (0., 0., 0., 1.)), .06), + AssemblyBody("balloon", ((0., 0., .12), (0., 0., 0., 1.)), .02)), + ((0., 1.), ) * 3, + linear_half_width=.2 if moving else 0., + angular_half_width=.3 if moving else 0.) + + +def test_normalized_root_coordinates_and_haar_rotation(): + """Uniform rotation has no preferred direction; motion cases are + explicit.""" + prior = _prior() + assert len(prior.coordinates.names) == 6 + assert len(_prior(True).coordinates.names) == 12 + assert prior.digest != _prior(True).digest + bounds = np.asarray(prior.coordinates.bounds) + points = np.random.default_rng(12).uniform(*bounds.T, size=(4096, 6)) + axes = [] + for point in points: + state = prior.lift(point) + axes.append( + Rotation.from_quat(state.poses["box"][1]).apply([0., 0., 1.])) + assert all( + np.array_equal(v, np.zeros((2, 3))) + for v in state.velocities.values()) + axes = np.asarray(axes) + np.testing.assert_allclose(axes.mean(axis=0), 0., atol=.025) + np.testing.assert_allclose(axes.T @ axes / len(axes), + np.eye(3) / 3, + atol=.025) + expected = np.array([.5, .5, .5]) + np.testing.assert_allclose(points[:, :3].mean(axis=0), expected, atol=.015) + + +def test_twist_matches_derivative_of_rigid_motion(): + """Offset bodies need omega-cross-offset motion, not copied root + velocity.""" + prior = _prior(True) + point = np.array([.4, .5, .6, .3, .7, .2, .1, -.2, .05, .2, -.1, .3]) + state = prior.lift(point) + dt = 1e-7 + root = np.array(state.poses["box"][0]) + turn = Rotation.from_rotvec(point[9:] * dt) + for name, (position, _) in state.poses.items(): + shifted = root + point[6:9] * dt + turn.apply( + np.array(position) - root) + np.testing.assert_allclose((shifted - position) / dt, + state.velocities[name][0], + atol=1e-8) + assert state.velocities["box"][0] != state.velocities["balloon"][0] + + +def test_engine_geometry_and_weld_frames_are_consistent(): + """Check actual engine geometry, not just bounds in the coordinate map.""" + prior = _prior(True) + bounds = np.asarray(prior.coordinates.bounds) + pcid = p.connect(p.DIRECT) + try: + box_shape = p.createCollisionShape(p.GEOM_BOX, + halfExtents=[.03] * 3, + physicsClientId=pcid) + ball_shape = p.createCollisionShape(p.GEOM_SPHERE, + radius=.02, + physicsClientId=pcid) + ids = { + "box": p.createMultiBody(.1, box_shape, physicsClientId=pcid), + "balloon": p.createMultiBody(.01, ball_shape, physicsClientId=pcid) + } + for point in np.random.default_rng(1).uniform(*bounds.T, + size=(32, 12)): + state = prior.lift(point) + for name, body_id in ids.items(): + p.resetBasePositionAndOrientation(body_id, + *state.poses[name], + physicsClientId=pcid) + lo, hi = np.asarray(p.getAABB(body_id, physicsClientId=pcid)) + assert np.all(lo >= 0) and np.all(hi <= 1) + assert not p.getClosestPoints( + ids["box"], ids["balloon"], 0., physicsClientId=pcid) + weld, = state.welds + # Independent transform composition through Bullet's own API. + parent_world = p.multiplyTransforms(*state.poses[weld.parent], + *weld.parent_frame) + child_world = p.multiplyTransforms(*state.poses[weld.child], + *weld.child_frame) + np.testing.assert_allclose(parent_world[0], + child_world[0], + atol=1e-7) + np.testing.assert_allclose(parent_world[1], + child_world[1], + atol=1e-7) + finally: + p.disconnect(pcid) + + +def test_prior_rejects_unjustified_or_empty_support(): + """Bad enclosing geometry or a cell too small is not a sampled scene.""" + prior = _prior() + with pytest.raises(ValueError, match="overlap"): + replace(prior, + bodies=(prior.bodies[0], replace(prior.bodies[1], radius=.1))) + with pytest.raises(ValueError, match="positive widths"): + replace(prior, free_cell=((0., .1), ) * 3) + with pytest.raises(ValueError, match="Twist"): + replace(prior, linear_half_width=.1) + with pytest.raises(ValueError, match="outside"): + prior.lift(np.zeros(6)) + with pytest.raises(ValueError, match="unit pose"): + replace(prior.bodies[1], pose=((0., 0., .1), (0., 0., 0., 0.))) + with pytest.raises(ValueError, match="identity root"): + replace(prior, bodies=prior.bodies[::-1]) + + +def test_supported_component_has_exact_plane_contact(): + """A face-supported component has three coordinates, not a tiny z band.""" + prior = replace(_prior(), support_depth=.03) + assert len(prior.coordinates.names) == 3 + assert prior.digest != _prior().digest + client = p.connect(p.DIRECT) + try: + plane = p.createCollisionShape(p.GEOM_PLANE, physicsClientId=client) + floor = p.createMultiBody(0, plane, physicsClientId=client) + shape = p.createCollisionShape(p.GEOM_BOX, + halfExtents=[.03] * 3, + physicsClientId=client) + box = p.createMultiBody(.1, shape, physicsClientId=client) + for yaw in np.linspace(0., 1., 17): + state = prior.lift(np.array([.4, .6, yaw])) + p.resetBasePositionAndOrientation(box, + *state.poses["box"], + physicsClientId=client) + contacts = p.getClosestPoints(box, + floor, + 1e-8, + physicsClientId=client) + assert contacts + # Engine distance roundoff, not an observation-noise likelihood. + assert max(abs(c[8]) for c in contacts) < 1e-12 + assert state.poses["box"][0][2] == .03 + np.testing.assert_array_equal(state.velocities["box"], + np.zeros((2, 3))) + with pytest.raises(ValueError, match="face and rest"): + replace(prior, linear_half_width=.1, angular_half_width=.1) + with pytest.raises(ValueError, match="below support"): + replace(prior, + bodies=(prior.bodies[0], + replace(prior.bodies[1], + pose=((0., 0., -.12), (0., 0., 0., 1.))))) + finally: + p.disconnect(client) diff --git a/tests/code_sim_learning/test_inference_assessment.py b/tests/code_sim_learning/test_inference_assessment.py new file mode 100644 index 000000000..7b94e5d63 --- /dev/null +++ b/tests/code_sim_learning/test_inference_assessment.py @@ -0,0 +1,129 @@ +"""Inference availability must not be confused with predictive adequacy.""" +from dataclasses import replace +from typing import Tuple + +import pytest + +from predicators.code_sim_learning.inference_assessment import \ + AssessmentProtocol, InferenceCheck, assess_inference +from predicators.code_sim_learning.inference_data import InferenceIdentity, \ + content_digest +from predicators.code_sim_learning.inference_sampling import BatchPosterior, \ + BoxPrior, SamplerConfig, sample_batch + + +def _candidate(*, impossible: bool = False) -> BatchPosterior: + prior = BoxPrior(("theta", ), ((-1., 1.), )) + digest = content_digest(b"assessment reference") + identity = InferenceIdentity(digest, digest, digest, prior.digest, digest) + return sample_batch(prior, + identity, + lambda _: -float("inf") if impossible else 0., + SamplerConfig(particles=16, + temperatures=2, + moves=1, + max_evaluations=100), + seed=0) + + +def _protocol() -> AssessmentProtocol: + return AssessmentProtocol(content_digest(b"test protocol"), + ("reference", "repeatability")) + + +def _passed() -> Tuple[InferenceCheck, ...]: + evidence = content_digest(b"test evidence") + return tuple( + InferenceCheck(name, "pass", "Test reference passed", evidence) + for name in _protocol().required_numerical_checks) + + +def test_predictive_failure_keeps_numerically_available_inference() -> None: + """A poor held-out prediction cannot silently suppress a computed fit.""" + candidate = _candidate() + bad_prediction = InferenceCheck( + "episode0.future.box.speed", "fail", "Held-out speed is inconsistent", + content_digest(b"held-out prediction report")) + result = assess_inference(candidate, _protocol(), _passed(), + (bad_prediction, )) + assert result.availability == "available" + assert result.posterior is candidate + assert result.predictive_checks == (bad_prediction, ) + assert result.posterior.marginal_quantiles("theta") == \ + candidate.marginal_quantiles("theta") + assert result.identity == candidate.identity + + +def test_missing_or_failed_numerics_do_not_expose_samples() -> None: + """Finished SMC and passing predictions do not replace numerical checks.""" + candidate = _candidate() + predictive = (InferenceCheck("future.event", "pass", "Event matches", + content_digest(b"prediction")), ) + missing = assess_inference(candidate, _protocol(), + _passed()[:1], predictive) + assert missing.availability == "unevaluated" + assert missing.posterior is None + assert missing.numerical_checks[-1].status == "unevaluated" + failed = replace(_passed()[1], + status="fail", + detail="Independent fits differ") + result = assess_inference(candidate, _protocol(), (_passed()[0], failed), + predictive) + assert result.availability == "numerical_failure" + assert result.posterior is None + assert result.predictive_checks == predictive + assert candidate.samples # The separate diagnostic artifact is intact. + + +def test_sampler_failure_cannot_be_overridden_by_assessment() -> None: + """No-support sampler outcomes stay unavailable despite caller verdicts.""" + candidate = _candidate(impossible=True) + assert candidate.status == "no_particle_support" + result = assess_inference(candidate, _protocol(), _passed()) + assert result.availability == "numerical_failure" + assert result.posterior is None + assert result.sampler_status == "no_particle_support" + + +def test_assessment_rejects_missing_evidence_and_protocol_drift() -> None: + """Reports cannot silently omit their evidence or change required + checks.""" + candidate = _candidate() + with pytest.raises(ValueError, match="evidence"): + InferenceCheck("reference", "pass", "No report") + with pytest.raises(ValueError, match="SHA256"): + AssessmentProtocol("unidentified", ("reference", )) + with pytest.raises(ValueError, match="distinct"): + AssessmentProtocol(content_digest(b"protocol"), ()) + with pytest.raises(ValueError, match="Duplicate"): + assess_inference(candidate, _protocol(), (_passed()[0], ) * 2) + with pytest.raises(ValueError, match="absent"): + assess_inference(candidate, _protocol(), + (replace(_passed()[0], name="ESS only"), )) + + +@pytest.mark.parametrize("changes", [ + { + "weights": (1., ) + }, + { + "weights": (0., ) * 16 + }, + { + "weights": (float("nan"), ) * 16 + }, + { + "samples": ((float("nan"), ), ) * 16 + }, + { + "samples": ((0., 1.), ) * 16 + }, + { + "completed_temperature": .5 + }, +]) +def test_malformed_sampler_artifacts_are_not_published(changes: dict) -> None: + """Deserialized or constructed invalid particles are not usable fits.""" + with pytest.raises(ValueError): + assess_inference(replace(_candidate(), **changes), _protocol(), + _passed()) diff --git a/tests/code_sim_learning/test_inference_blocks.py b/tests/code_sim_learning/test_inference_blocks.py new file mode 100644 index 000000000..85fb81c61 --- /dev/null +++ b/tests/code_sim_learning/test_inference_blocks.py @@ -0,0 +1,93 @@ +"""Declared block kernels target the same joint distributions.""" +import math +from dataclasses import replace +from typing import Union + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_data import InferenceIdentity, \ + content_digest +from predicators.code_sim_learning.inference_sampling import BoxPrior, \ + ConditionedPrior, PriorPoint, SamplerConfig, sample_batch + + +@pytest.mark.parametrize("conditional", [False, True]) +def test_joint_block_target_against_grid(conditional: bool) -> None: + """Correlated coordinates and uninformed marginals survive blocked + moves.""" + box = BoxPrior(tuple(f"x{i}" for i in range(6)), ((-1., 1.), ) * 6) + digest = content_digest(b"block reference") + prior: Union[BoxPrior, ConditionedPrior] = ConditionedPrior( + box.names, digest, digest, box) if conditional else box + identity = InferenceIdentity(digest, digest, digest, prior.digest, digest) + + def likelihood(point: np.ndarray) -> float: + return -.5 * (((point[0] + point[1] - .3) / .12)**2 + + ((point[0] - point[1] - .1) / .4)**2) + + def conditioning(point: np.ndarray) -> PriorPoint: + return PriorPoint(tuple(point), .8 * float(point[0])) + + config = SamplerConfig(particles=1600, + temperatures=24, + moves=10, + proposal_scale=.08, + max_evaluations=400000, + proposal_blocks=((0, 1), (2, ), (3, ), (4, ), + (5, ))) + result = sample_batch(prior, + identity, + likelihood, + config, + 14, + condition=conditioning if conditional else None) + assert result.status == "complete" + axis = np.linspace(-1., 1., 401) + a, b = np.meshgrid(axis, axis, indexing="ij") + log_weight = -.5 * (((a + b - .3) / .12)**2 + ((a - b - .1) / .4)**2) + if conditional: + log_weight += .8 * a + weights = np.exp(log_weight - log_weight.max()) + weights /= weights.sum() + expected = np.array([(weights * a).sum(), (weights * b).sum()]) + samples = np.asarray(result.samples) + mean = np.average(samples, axis=0, weights=result.weights) + np.testing.assert_allclose(mean[:2], expected, atol=.02) + assert np.all(abs(mean[2:]) < .1) + variance = np.average((samples - mean)**2, axis=0, weights=result.weights) + np.testing.assert_allclose(variance[2:], np.full(4, 1 / 3), atol=.05) + expected_cov = (weights * (a - expected[0]) * (b - expected[1])).sum() + covariance = np.average( + (samples[:, 0] - mean[0]) * (samples[:, 1] - mean[1]), + weights=result.weights) + assert covariance == pytest.approx(expected_cov, abs=.008) + + +def test_block_partition_and_budget_contract() -> None: + """Malformed partitions cannot leave coordinates permanently frozen.""" + box = BoxPrior(("x", "y"), ((0., 1.), ) * 2) + digest = content_digest(b"block validation") + identity = InferenceIdentity(digest, digest, digest, box.digest, digest) + for blocks in (((0, ), (0, 1)), ((), ), ((-1, ), ), ((True, ), )): + with pytest.raises(ValueError, match="distinct nonnegative"): + SamplerConfig(proposal_blocks=blocks) + for blocks in (((0, ), ), ((0, 1, 2), )): + with pytest.raises(ValueError, match="partition all"): + sample_batch(box, identity, lambda _: 0., + SamplerConfig(proposal_blocks=blocks), 0) + config = SamplerConfig(particles=10, + temperatures=2, + moves=2, + max_evaluations=10, + proposal_blocks=((0, ), (1, ))) + result = sample_batch(box, identity, lambda _: 0., config, 0) + assert result.status == "budget_exhausted" + assert not result.samples + assert not result.weights + assert result.evaluations == 10 + plain = replace(config, max_evaluations=100, proposal_blocks=()) + result = sample_batch(box, identity, lambda _: 0., plain, 1) + assert result == sample_batch(box, identity, lambda _: 0., + replace(plain, proposal_blocks=()), 1) + assert math.isclose(sum(result.weights), 1.) diff --git a/tests/code_sim_learning/test_inference_checkpoint.py b/tests/code_sim_learning/test_inference_checkpoint.py new file mode 100644 index 000000000..b49882de2 --- /dev/null +++ b/tests/code_sim_learning/test_inference_checkpoint.py @@ -0,0 +1,208 @@ +"""Interrupted inference must reproduce uninterrupted fixed-target sampling.""" +import json +import math +from dataclasses import replace +from pathlib import Path +from typing import List, Union + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_checkpoint import \ + SamplerCheckpoint +from predicators.code_sim_learning.inference_data import InferenceIdentity, \ + content_digest +from predicators.code_sim_learning.inference_sampling import BoxPrior, \ + ConditionedPrior, PriorPoint, SamplerConfig, sample_batch + + +@pytest.mark.parametrize("conditional", [False, True]) +@pytest.mark.parametrize("interruption_stage", [0, 2, 5]) +def test_resume_exact_run(tmp_path: Path, conditional: bool, + interruption_stage: int) -> None: + """Disk round trips preserve weights, ancestry, proposals and RNG state. + + Interrupt inside a move stage, then rerun from the last saved + boundary. Zero-support particles and conditional density factors + remain present, and no initial candidates are evaluated again. + """ + box = BoxPrior(("u", "v"), ((0., 1.), (0., 1.))) + digest = content_digest(b"checkpoint reference") + prior: Union[BoxPrior, ConditionedPrior] = box + if conditional: + prior = ConditionedPrior(("x", "v", "derived"), digest, digest, box) + identity = InferenceIdentity(digest, digest, digest, prior.digest, digest) + config = SamplerConfig(particles=48, + temperatures=5, + moves=3, + proposal_scale=.2, + max_evaluations=1000, + proposal_blocks=((0, ), (1, )), + temperature_schedule=(.005, .03, .15, .5, 1.)) + + def condition(value: np.ndarray) -> PriorPoint: + return PriorPoint((value[0]**2, value[1], value[0] + value[1]), + math.log(.1 + value[1])) + + def likelihood(value: np.ndarray) -> float: + if value[1] < .25: + return -math.inf + return float(-200 * (value[0] - .7)**2 - 4 * value[1]) + + reference = sample_batch(prior, + identity, + likelihood, + config, + 19, + condition=condition if conditional else None) + assert reference.status == "complete" + assert reference.resampling_count > 0 + path = tmp_path / "checkpoint.json" + ready = False + calls = 0 + + def save(checkpoint: SamplerCheckpoint) -> None: + nonlocal ready + checkpoint.save(path) + ready = checkpoint.unpack()["completed_stage"] == interruption_stage + # The returned mapping is independent of the checkpoint and solver. + checkpoint.unpack()["particles"][0][0] = math.nan + + def interrupted_likelihood(value: np.ndarray) -> float: + nonlocal calls + calls += 1 + if ready: + raise RuntimeError("worker interrupted during target evaluation") + return likelihood(value) + + if interruption_stage < config.temperatures: + with pytest.raises(RuntimeError, match="worker interrupted"): + sample_batch(prior, + identity, + interrupted_likelihood, + config, + 19, + condition=condition if conditional else None, + checkpoint=save) + else: + assert sample_batch(prior, + identity, + interrupted_likelihood, + config, + 19, + condition=condition if conditional else None, + checkpoint=save) == reference + checkpoint = SamplerCheckpoint.load(path) + assert checkpoint.unpack()["completed_stage"] == interruption_stage + calls = 0 + + def counted(value: np.ndarray) -> float: + nonlocal calls + calls += 1 + return likelihood(value) + + resumed = sample_batch(prior, + identity, + counted, + config, + 19, + condition=condition if conditional else None, + resume=checkpoint) + assert resumed == reference + assert calls == reference.evaluations - checkpoint.unpack()["evaluations"] + + +def test_reject_changed_run_and_damaged_checkpoint( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Reject incompatible inputs before target evaluation; writes are + atomic.""" + prior = BoxPrior(("x", ), ((0., 1.), )) + digest = content_digest(b"immutable") + identity = InferenceIdentity(digest, digest, digest, prior.digest, digest) + config = SamplerConfig(particles=8, temperatures=2, moves=1) + saved: List[SamplerCheckpoint] = [] + sample_batch(prior, + identity, + lambda _: 0., + config, + 0, + checkpoint=saved.append) + checkpoint = saved[0] + + def never(_: np.ndarray) -> float: + raise AssertionError("incompatible checkpoint evaluated") + + for field in ("program", "data", "runtime", "sensor"): + changed = replace(identity, **{field: content_digest(b"changed")}) + with pytest.raises(ValueError, match="differs"): + sample_batch(prior, changed, never, config, 0, resume=checkpoint) + for run_config, run_seed in ((config, 1), (replace(config, moves=2), 0)): + with pytest.raises(ValueError, match="differs"): + sample_batch(prior, + identity, + never, + run_config, + run_seed, + resume=checkpoint) + for key, value, message in (("particles", [], + "shape"), ("weights", [-1.] * 8, "values"), + ("completed_stage", 10, "progress")): + state = checkpoint.unpack() + state[key] = value + invalid = replace(checkpoint, state=json.dumps(state)) + with pytest.raises(ValueError, match=message): + sample_batch(prior, identity, never, config, 0, resume=invalid) + path = tmp_path / "checkpoint.json" + checkpoint.save(path) + assert SamplerCheckpoint.load(path) == checkpoint + original = path.read_bytes() + + def failed_replace(*_args: object) -> None: + raise OSError("write interrupted") + + monkeypatch.setattr("os.replace", failed_replace) + with pytest.raises(OSError, match="write interrupted"): + saved[-1].save(path) + assert path.read_bytes() == original + assert list(tmp_path.iterdir()) == [path] + damaged = json.loads(path.read_text()) + damaged["payload"] += " " + path.write_text(json.dumps(damaged)) + with pytest.raises(ValueError, match="checksum"): + SamplerCheckpoint.load(path) + + +def test_budget_does_not_publish_checkpoint_particles() -> None: + """A resumable stage is neither a posterior nor extra evaluation budget.""" + prior = BoxPrior(("x", ), ((0., 1.), )) + digest = content_digest(b"budget") + identity = InferenceIdentity(digest, digest, digest, prior.digest, digest) + config = SamplerConfig(particles=16, + temperatures=8, + moves=2, + max_evaluations=80) + saved: List[SamplerCheckpoint] = [] + result = sample_batch(prior, + identity, + lambda _: 0., + config, + 0, + checkpoint=saved.append) + assert result.status == "budget_exhausted" + assert not result.samples and not result.weights + resumed = sample_batch(prior, + identity, + lambda _: 0., + config, + 0, + resume=saved[-1]) + assert resumed == result + saved.clear() + unsupported = sample_batch(prior, + identity, + lambda _: -math.inf, + config, + 0, + checkpoint=saved.append) + assert unsupported.status == "no_particle_support" + assert not saved diff --git a/tests/code_sim_learning/test_inference_conditional_batch.py b/tests/code_sim_learning/test_inference_conditional_batch.py new file mode 100644 index 000000000..6eec00cd4 --- /dev/null +++ b/tests/code_sim_learning/test_inference_conditional_batch.py @@ -0,0 +1,174 @@ +"""Integrated exact conditioning, noisy dynamics and posterior sampling.""" +import math +from dataclasses import replace +from typing import Callable, Tuple + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_conditioning import \ + AffineConditioning +from predicators.code_sim_learning.inference_data import InferenceIdentity, \ + content_digest +from predicators.code_sim_learning.inference_sampling import BoxPrior, \ + ConditionedPrior, PriorPoint, SamplerConfig, sample_batch + + +def _problem( + upper: float = 2. +) -> Tuple[ConditionedPrior, InferenceIdentity, Callable[[np.ndarray], + PriorPoint]]: + original = BoxPrior(("theta", "start", "unused"), + ((1., upper), (0., 1.), (-1., 1.))) + chart = AffineConditioning( + original, ("start", ), (.5, ), + content_digest(b"x(t)=start*theta**t; exact x(1)=.5")) + prior = ConditionedPrior(original.names, original.digest, chart.digest, + BoxPrior(chart.free_names, chart.free_bounds)) + digest = content_digest(b"synthetic full-data reference") + identity = InferenceIdentity(digest, digest, digest, prior.digest, digest) + + def condition(free: np.ndarray) -> PriorPoint: + point = chart.lift(free, np.array([[free[0]]]), np.zeros(1)) + return PriorPoint(point.joint, point.log_base_weight) + + return prior, identity, condition + + +def test_base_density_survives_every_metropolis_move() -> None: + """Zero remaining likelihood must preserve the conditional base, not q.""" + prior, identity, condition = _problem(8.) + result = sample_batch(prior, + identity, + lambda _: 0., + SamplerConfig(particles=8192, + temperatures=12, + moves=4, + proposal_scale=.12, + max_evaluations=600000), + 4, + condition=condition) + assert result.status == "complete" + samples = np.asarray(result.samples) + assert np.average(samples[:, 0], + weights=result.weights) == pytest.approx(7 / math.log(8), + abs=.07) + assert abs(np.average(samples[:, 2], weights=result.weights)) < .07 + np.testing.assert_allclose(samples[:, 0] * samples[:, 1], .5, atol=1e-15) + assert isinstance(result.prior, ConditionedPrior) + assert result.prior.original_prior == prior.original_prior + assert result.estimator == "offline_tempered_smc_conditional" + + +def test_noisy_growth_predictions_against_quadrature() -> None: + """Joint initial state and dynamics predict a held-out time from one + fit.""" + prior, identity, condition = _problem() + times = np.array([0, 2, 3]) + observed = np.array([.38, .71, 1.07]) + sigma = .2 + + def likelihood(joint: np.ndarray) -> float: + return float(-.5 * np.sum( + ((joint[1] * joint[0]**times - observed) / sigma)**2)) + + axis = 1 + (np.arange(10000) + .5) / 10000 + log_grid = -np.log(axis) + for time, value in zip(times, observed): + log_grid -= .5 * ((.5 * axis**(time - 1) - value) / sigma)**2 + grid_weights = np.exp(log_grid - log_grid.max()) + grid_weights /= grid_weights.sum() + result = sample_batch(prior, + identity, + likelihood, + SamplerConfig(particles=1600, + temperatures=24, + moves=4, + max_evaluations=180000), + 11, + condition=condition) + assert result.status == "complete" + samples = np.asarray(result.samples) + weights = np.asarray(result.weights) + assert np.average(samples[:, 0], weights=weights) == pytest.approx( + float(grid_weights @ axis), abs=.012) + assert np.average(samples[:, 1], weights=weights) == pytest.approx( + float(grid_weights @ (.5 / axis)), abs=.005) + prediction = samples[:, 1] * samples[:, 0]**4 + assert np.average(prediction, weights=weights) == pytest.approx(float( + grid_weights @ (.5 * axis**3)), + abs=.04) + covariance = np.cov(samples[:, :2].T, aweights=weights) + assert covariance[0, 1] / math.sqrt( + covariance[0, 0] * covariance[1, 1]) < -.95 + assert result.marginal_quantiles("start")[0] < result.marginal_quantiles( + "start")[2] + + +def test_tiny_base_mass_can_be_selected_by_exact_discrete_evidence() -> None: + """Do not underflow a rare component before incorporating its evidence.""" + box = BoxPrior(("x", ), ((0., 1.), )) + digest = content_digest(b"rare conditional support") + prior = ConditionedPrior(box.names, box.digest, digest, box) + identity = InferenceIdentity(digest, digest, digest, prior.digest, digest) + result = sample_batch( + prior, + identity, + lambda x: 0. if x[0] >= .5 else -math.inf, + SamplerConfig(particles=100, temperatures=4, moves=1), + 0, + condition=lambda x: PriorPoint(tuple(x), -1000. if x[0] >= .5 else 0.)) + assert result.status == "complete" + assert all(row[0] >= .5 for row, w in zip(result.samples, result.weights) + if w > 0) + assert sum(result.weights) == pytest.approx(1.) + + +def test_conditional_failures_are_not_posterior_samples() -> None: + """Map errors, zero searched support and budget exhaustion remain + distinct.""" + prior, identity, condition = _problem() + config = SamplerConfig(particles=20, temperatures=3, moves=1) + with pytest.raises(ValueError, match="coordinate map"): + sample_batch(prior, identity, lambda _: 0., config, 0) + with pytest.raises(ValueError, match="Base weight"): + sample_batch(prior, + identity, + lambda _: 0., + config, + 0, + condition=lambda _: PriorPoint((1., .5, 0.), math.inf)) + with pytest.raises(ValueError, match="joint values"): + sample_batch(prior, + identity, + lambda _: 0., + config, + 0, + condition=lambda _: PriorPoint((math.nan, .5, 0.), 0.)) + result = sample_batch(prior, + identity, + lambda _: 0., + config, + 0, + condition=lambda _: PriorPoint( + (1., .5, 0.), -math.inf)) + assert result.status == "no_particle_support" and not result.samples + result = sample_batch(prior, + identity, + lambda _: 0., + replace(config, max_evaluations=2), + 0, + condition=condition) + assert result.status == "budget_exhausted" and not result.samples + result = sample_batch(prior, + identity, + lambda _: 0., + config, + 0, + condition=condition) + assert result == sample_batch(prior, + identity, + lambda _: 0., + config, + 0, + condition=condition) diff --git a/tests/code_sim_learning/test_inference_conditioning.py b/tests/code_sim_learning/test_inference_conditioning.py new file mode 100644 index 000000000..2fe3b4325 --- /dev/null +++ b/tests/code_sim_learning/test_inference_conditioning.py @@ -0,0 +1,112 @@ +"""Exact-observation references expose rejection and projection bias.""" +import math +from dataclasses import replace + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_conditioning import \ + AffineConditioning, UnsupportedConditioning +from predicators.code_sim_learning.inference_data import content_digest +from predicators.code_sim_learning.inference_sampling import BoxPrior + + +def _product_chart() -> AffineConditioning: + return AffineConditioning( + BoxPrior(("theta", "start", "unused"), + ((1., 2.), (0., 1.), (-1., 1.))), ("start", ), (.5, ), + content_digest(b"y = theta * start")) + + +def test_exact_output_rejection_and_correct_conditional_reference() -> None: + """Conditioning y=theta*start informs theta through a 1/theta factor. + + Independent continuous draws miss the equality. Merely projecting + start=y/theta keeps theta uniform and gives the wrong posterior. + Midpoint quadrature over the same chart recovers the analytic + answer. + """ + rng = np.random.default_rng(51) + prior_draws = rng.uniform((1, 0), (2, 1), size=(1000, 2)) + assert not np.any(prior_draws[:, 0] * prior_draws[:, 1] == .5) + chart = _product_chart() + theta = 1 + (np.arange(1000) + .5) / 1000 + points = [ + chart.lift(np.array([t, .25]), np.array([[t]]), np.array([0.])) + for t in theta + ] + weights = np.exp([point.log_base_weight for point in points]) + assert np.mean(theta) == pytest.approx(1.5) + assert np.average(theta, weights=weights) == pytest.approx(1 / math.log(2), + abs=1e-7) + np.testing.assert_allclose([p.joint[1] for p in points], .5 / theta) + assert all(p.joint[2] == .25 for p in points) + assert all(p.max_constraint_residual <= p.numerical_residual_bound + for p in points) + + +def test_initial_coordinate_conditioning_and_joint_order() -> None: + """Exactly observed initial coordinates leave independent uniforms + alone.""" + prior = BoxPrior(("x", "v", "z"), ((-2, 2), (-3, 3), (0, 10))) + chart = AffineConditioning(prior, ("z", "x"), (6., .2), + content_digest(b"y = (z,x)")) + assert chart.free_names == ("v", ) + assert chart.free_bounds == ((-3., 3.), ) + point = chart.lift(np.array([1.]), np.eye(2), np.zeros(2)) + assert point.joint == (.2, 1., 6.) + assert point.log_base_weight == pytest.approx(-math.log(40)) + fully = AffineConditioning(prior, prior.names, (.2, 1., 6.), + content_digest(b"identity")) + assert fully.lift(np.array([]), np.eye(3), + np.zeros(3)).joint == point.joint + + +def test_prior_support_and_singular_chart_are_distinct() -> None: + """Point rejection does not establish global inconsistency.""" + chart = _product_chart() + outside = chart.lift(np.array([1., 0.]), np.array([[.1]]), np.zeros(1)) + assert outside.joint[1] == 5 + assert outside.log_base_weight == -math.inf + outside_free = chart.lift(np.array([3., 0.]), np.ones((1, 1)), np.zeros(1)) + assert outside_free.log_base_weight == -math.inf + with pytest.raises(UnsupportedConditioning, match="Singular"): + chart.lift(np.array([1., 0.]), np.zeros((1, 1)), np.zeros(1)) + with pytest.raises(ValueError, match="finite inputs"): + chart.lift(np.array([1., 0.]), np.array([[np.nan]]), np.zeros(1)) + with pytest.raises(ValueError, match="shape"): + chart.lift(np.array([1., 0.]), np.eye(2), np.zeros(1)) + + +def test_change_of_output_units_preserves_normalized_density() -> None: + """Scaling exact observations changes evidence, not normalized weights.""" + chart = _product_chart() + scaled = replace(chart, + observed=(5., ), + equation_identity=content_digest(b"y = 10*theta*start")) + assert scaled.digest != chart.digest + for theta in (1., 1.25, 2.): + point = chart.lift(np.array([theta, 0.]), np.array([[theta]]), + np.zeros(1)) + other = scaled.lift(np.array([theta, 0.]), np.array([[10 * theta]]), + np.zeros(1)) + np.testing.assert_allclose(point.joint, other.joint) + assert other.log_base_weight - point.log_base_weight == pytest.approx( + -math.log(10)) + + +def test_affine_offset_and_validation() -> None: + """Nontrivial matrices retain the full determinant, with no projection.""" + prior = BoxPrior(("a", "b", "c"), ((-10, 10), ) * 3) + chart = AffineConditioning(prior, ("a", "b"), (4., 5.), + content_digest(b"coupled affine")) + point = chart.lift(np.array([2.]), np.array([[2., 1.], [0., -3.]]), + np.array([1., 2.])) + assert point.joint == (2., -1., 2.) + assert point.log_base_weight == pytest.approx(-math.log(6 * 400)) + with pytest.raises(ValueError, match="distinct"): + replace(chart, eliminated=("a", "a")) + with pytest.raises(ValueError, match="finite observation"): + replace(chart, observed=(math.inf, 0.)) + with pytest.raises(ValueError, match="SHA256"): + replace(chart, equation_identity="description") diff --git a/tests/code_sim_learning/test_inference_data.py b/tests/code_sim_learning/test_inference_data.py new file mode 100644 index 000000000..58261a547 --- /dev/null +++ b/tests/code_sim_learning/test_inference_data.py @@ -0,0 +1,149 @@ +"""Offline likelihood checks against the real observation injector.""" +import dataclasses +import math + +import numpy as np +import pytest +from scipy.stats import norm + +from predicators.code_sim_learning.inference_data import EpisodeData, \ + InferenceData, InferenceIdentity, Observation, SensorFeature, \ + SensorModel, content_digest +from predicators.observation_noise import ObservationNoise, step_rng +from predicators.structs import Object, State, Type + + +def _state() -> State: + obj = Object( + "box", + Type("box", ["x", "yaw", "pressure", "attached"], + sensor_features=["pressure"])) + robot = Object("arm", Type("robot", ["x"])) + return State({ + obj: np.array([0.2, math.pi, 1.0, 1.0]), + robot: np.array([0.0]) + }) + + +def test_injector_likelihood_and_exact_constraints() -> None: + """Raw angles/readings match scipy's density; exact errors have no + floor.""" + truth = _state() + noise = ObservationNoise(position=0.03, orientation=0.2, scalar=0.1) + sensor = SensorModel.from_state(truth, noise) + predicted = dict(Observation.from_state(0, truth).values) + standardized = [] + for step in range(200): + measured = Observation.from_state( + step, noise.perturb(truth, step_rng(5, 0, 0, step))) + values = dict(measured.values) + z = [(values[f.key] - predicted[f.key]) / f.sigma + for f in sensor.features if f.sigma] + standardized.extend(z) + expected = sum( + norm.logpdf(values[f.key], loc=predicted[f.key], scale=f.sigma) + for f in sensor.features if f.sigma) + assert sensor.log_likelihood(measured, predicted) == \ + pytest.approx(expected, abs=1e-12) + assert abs(np.mean(standardized)) < 0.12 + assert abs(np.std(standardized) - 1) < 0.12 + measured = Observation.from_state(0, truth) + shifted = dict(predicted) + shifted[("box", "box", "yaw")] -= 2 * math.pi + assert sensor.log_likelihood(measured, shifted) < -450 + for key in (("box", "box", "attached"), ("arm", "robot", "x")): + wrong = dict(predicted) + wrong[key] = np.nextafter(wrong[key], math.inf) + assert sensor.log_likelihood(measured, wrong) == -math.inf + + +def test_missing_conditioned_and_invalid_predictions() -> None: + """Only explicitly conditioned fields can bypass an exact constraint.""" + truth = _state() + key = ("arm", "robot", "x") + sensor = SensorModel.from_state(truth, ObservationNoise(position=.1), + [key]) + measured = Observation.from_state(0, truth) + prediction = dict(measured.values) + prediction.pop(key) + assert math.isfinite(sensor.log_likelihood(measured, prediction)) + sparse = Observation(0, ((("box", "box", "x"), .25), )) + assert sensor.log_likelihood(sparse, prediction) == pytest.approx( + norm.logpdf(.25, .2, .1)) + prediction.pop(("box", "box", "x")) + with pytest.raises(ValueError, match="Missing or nonfinite"): + sensor.log_likelihood(sparse, prediction) + with pytest.raises(ValueError, match="Unknown observed"): + sensor.log_likelihood(Observation(0, ((("new", "box", "x"), 0), )), {}) + with pytest.raises(ValueError, match="Only exact"): + SensorModel.from_state(truth, ObservationNoise(position=.1), + [("box", "box", "x")]) + with pytest.raises(ValueError, match="Unknown conditioned"): + SensorModel.from_state(truth, ObservationNoise(), [("none", "x", "x")]) + with pytest.raises(ValueError, match="declared"): + SensorModel.from_state(truth, ObservationNoise(declared=False)) + with pytest.raises(ValueError, match="Invalid declared"): + SensorModel.from_state(truth, ObservationNoise(position=-1)) + with pytest.raises(ValueError, match="finite"): + Observation(0, ((("box", "box", "x"), math.nan), )) + with pytest.raises(ValueError, match="Duplicate sensor"): + SensorModel((SensorFeature(key, 0), SensorFeature(key, 0))) + + +def test_ledger_identity_and_observation_ownership() -> None: + """Duplicate observe calls cannot tighten confidence or mutate evidence.""" + state = _state() + obs = Observation.from_state(0, state) + ledger = InferenceData((EpisodeData("run/reset0", (), (obs, obs)), )) + original = ledger.digest + once = InferenceData((EpisodeData("run/reset0", (), (obs, )), )) + assert once == ledger + state[next(iter(state))][0] += 2 + assert ledger.digest == original + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(obs, "step", 1) + with pytest.raises(ValueError, match="Conflicting"): + EpisodeData("run/reset0", (), (obs, Observation.from_state(0, state))) + with pytest.raises(ValueError, match="exceeds"): + EpisodeData("run/reset0", (), (dataclasses.replace(obs, step=1), )) + with pytest.raises(ValueError, match="Duplicate reset"): + InferenceData(ledger.episodes * 2) + changed = InferenceData((EpisodeData("run/reset0", ((0.1, ), ), + (obs, )), )) + assert changed.digest != original + masked = dataclasses.replace(obs, values=obs.values[:-1]) + assert InferenceData((EpisodeData("run/reset0", (), (masked,)),)).digest \ + != original + identity = InferenceIdentity(original, content_digest(b"sensor"), + content_digest(b"source and parameters"), + content_digest(b"prior"), + content_digest(b"runtime and layout")) + for field in dataclasses.fields(identity): + replacement = dataclasses.replace( + identity, **{field.name: content_digest(b"different")}) + assert replacement.digest != identity.digest + with pytest.raises(ValueError, match="SHA256"): + dataclasses.replace(identity, program="unversioned") + + +def test_batch_alignment_and_cached_reads() -> None: + """A second read has no likelihood factor; only another step can add + one.""" + key = ("b", "box", "x") + sensor = SensorModel((SensorFeature(key, .1), )) + observation = Observation(0, ((key, .2), )) + once = InferenceData((EpisodeData("reset0", (), (observation, )), )) + repeated = InferenceData((EpisodeData("reset0", (), + (observation, observation)), )) + predicted = {"reset0": [{key: .15}]} + assert once.log_likelihood(sensor, predicted) == \ + repeated.log_likelihood(sensor, predicted) + stepped = InferenceData((EpisodeData( + "reset0", ((0., ), ), + (observation, dataclasses.replace(observation, step=1))), )) + assert stepped.log_likelihood(sensor, {"reset0": predicted["reset0"] * 2}) \ + == pytest.approx(2 * once.log_likelihood(sensor, predicted)) + with pytest.raises(ValueError, match="length"): + once.log_likelihood(sensor, {"reset0": predicted["reset0"] * 2}) + with pytest.raises(ValueError, match="episodes"): + once.log_likelihood(sensor, {"reset1": predicted["reset0"]}) diff --git a/tests/code_sim_learning/test_inference_discrepancy.py b/tests/code_sim_learning/test_inference_discrepancy.py new file mode 100644 index 000000000..0f5b8f061 --- /dev/null +++ b/tests/code_sim_learning/test_inference_discrepancy.py @@ -0,0 +1,168 @@ +"""Independent integration references for exact-speed transition +conditioning.""" +import math + +import numpy as np +import pytest +from scipy.integrate import quad +from scipy.stats import ncx2 + +from predicators.code_sim_learning.inference_conditioning import \ + ConditioningNumericalError, RestOrGaussianVelocityPrior, \ + UnsupportedConditioning +from predicators.code_sim_learning.inference_discrepancy import \ + VelocityDiscrepancy + + +@pytest.mark.parametrize("mean", [0., 1e-6, .2, 2.]) +def test_radial_density_integrates_gaussian_shells(mean: float) -> None: + """The retained factor equals an independent spherical Gaussian + integral.""" + sigma = .3 + model = VelocityDiscrepancy(.2, sigma) + for speed in (.05, .4, 1.8): + result = model.condition_on_speed((0., 0., mean), speed, (.3, .7)) + + def shell(cosine: float, radius: float = speed) -> float: + exponent = -(radius**2 + mean**2 - 2 * radius * mean * cosine) / \ + (2 * sigma**2) + return (2 * math.pi * radius**2 * math.exp(exponent) / + (math.sqrt(2 * math.pi) * sigma)**3) + + expected = .8 * quad(shell, -1., 1., epsabs=1e-40, epsrel=1e-10)[0] + assert math.exp(result.log_observation_factor) == \ + pytest.approx(expected, rel=1e-9, abs=1e-40) + + def radial(speed: float) -> float: + return math.exp( + model.condition_on_speed((0., 0., mean), speed, + (.3, .7)).log_observation_factor) + + upper = mean + 12 * sigma + assert quad(radial, 0., upper, epsabs=1e-10)[0] == pytest.approx(.8) + second_moment = quad(lambda r: r * r * radial(r), 0., upper, + epsabs=1e-10)[0] + assert second_moment == pytest.approx(.8 * (mean**2 + 3 * sigma**2)) + + +@pytest.mark.parametrize("concentration", [0., 1e-5, .1, 1., 50., 1000.]) +def test_direction_quantiles_match_conditional_sphere( + concentration: float) -> None: + """The eliminated speed retains directional bias rather than zeroing it.""" + model = VelocityDiscrepancy(0., 1.) + # With speed=1 and sigma=1, the predicted magnitude is concentration. + for uniform in (0., .001, .2, .5, .9, .999, 1.): + result = model.condition_on_speed((0., 0., concentration), 1., + (uniform, .31)) + cosine = result.velocity[2] + assert result.speed_residual < 1e-14 + if concentration == 0: + cdf = (cosine + 1) / 2 + elif concentration < .5: + cdf = math.expm1(concentration * (cosine + 1)) / \ + math.expm1(2 * concentration) + else: + cdf = (math.exp(concentration * (cosine - 1)) - + math.exp(-2 * concentration)) / \ + -math.expm1(-2 * concentration) + assert cdf == pytest.approx(uniform, abs=2e-12) + + +def test_rotation_and_large_concentration_do_not_change_radial_evidence( +) -> None: + """Stable formulas avoid sinh overflow and orient the law in world + space.""" + model = VelocityDiscrepancy(.1, 1e-4) + predicted = (3., 4., 0.) + result = model.condition_on_speed(predicted, 5., (.5, .3)) + axis_result = model.condition_on_speed((0., 0., 5.), 5., (.5, .3)) + assert result.log_observation_factor == axis_result.log_observation_factor + np.testing.assert_allclose(np.dot(result.velocity, predicted) / 5., + axis_result.velocity[2], + atol=1e-14) + assert math.isfinite(result.log_observation_factor) + assert result.speed_residual < 1e-14 + + +def test_central_case_and_rest_mass_match_original_velocity_prior() -> None: + """The extension retains the existing rest atom and central reference.""" + model = VelocityDiscrepancy(.3, .2) + prior = RestOrGaussianVelocityPrior(.3, .2) + for speed, coords in ((0., ()), (.01, (.2, .7)), (.5, (.4, .3))): + result = model.condition_on_speed((0., 0., 0.), speed, coords) + reference = prior.condition_on_speed(speed, coords) + np.testing.assert_allclose(result.velocity, + reference.velocity, + atol=1e-15) + assert result.log_observation_factor == pytest.approx( + reference.log_observation_factor) + assert result.free_dimensions == reference.free_dimensions + rest = model.condition_on_speed((4., 2., 1.), 0.) + assert rest.velocity == (0., 0., 0.) + assert rest.log_observation_factor == math.log(.3) + assert VelocityDiscrepancy(1., .2).condition_on_speed( + (0., 0., 1.), 1., (.2, .3)).log_observation_factor == -math.inf + with pytest.raises(UnsupportedConditioning): + VelocityDiscrepancy(0., .2).condition_on_speed((1., 0., 0.), 0.) + + +def test_invalid_laws_and_numerical_failure_are_explicit() -> None: + """No variance floor or clipping rescues a malformed correction model.""" + for rho, sigma in ((-.1, .1), (1.1, .1), (.1, 0.), (.1, float("nan"))): + with pytest.raises(ValueError): + VelocityDiscrepancy(rho, sigma) + model = VelocityDiscrepancy(.1, .2) + with pytest.raises(ValueError): + model.condition_on_speed((float("nan"), 0., 0.), 1., (.5, .5)) + with pytest.raises(ValueError): + model.condition_on_speed((0., 0., 0.), 1., (1.1, .5)) + with pytest.raises(ValueError): + model.condition_on_speed((0., 0., 0.), 0., (.5, .5)) + with pytest.raises(ConditioningNumericalError): + VelocityDiscrepancy(.1, 1e-300).condition_on_speed((1., 0., 0.), 1., + (.5, .5)) + assert model.digest != VelocityDiscrepancy(.2, .2).digest + assert model.digest != VelocityDiscrepancy(.1, .3).digest + + +@pytest.mark.parametrize("rest_probability", [0., .3, 1.]) +@pytest.mark.parametrize("predicted", [(0., 0., 0.), (.4, -.3, .8)]) +def test_unconditional_draws_match_mixture_moments_and_speed_law( + rest_probability, predicted): + """Generated transitions retain rest mass and the noncentral speed law.""" + sigma = .2 + model = VelocityDiscrepancy(rest_probability, sigma) + rng = np.random.default_rng(82) + draws = np.array([model.sample(predicted, rng) for _ in range(24000)]) + mean = np.array(predicted) + expected_mean = (1 - rest_probability) * mean + covariance = (1 - rest_probability) * sigma**2 * np.eye(3) + \ + rest_probability * (1 - rest_probability) * np.outer(mean, mean) + assert draws.mean(axis=0) == pytest.approx(expected_mean, abs=.012) + assert np.cov(draws.T) == pytest.approx(covariance, abs=.012) + speeds = np.linalg.norm(draws, axis=1) + assert np.mean(speeds == 0.) == pytest.approx(rest_probability, abs=.012) + for speed in (.2, .6, 1.2): + expected = rest_probability + (1 - rest_probability) * \ + ncx2.cdf((speed / sigma)**2, 3, float(mean @ mean) / sigma**2) + assert np.mean(speeds <= speed) == pytest.approx(expected, abs=.012) + + +def test_transition_draw_preserves_existing_random_stream(): + """Extracting the existing native branch leaves generated paths intact.""" + model = VelocityDiscrepancy(.3, .2) + predicted = (.4, -.3, .8) + old_rng = np.random.default_rng(23) + new_rng = np.random.default_rng(23) + for _ in range(100): + old = tuple(float(v) for v in old_rng.normal(predicted, model.sigma)) \ + if old_rng.random() >= model.rest_probability else (0., 0., 0.) + assert model.sample(predicted, new_rng) == old + assert old_rng.random() == new_rng.random() + for invalid in ((math.nan, 0., 0.), (math.inf, 0., 0.), (0., 0.)): + with pytest.raises(ValueError, match="three finite"): + model.sample(invalid, new_rng) + with np.errstate(over="ignore"), \ + pytest.raises(ConditioningNumericalError, match="overflow"): + VelocityDiscrepancy(0., 1e308).sample((1.7e308, 1.7e308, 1.7e308), + np.random.default_rng(4)) diff --git a/tests/code_sim_learning/test_inference_feasibility.py b/tests/code_sim_learning/test_inference_feasibility.py new file mode 100644 index 000000000..3f9c7888d --- /dev/null +++ b/tests/code_sim_learning/test_inference_feasibility.py @@ -0,0 +1,74 @@ +"""Whole-candidate rejection preserves the declared conditional measure.""" +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_data import content_digest +from predicators.code_sim_learning.inference_feasibility import draw_feasible + +DIGEST = content_digest(b"fixed base and triangular support reference") + + +def test_joint_constraint_induces_the_expected_dependence(): + """Uniform points in a triangle have mean 1/3 and covariance -1/36.""" + result = draw_feasible(DIGEST, + DIGEST, + lambda rng: tuple(rng.uniform(size=2)), + lambda p: sum(p) < 1, + count=4096, + max_draws=12000, + seed=0) + assert result.status == "complete" + samples = np.asarray(result.samples) + np.testing.assert_allclose(samples.mean(axis=0), 1 / 3, atol=.012) + assert np.cov(samples.T)[0, 1] == pytest.approx(-1 / 36, abs=.004) + assert np.all(samples.sum(axis=1) < 1) + + +def test_global_rejection_reweights_mixture_cases(): + """Equal base case weights become 1:4 after different feasibility rates.""" + result = draw_feasible(DIGEST, + DIGEST, + lambda rng: + (int(rng.integers(2)), float(rng.random())), + lambda p: p[1] < (.2 if p[0] == 0 else .8), + count=4096, + max_draws=12000, + seed=1) + assert result.status == "complete" + assert np.mean([p[0] for p in result.samples]) == pytest.approx(.8, + abs=.02) + + +def test_exhaustion_and_errors_do_not_manufacture_feasible_samples(): + """No accepted sample is a finite-search result, not proof of + impossibility.""" + result = draw_feasible(DIGEST, + DIGEST, + lambda rng: rng.random(), + lambda _: False, + count=2, + max_draws=4, + seed=0) + assert result.status == "budget_exhausted" + assert result.draws == 4 and result.accepted == 0 and not result.samples + with pytest.raises(TypeError, match="boolean"): + draw_feasible(DIGEST, + DIGEST, + lambda rng: rng.random(), + lambda _: 1., + count=1, + max_draws=2, + seed=0) + + def broken(_): + """A setup failure must propagate.""" + raise RuntimeError("scene initialization failed") + + with pytest.raises(RuntimeError, match="scene initialization"): + draw_feasible(DIGEST, + DIGEST, + lambda rng: rng.random(), + broken, + count=1, + max_draws=2, + seed=0) diff --git a/tests/code_sim_learning/test_inference_feasible_batch.py b/tests/code_sim_learning/test_inference_feasible_batch.py new file mode 100644 index 000000000..1c834b452 --- /dev/null +++ b/tests/code_sim_learning/test_inference_feasible_batch.py @@ -0,0 +1,181 @@ +"""Geometric conditioning must preserve the declared parameter prior law.""" +import math +from dataclasses import replace +from typing import Callable, Tuple + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_conditioning import \ + AffineConditioning +from predicators.code_sim_learning.inference_data import InferenceIdentity, \ + content_digest +from predicators.code_sim_learning.inference_feasibility import \ + FeasibleConditioning +from predicators.code_sim_learning.inference_sampling import BoxPrior, \ + ConditionedPrior, PriorPoint, SamplerConfig, sample_batch + +DIGEST = content_digest(b"feasible batch analytic reference") + + +def _reference( + observed: float = .5 +) -> Tuple[FeasibleConditioning, Callable[[np.ndarray], PriorPoint]]: + """The state fits inside a theta-wide region; observe theta*x exactly.""" + original = BoxPrior(("theta", "x", "unused"), + ((1., 4.), (0., 4.), (-1., 1.))) + chart = AffineConditioning(original, ("x", ), (observed, ), DIGEST) + base = ConditionedPrior(original.names, original.digest, chart.digest, + BoxPrior(chart.free_names, chart.free_bounds)) + + def condition(free: np.ndarray) -> PriorPoint: + point = chart.lift(free, np.array([[free[0]]]), np.zeros(1)) + return PriorPoint(point.joint, point.log_base_weight) + + return FeasibleConditioning(base, DIGEST, "global_joint"), condition + + +def test_support_normalization_changes_the_parameter_posterior() -> None: + """Ignoring Z(theta) changes 1/theta**2 into 1/theta, a different law.""" + global_law, condition = _reference() + state_law = replace(global_law, + normalization="conditional_state", + normalizer_identity=DIGEST) + grid = 1 + (np.arange(2048) + .5) * 3 / 2048 + global_weights = [] + state_weights = [] + for theta in grid: + free = np.array([theta, .1]) + whole = global_law.lift(free, condition, lambda x: x[1] <= x[0]) + state = state_law.lift(free, + condition, + lambda x: x[1] <= x[0], + log_normalizer=lambda x: math.log(x[0] / 4)) + assert whole.joint == state.joint + global_weights.append(math.exp(whole.log_weight)) + state_weights.append(math.exp(state.log_weight)) + assert np.average(grid, + weights=global_weights) == pytest.approx(3 / math.log(4), + abs=1e-6) + assert np.average(grid, weights=state_weights) == pytest.approx( + math.log(4) / .75, abs=1e-6) + assert global_law.prior.original_prior != state_law.prior.original_prior + new_data, _ = _reference(4.) + assert new_data.prior.original_prior == global_law.prior.original_prior + assert new_data.prior.digest != global_law.prior.digest + + +def test_exact_conditioning_and_support_reach_the_same_sampler() -> None: + """Noisy evidence is applied once after the supported base correction.""" + law, condition = _reference(4.) + law = replace(law, + normalization="conditional_state", + normalizer_identity=DIGEST) + + def lift(free: np.ndarray) -> PriorPoint: + return law.lift(free, + condition, + lambda x: x[1] <= x[0], + log_normalizer=lambda x: math.log(x[0] / 4)) + + def likelihood(joint: np.ndarray) -> float: + return -.5 * ((joint[1] - 1.6) / .3)**2 + + identity = InferenceIdentity(DIGEST, DIGEST, DIGEST, law.prior.digest, + DIGEST) + result = sample_batch(law.prior, + identity, + likelihood, + SamplerConfig(particles=2048, + temperatures=12, + moves=3, + max_evaluations=100000), + 19, + condition=lift) + assert result.status == "complete" + samples = np.asarray(result.samples) + positive = np.asarray(result.weights) > 0 + assert np.all(samples[positive, 1] <= samples[positive, 0]) + np.testing.assert_allclose(samples[:, 0] * samples[:, 1], 4., atol=1e-15) + grid = 2 + (np.arange(10000) + .5) / 5000 + weights = np.exp(-.5 * ((4 / grid - 1.6) / .3)**2) / grid**2 + assert np.average(samples[:, 0], weights=result.weights) == \ + pytest.approx(np.average(grid, weights=weights), abs=.04) + assert abs(np.average(samples[:, 2], weights=result.weights)) < .06 + + +def test_missing_normalizers_and_failed_search_are_not_posteriors() -> None: + """Unavailable normalization raises; finite support search stays + distinct.""" + law, condition = _reference() + with pytest.raises(ValueError, match="normalizer identity"): + replace(law, normalization="conditional_state") + state_law = replace(law, + normalization="conditional_state", + normalizer_identity=DIGEST) + with pytest.raises(ValueError, match="log normalizer"): + state_law.lift(np.array([2., 0.]), condition, lambda _: True) + with pytest.raises(ValueError, match="log normalizer"): + law.lift(np.array([2., 0.]), + condition, + lambda _: True, + log_normalizer=lambda _: 0.) + for bad in (-math.inf, math.inf, math.nan, .1): + + def invalid_normalizer(_: np.ndarray, value: float = bad) -> float: + """Supply an invalid probability without a loop-variable + closure.""" + return value + + with pytest.raises(ValueError, match="support probability"): + state_law.lift(np.array([2., 0.]), + condition, + lambda _: True, + log_normalizer=invalid_normalizer) + prior = law.prior + identity = InferenceIdentity(DIGEST, DIGEST, DIGEST, prior.digest, DIGEST) + result = sample_batch( + prior, + identity, + lambda _: 0., + SamplerConfig(particles=8, temperatures=2, moves=1), + 0, + condition=lambda x: law.lift(x, condition, lambda _: False)) + assert result.status == "no_particle_support" + assert not result.samples + + +def test_callback_errors_and_mutation_do_not_change_candidates() -> None: + """Support checks use owned arrays and propagate setup failures.""" + law, condition = _reference() + free = np.array([2., 0.]) + + def mutate(joint: np.ndarray) -> bool: + joint[:] = -99 + return True + + point = law.lift(free, condition, mutate) + assert point.joint == (2., .25, 0.) + np.testing.assert_array_equal(free, [2., 0.]) + + def broken(_: np.ndarray) -> bool: + raise RuntimeError("scene failed to initialize") + + with pytest.raises(RuntimeError, match="initialize"): + law.lift(free, condition, broken) + with pytest.raises(TypeError, match="boolean"): + law.lift(free, condition, + lambda _: 1.) # type: ignore[arg-type,return-value] + for invalid in (PriorPoint((math.nan, .25, 0.), + 0.), PriorPoint((2., .25, 0.), math.inf)): + + def invalid_point(_: np.ndarray, + value: PriorPoint = invalid) -> PriorPoint: + """Supply a malformed base point.""" + return value + + with pytest.raises(ValueError, match="Base map"): + law.lift(free, invalid_point, broken) + rejected = law.lift(free, lambda _: PriorPoint((2., .25, 0.), -math.inf), + broken) + assert rejected.log_weight == -math.inf diff --git a/tests/code_sim_learning/test_inference_forecast.py b/tests/code_sim_learning/test_inference_forecast.py new file mode 100644 index 000000000..ae9b3434a --- /dev/null +++ b/tests/code_sim_learning/test_inference_forecast.py @@ -0,0 +1,159 @@ +"""Future draws match conditional laws without receiving future readings.""" +import math + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_conditioning import \ + UnsupportedConditioning +from predicators.code_sim_learning.inference_data import Observation, \ + SensorFeature, SensorModel +from predicators.code_sim_learning.inference_observation import \ + CheckedReadoutFactor, EulerOutputFactor, OutputObservationModel, \ + ScalarOutputFactor +from predicators.code_sim_learning.inference_orientation import \ + QuaternionOutputError +from predicators.code_sim_learning.inference_output_error import \ + GaussianOutputError +from predicators.code_sim_learning.inference_readout import ExactReadout + +POSITION = ("box", "body", "x") +DISPLAY = ("box", "body", "display") +EVENT = ("box", "body", "attached") +ANGLES = (("robot", "robot", "roll"), ("robot", "robot", "tilt"), + ("robot", "robot", "wrist")) + + +@pytest.mark.parametrize("persistence", [0., .8]) +@pytest.mark.parametrize("sensor_sigma", [0., .07]) +def test_forecast_matches_dense_conditional_gaussian(persistence, + sensor_sigma): + """Both cross-time covariance and means match a Schur-complement + reference.""" + process = GaussianOutputError(persistence, .12, .2) + model = OutputObservationModel(SensorModel( + (SensorFeature(POSITION, sensor_sigma), )), + scalars=(ScalarOutputFactor( + POSITION, process), )) + native = np.array([.0, .1, .2, .3, .4]) + predictions = tuple( + Observation(i, ((POSITION, v), )) for i, v in enumerate(native)) + readings = np.array([.3, -.1]) + prefix = tuple( + Observation(i, ((POSITION, v), )) for i, v in enumerate(readings)) + transition = np.array( + [[persistence**(t - k) if k <= t else 0. for k in range(5)] + for t in range(5)]) + covariance = transition @ np.diag([.2**2] + [.12**2] * 4) @ transition.T + covariance += sensor_sigma**2 * np.eye(5) + cross = covariance[2:, :2] + mean = native[2:] + cross @ np.linalg.solve(covariance[:2, :2], + readings - native[:2]) + conditional_cov = covariance[2:, 2:] - \ + cross @ np.linalg.solve(covariance[:2, :2], cross.T) + rng = np.random.default_rng(52) + draws = np.array([[ + dict(o.values)[POSITION] + for o in model.sample_future(predictions, prefix, rng) + ] for _ in range(6000)]) + assert draws.mean(axis=0) == pytest.approx(mean, abs=.012) + assert np.cov(draws.T) == pytest.approx(conditional_cov, abs=.004) + first = model.sample_future(predictions, prefix, np.random.default_rng(3)) + repeated = model.sample_future(predictions, prefix, + np.random.default_rng(3)) + assert first == repeated + assert [o.step for o in model.sample_future(predictions, prefix, rng)] == \ + [2, 3, 4] + + +def test_unconditioned_draw_retains_original_initial_error(): + """No prefix uses b0's original variance without an extra transition.""" + model = OutputObservationModel(SensorModel((SensorFeature(POSITION, + 0.), )), + scalars=(ScalarOutputFactor( + POSITION, + GaussianOutputError(.8, .01, .3)), )) + predictions = (Observation(0, ((POSITION, .4), )), ) + rng = np.random.default_rng(71) + values = np.array([ + dict(model.sample_future(predictions, (), rng)[0].values)[POSITION] + for _ in range(6000) + ]) + assert values.mean() == pytest.approx(.4, abs=.015) + assert values.var() == pytest.approx(.09, abs=.005) + + +def test_forecast_preserves_events_and_derived_observation_relationship(): + """A checked display follows its noisy-error source, not native state.""" + sensor = SensorModel( + tuple(SensorFeature(k, 0.) for k in (POSITION, DISPLAY, EVENT))) + model = OutputObservationModel(sensor, + scalars=(ScalarOutputFactor( + POSITION, + GaussianOutputError(.8, .1, .2)), ), + readouts=(CheckedReadoutFactor( + ExactReadout(POSITION, DISPLAY, + sensor.digest, "a" * 64), + lambda value: 2 * value + 1), )) + predictions = (Observation(0, ((POSITION, 0.), (EVENT, 0.))), + Observation(1, ((POSITION, 0.), (EVENT, 1.)))) + prefix = (Observation(0, ((POSITION, .3), (DISPLAY, 1.6), (EVENT, 0.))), ) + draw = model.sample_future(predictions, prefix, np.random.default_rng(2)) + values = dict(draw[0].values) + assert values[EVENT] == 1. + assert values[DISPLAY] == 2 * values[POSITION] + 1 + assert values[POSITION] != 0. + assert math.isfinite(model.log_likelihood(predictions, prefix + draw)) + assert dict(predictions[1].values)[POSITION] == 0. + false_prefix = (Observation(0, ((POSITION, .3), (DISPLAY, 1.6), + (EVENT, 1.))), ) + with pytest.raises(UnsupportedConditioning, + match="zero-likelihood prefix"): + model.sample_future(predictions, false_prefix, + np.random.default_rng(2)) + + +def test_euler_forecast_uses_unnormalized_antipodal_readout(): + """Near a pitch pole, raw draws retain pole mass and both yaw branches.""" + model = OutputObservationModel( + SensorModel(tuple(SensorFeature(k, 0.) for k in ANGLES)), + eulers=(EulerOutputFactor(ANGLES, QuaternionOutputError(.03)), )) + prediction = (Observation(0, tuple(zip(ANGLES, (0., math.pi / 2, 0.)))), ) + rng = np.random.default_rng(14) + values = [ + dict(model.sample_future(prediction, (), rng)[0].values) + for _ in range(6000) + ] + poles = [row for row in values if row[ANGLES[1]] == math.pi / 2] + # Normalizing raw quaternions would remove almost all of this pole mass. + assert .4 < len(poles) / len(values) < .6 + assert all(row[ANGLES[0]] == 0. for row in poles) + assert .4 < np.mean([abs(row[ANGLES[2]]) > math.pi for row in poles]) < .6 + for row in values[:8]: + assert math.isfinite( + model.log_likelihood(prediction, + (Observation(0, tuple(row.items())), ))) + changed = OutputObservationModel(model.sensor, + eulers=(EulerOutputFactor( + ANGLES, + QuaternionOutputError(.03, .99)), )) + with pytest.raises(UnsupportedConditioning, match="pole threshold"): + changed.sample_future(prediction, (), rng) + + +def test_forecast_requires_explicit_input_and_complete_prediction(): + """Known inputs are copied; absent inputs and malformed histories fail.""" + sensor = SensorModel( + (SensorFeature(POSITION, 0., + conditioned=True), SensorFeature(EVENT, 0.))) + model = OutputObservationModel(sensor) + prediction = (Observation(0, ((POSITION, .4), (EVENT, 1.))), ) + rng = np.random.default_rng(1) + assert model.sample_future(prediction, (), rng) == prediction + assert not model.sample_future(prediction, prediction, rng) + for incomplete in ((Observation(0, ((EVENT, 1.), )), ), + (Observation(1, prediction[0].values), ), ()): + with pytest.raises(ValueError): + model.sample_future(incomplete, (), rng) + with pytest.raises(ValueError): + model.sample_future(prediction, prediction * 2, rng) diff --git a/tests/code_sim_learning/test_inference_future_likelihood.py b/tests/code_sim_learning/test_inference_future_likelihood.py new file mode 100644 index 000000000..8209307de --- /dev/null +++ b/tests/code_sim_learning/test_inference_future_likelihood.py @@ -0,0 +1,148 @@ +"""Causal future scores match independent conditional probability +references.""" +import math + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_conditioning import \ + UnsupportedConditioning +from predicators.code_sim_learning.inference_data import Observation, \ + SensorFeature, SensorModel +from predicators.code_sim_learning.inference_observation import \ + CheckedReadoutFactor, EulerOutputFactor, OutputObservationModel, \ + ScalarOutputFactor +from predicators.code_sim_learning.inference_orientation import \ + QuaternionOutputError +from predicators.code_sim_learning.inference_output_error import \ + GaussianOutputError +from predicators.code_sim_learning.inference_readout import ExactReadout + +POSITION = ("box", "body", "x") +DISPLAY = ("box", "body", "display") +EVENT = ("box", "body", "attached") +ANGLES = (("robot", "robot", "roll"), ("robot", "robot", "pitch"), + ("robot", "robot", "yaw")) + + +@pytest.mark.parametrize("sensor_sigma", [0., .07]) +@pytest.mark.parametrize("persistence", [-.6, 0., .8, 1.]) +def test_future_density_matches_dense_gaussian(sensor_sigma, persistence): + """A joint suffix density retains both prefix information and + covariance.""" + model = OutputObservationModel( + SensorModel((SensorFeature(POSITION, sensor_sigma), )), + scalars=(ScalarOutputFactor(POSITION, + GaussianOutputError(persistence, .12, + .2)), )) + native = np.array([0., .1, .2, .3, .4]) + values = np.array([.3, -.1, .1, .35, .5]) + predictions = tuple( + Observation(i, ((POSITION, v), )) for i, v in enumerate(native)) + # A missing reading still advances the process by one physical step. + observations = tuple( + Observation(i, () if i == 1 else ((POSITION, v), )) + for i, v in enumerate(values)) + transition = np.array( + [[persistence**(t - k) if k <= t else 0. for k in range(5)] + for t in range(5)]) + covariance = transition @ np.diag([.2**2] + [.12**2] * 4) @ transition.T + covariance += sensor_sigma**2 * np.eye(5) + cross = covariance[2:, :1] + mean = native[2:] + cross[:, 0] * (values[0] - native[0]) / covariance[0, + 0] + conditional = covariance[2:, 2:] - cross @ cross.T / covariance[0, 0] + delta = values[2:] - mean + sign, logdet = np.linalg.slogdet(conditional) + assert sign == 1. + expected = -.5 * (3 * math.log(2 * math.pi) + logdet + + delta @ np.linalg.solve(conditional, delta)) + score = model.log_future_likelihood(predictions, observations[:2], + observations[2:]) + assert score == pytest.approx(expected, abs=1e-12) + assert score == pytest.approx( + model.log_likelihood(predictions, observations) - + model.log_likelihood(predictions[:2], observations[:2]), + abs=1e-12) + assert model.log_future_likelihood(predictions, (), observations) == \ + model.log_likelihood(predictions, observations) + assert model.log_future_likelihood(predictions, observations, ()) == 0. + + +@pytest.mark.parametrize("use_error", [False, True]) +def test_small_future_score_is_not_lost_to_prefix_cancellation(use_error): + """Large finite prefix evidence must not round a future log density to + 0.""" + factors = (ScalarOutputFactor(POSITION, + GaussianOutputError(0., 1., 1.)), ) \ + if use_error else () + model = OutputObservationModel(SensorModel( + (SensorFeature(POSITION, 0. if use_error else 1.), )), + scalars=factors) + predictions = tuple(Observation(i, ((POSITION, 0.), )) for i in range(2)) + prefix = (Observation(0, ((POSITION, 1e12), )), ) + future = (Observation(1, ((POSITION, .3), )), ) + expected = -.5 * .3**2 - .5 * math.log(2 * math.pi) + assert model.log_future_likelihood(predictions, prefix, future) == \ + pytest.approx(expected, abs=1e-14) + assert model.log_likelihood(predictions, prefix + future) - \ + model.log_likelihood(predictions[:1], prefix) == 0. + + +def test_exact_future_failure_is_distinct_from_unsupported_prefix(): + """A rejected future is a prediction failure, not undefined + conditioning.""" + sensor = SensorModel( + tuple(SensorFeature(k, 0.) for k in (POSITION, DISPLAY, EVENT))) + model = OutputObservationModel(sensor, + scalars=(ScalarOutputFactor( + POSITION, + GaussianOutputError(1., 0., 0.)), ), + readouts=(CheckedReadoutFactor( + ExactReadout(POSITION, DISPLAY, + sensor.digest, "a" * 64), + lambda value: 2 * value), )) + predictions = tuple( + Observation(i, ((POSITION, 0.), (EVENT, 0.))) for i in range(2)) + valid = tuple( + Observation(i, ((POSITION, 0.), (DISPLAY, 0.), (EVENT, 0.))) + for i in range(2)) + assert model.log_future_likelihood(predictions, valid[:1], valid[1:]) == 0. + for key in (POSITION, DISPLAY, EVENT): + values = dict(valid[1].values) + values[key] = 1. + if key == POSITION: + values[DISPLAY] = 2. + bad = (Observation(1, tuple(values.items())), ) + assert model.log_future_likelihood(predictions, valid[:1], bad) == \ + -math.inf + bad_prefix = (Observation(0, bad[0].values), ) + with pytest.raises(UnsupportedConditioning, match="zero-likelihood"): + model.log_future_likelihood(predictions, bad_prefix, valid[1:]) + with pytest.raises(ValueError, match="Matching nonempty"): + model.log_future_likelihood(predictions, valid[:1], ()) + with pytest.raises(ValueError, match="each step"): + model.log_future_likelihood(predictions, valid[:1], valid[:1]) + + +def test_coupled_euler_scores_use_only_future_factors(): + """Native pole outputs retain their mixture factor without prefix reuse.""" + model = OutputObservationModel( + SensorModel(tuple(SensorFeature(k, 0.) for k in ANGLES)), + eulers=(EulerOutputFactor(ANGLES, QuaternionOutputError(.1)), )) + values = tuple(zip(ANGLES, (0., math.pi / 2, 4.7))) + predictions = tuple(Observation(i, values) for i in range(3)) + observations = tuple( + Observation(i, tuple(zip(ANGLES, (0., math.pi / 2, 4.5 + .1 * i)))) + for i in range(3)) + # Euler errors are independent across time conditional on native poses. + expected = math.fsum( + model.log_likelihood((Observation(0, predictions[i].values), ), ( + Observation(0, observations[i].values), )) for i in (1, 2)) + assert math.isfinite(expected) + assert model.log_future_likelihood(predictions, observations[:1], + observations[1:]) == expected + with pytest.raises(UnsupportedConditioning, match="Partial Euler"): + model.log_future_likelihood( + predictions, observations[:1], + (Observation(1, ((ANGLES[0], 0.), )), observations[2])) diff --git a/tests/code_sim_learning/test_inference_gaussian_coordinate.py b/tests/code_sim_learning/test_inference_gaussian_coordinate.py new file mode 100644 index 000000000..4e858b29a --- /dev/null +++ b/tests/code_sim_learning/test_inference_gaussian_coordinate.py @@ -0,0 +1,69 @@ +"""Exact Gaussian proposals preserve original-prior and sensor evidence.""" +import math + +import pytest + +from predicators.code_sim_learning.inference_conditioning import \ + ConditioningNumericalError, condition_gaussian_coordinate + + +def _log_density(x: float, mean: float, sigma: float) -> float: + return -.5 * ((x - mean) / sigma)**2 - math.log(sigma) - \ + .5 * math.log(2 * math.pi) + + +def test_proposal_ratio_is_the_original_marginal_density() -> None: + """p0(x) p(y|x) / q(x|y) is constant, including off-center readings.""" + for observed in (-2., .3, 4.): + point = condition_gaussian_coordinate(.1, .7, observed, .2) + for offset in (-2., -.5, 0., .5, 2.): + value = point.mean + offset * point.sigma + ratio = (_log_density(value, .1, .7) + + _log_density(observed, value, .2) - + _log_density(value, point.mean, point.sigma)) + assert ratio == pytest.approx(point.log_observation_factor, + abs=1e-12) + expected = _log_density(observed, .1, math.hypot(.7, .2)) + assert point.log_observation_factor == pytest.approx(expected) + + +def test_exact_coordinate_retains_its_prior_density() -> None: + """An exact reading eliminates the coordinate without a tiny variance.""" + point = condition_gaussian_coordinate(.1, .7, 2., 0.) + assert point.mean == 2. and point.sigma == 0. + assert point.log_observation_factor == pytest.approx( + _log_density(2., .1, .7)) + + +def test_fixed_prior_repeated_fit_and_independent_new_reading() -> None: + """Repeated fitting is identical; new independent evidence adds once.""" + first = condition_gaussian_coordinate(0., 1., 1., .5) + repeated = condition_gaussian_coordinate(0., 1., 1., .5) + assert first == repeated + second = condition_gaussian_coordinate(first.mean, first.sigma, -.5, .5) + expected_variance = 1 / (1 + 4 + 4) + assert second.mean == pytest.approx(expected_variance * (4 - 2)) + assert second.sigma**2 == pytest.approx(expected_variance) + # Joint evidence equals prior*both likelihoods/posterior everywhere. + for value in (-.3, .2, 1.): + joint_ratio = (_log_density(value, 0., 1.) + + _log_density(1., value, .5) + + _log_density(-.5, value, .5) - + _log_density(value, second.mean, second.sigma)) + assert joint_ratio == pytest.approx(first.log_observation_factor + + second.log_observation_factor) + + +def test_scale_and_numerical_failures_remain_explicit() -> None: + """Stable arithmetic handles large sigmas and rejects lost support.""" + point = condition_gaussian_coordinate(0., 1e200, 1e200, 1e200) + assert point.mean == pytest.approx(5e199) + assert point.sigma == pytest.approx(1e200 / math.sqrt(2)) + with pytest.raises(ConditioningNumericalError, match="overflow"): + condition_gaussian_coordinate(0., 1.7e308, 0., 1.7e308) + with pytest.raises(ConditioningNumericalError, match="range"): + condition_gaussian_coordinate(0., 1e-300, 1e300, 1e-300) + with pytest.raises(ValueError, match="finite"): + condition_gaussian_coordinate(0., 1., math.nan, .1) + with pytest.raises(ValueError, match="sigma"): + condition_gaussian_coordinate(0., 0., 0., .1) diff --git a/tests/code_sim_learning/test_inference_joints.py b/tests/code_sim_learning/test_inference_joints.py new file mode 100644 index 000000000..7c03d7a87 --- /dev/null +++ b/tests/code_sim_learning/test_inference_joints.py @@ -0,0 +1,111 @@ +"""Exact joint conditioning retains prior mass and unobserved motion.""" +import math +from dataclasses import replace + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_joints import \ + ConditionedJointPrior, GaussianJointPosition, \ + IncompatibleJointObservation, JointCoordinateBoundary, JointStatePrior + + +def test_conditioning_preserves_unobserved_positions_and_velocities(): + """Initial proprioception constrains positions, not unmeasured motion.""" + prior = JointStatePrior(("arm", "head", "fixed"), + ((-2., 2.), (-1., 1.), None), (.5, .25, 0.)) + observations = {"arm": .7, "fixed": 0.} + conditional = prior.condition_positions(observations) + observations["arm"] = -.2 + assert conditional.coordinates.names == ("head.position", "arm.velocity", + "head.velocity") + bounds = np.asarray(conditional.coordinates.bounds) + rng = np.random.default_rng(0) + values = np.array([ + conditional.lift(point) + for point in rng.uniform(*bounds.T, size=(2048, 3)) + ]) + np.testing.assert_array_equal(values[:, 0, 0], np.full(2048, .7)) + np.testing.assert_array_equal(values[:, 2], np.zeros((2048, 2))) + assert abs(values[:, 1, 0].mean()) < .04 + assert abs(values[:, 0, 1].mean()) < .03 + assert abs(values[:, 1, 1].mean()) < .02 + assert conditional.log_observation_factor == -math.log(4) + assert conditional.prior.digest == prior.digest + assert conditional.digest != prior.condition_positions({"arm": .8}).digest + + +def test_rest_and_fully_conditioned_cases_are_atoms(): + """A deterministic conditional still retains the original reading + density.""" + prior = JointStatePrior(("arm", "fixed"), ((-2., 2.), None), (0., 0.)) + conditional = prior.condition_positions({"arm": 0., "fixed": 0.}) + assert conditional.coordinates is None + assert conditional.lift(np.array([])) == ((0., 0.), (0., 0.)) + assert conditional.log_observation_factor == -math.log(4) + # Uniform[-1,1] gives twice the observation density of Uniform[-2,2]. + narrower = replace(prior, position_priors=((-1., 1.), None)) + ratio = math.exp( + narrower.condition_positions({ + "arm": 0. + }).log_observation_factor - conditional.log_observation_factor) + assert ratio == pytest.approx(2.) + + +def test_exact_positions_are_not_wrapped_clipped_or_softened(): + """Winding and exact support violations stay explicit.""" + prior = JointStatePrior(("wheel", "fixed"), ((-8., 8.), None), (1., 0.)) + conditional = prior.condition_positions({"wheel": 7.}) + assert conditional.lift(np.array([.2]))[0] == (7., .2) + for observations in ({"wheel": 8.00000001}, {"fixed": 1e-15}): + with pytest.raises(IncompatibleJointObservation): + prior.condition_positions(observations) + with pytest.raises(ValueError, match="repeated"): + ConditionedJointPrior(prior, (("wheel", 0.), ("wheel", 1.))) + with pytest.raises(ValueError, match="finite"): + prior.condition_positions({"wheel": math.nan}) + with pytest.raises(ValueError, match="outside"): + conditional.lift(np.array([2.])) + + +def test_missing_joint_motion_cannot_be_invented(): + """The caller must supply complete mechanical and motion assumptions.""" + with pytest.raises(ValueError, match="matching"): + JointStatePrior(("arm", "head"), ((-1., 1.), ), (0., )) + with pytest.raises(ValueError, match="Fixed"): + JointStatePrior(("fixed", ), (None, ), (.1, )) + with pytest.raises(ValueError, match="finite position"): + JointStatePrior(("wheel", ), ((-math.inf, math.inf), ), (1., )) + + +def test_gaussian_reset_law_conditions_without_widening_bounds(): + """Unbounded reset support retains its density and does not wrap angles.""" + normal = GaussianJointPosition(0., math.pi) + prior = JointStatePrior(("shoulder", "head", "fixed"), + (normal, normal, None), (0., .25, 0.)) + measured = -1.5119263197144368 + conditional = prior.condition_positions({"shoulder": measured}) + assert conditional.lift(np.array([.5, .1])) == ((measured, 0.), (0., .1), + (0., 0.)) + expected = -.5 * (measured / math.pi)**2 - math.log( + math.pi * math.sqrt(2 * math.pi)) + assert conditional.log_observation_factor == pytest.approx(expected) + assert prior.condition_positions({ + "shoulder": 7. + }).lift(np.array([.5, 0.]))[0][0] == 7. + for unit in (.001, .1, .5, .9, .999): + position = conditional.lift(np.array([unit, 0.]))[1][0] + cdf = .5 * (1 + math.erf(position / (math.pi * math.sqrt(2)))) + assert cdf == pytest.approx(unit, abs=1e-14) + + +def test_gaussian_coordinate_endpoints_and_numerical_errors_are_distinct(): + """Zero-measure quantile endpoints are not finite Gaussian states.""" + normal = GaussianJointPosition(0., 1.) + for endpoint in (0., 1.): + with pytest.raises(JointCoordinateBoundary): + normal.quantile(endpoint) + with pytest.raises(ArithmeticError): + normal.log_density(1e308) + with pytest.raises(ValueError): + GaussianJointPosition(0., 0.) diff --git a/tests/code_sim_learning/test_inference_observation.py b/tests/code_sim_learning/test_inference_observation.py new file mode 100644 index 000000000..71e5affd9 --- /dev/null +++ b/tests/code_sim_learning/test_inference_observation.py @@ -0,0 +1,127 @@ +"""Full-output composition retains all measurements and exact constraints.""" +import math + +import pytest + +from predicators.code_sim_learning.inference_conditioning import \ + UnsupportedConditioning +from predicators.code_sim_learning.inference_data import Observation, \ + SensorFeature, SensorModel +from predicators.code_sim_learning.inference_observation import \ + CheckedReadoutFactor, EulerOutputFactor, OutputObservationModel, \ + ScalarOutputFactor +from predicators.code_sim_learning.inference_orientation import \ + QuaternionOutputError +from predicators.code_sim_learning.inference_output_error import \ + GaussianOutputError +from predicators.code_sim_learning.inference_readout import ExactReadout + +JOINT = ("robot", "joint", "q") +DISPLAY = ("robot", "robot", "fingers") +EVENT = ("object", "body", "attached") +ANGLES = (("robot", "robot", "roll"), ("robot", "robot", "pitch"), + ("robot", "robot", "yaw")) + + +def test_readout_preserves_source_and_unmodeled_event() -> None: + """A finite source-error density cannot conceal another exact failure.""" + sensor = SensorModel( + tuple(SensorFeature(k, 0.) for k in (JOINT, DISPLAY, EVENT))) + declaration = ExactReadout(JOINT, DISPLAY, sensor.digest, "a" * 64) + model = OutputObservationModel( + sensor, + scalars=(ScalarOutputFactor(JOINT, GaussianOutputError(.8, .1, .2)), ), + readouts=(CheckedReadoutFactor(declaration, lambda x: 2 * x + 1), )) + predictions = tuple( + Observation(i, ((JOINT, 0.), (EVENT, 0.))) for i in range(3)) + observations = tuple( + Observation(i, ((JOINT, .1), (DISPLAY, 1.2), (EVENT, 0.))) + for i in range(3)) + score = model.log_likelihood(predictions, observations) + assert math.isfinite(score) + assert model.log_likelihood(predictions, observations) == score + changed = list(observations) + changed[2] = Observation(2, ((JOINT, .1), (DISPLAY, 1.2), (EVENT, 1.))) + assert model.log_likelihood(predictions, tuple(changed)) == -math.inf + changed[2] = Observation(2, ((JOINT, .1), (DISPLAY, 1.21), (EVENT, 0.))) + assert model.log_likelihood(predictions, tuple(changed)) == -math.inf + # A contradictory readout must not hide a malformed observation schema. + changed[2] = Observation(2, changed[2].values + ((ANGLES[0], .1), )) + with pytest.raises(ValueError, match="Unknown observed"): + model.log_likelihood(predictions, tuple(changed)) + # Removing the initial reading changes evidence; it is never implicit. + without_initial = (Observation(0, ()), ) + observations[1:] + assert model.log_likelihood(predictions, without_initial) != score + without_error = OutputObservationModel(sensor, readouts=model.readouts) + assert without_error.log_likelihood(predictions, observations) == -math.inf + assert without_error.digest != model.digest + + +def test_euler_factor_covers_all_three_fields_once() -> None: + """Pole observations retain coupled mass, while other fields still + score.""" + sensor = SensorModel(tuple(SensorFeature(k, 0.) for k in (*ANGLES, EVENT))) + process = QuaternionOutputError(.1) + model = OutputObservationModel(sensor, + eulers=(EulerOutputFactor(ANGLES, + process), )) + prediction = Observation( + 0, + tuple(zip(ANGLES, (0., math.pi / 2, 0.))) + ((EVENT, 0.), )) + observed = Observation( + 0, + tuple(zip(ANGLES, (0., math.pi / 2, 4.7))) + ((EVENT, 0.), )) + assert math.isfinite(model.log_likelihood((prediction, ), (observed, ))) + partial = Observation(0, ((ANGLES[0], 0.), )) + with pytest.raises(UnsupportedConditioning, match="Partial Euler"): + model.log_likelihood((prediction, ), (partial, )) + assert model.log_likelihood((prediction, ), (Observation(0, ()), )) == 0. + with pytest.raises(ValueError, match="multiple factors"): + OutputObservationModel(sensor, + scalars=(ScalarOutputFactor( + ANGLES[0], GaussianOutputError(.8, .1)), ), + eulers=model.eulers) + noisy = SensorModel(tuple(SensorFeature(k, .1) for k in ANGLES)) + with pytest.raises(UnsupportedConditioning, match="exact readings"): + OutputObservationModel(noisy, eulers=model.eulers) + + +def test_sensor_fallback_and_step_contract() -> None: + """Unassigned evidence uses the declared sensor law with no hidden mask.""" + sensor = SensorModel((SensorFeature(JOINT, .1), SensorFeature(EVENT, 0.))) + model = OutputObservationModel(sensor) + predictions = (Observation(0, ((JOINT, .2), (EVENT, 1.))), ) + observations = (Observation(0, ((JOINT, .3), (EVENT, 1.))), ) + expected = sensor.log_likelihood(observations[0], + dict(predictions[0].values)) + assert model.log_likelihood(predictions, observations) == expected + with pytest.raises(ValueError, match="each step"): + model.log_likelihood((Observation(1, predictions[0].values), ), + observations) + with pytest.raises(ValueError, match="Matching nonempty"): + model.log_likelihood((), ()) + with pytest.raises(ValueError, match="Unknown observed"): + model.log_likelihood(predictions, (Observation(0, + ((DISPLAY, 0.), )), )) + with pytest.raises(ValueError, match="Missing or nonfinite"): + model.log_likelihood((Observation(0, ()), ), observations) + + +def test_conditioned_inputs_and_readout_dependencies_are_explicit() -> None: + """Neither an external input nor a derived display gets another density.""" + sensor = SensorModel((SensorFeature(JOINT, 0., conditioned=True), + SensorFeature(DISPLAY, 0.), SensorFeature(EVENT, + 0.))) + with pytest.raises(ValueError, match="predicted sensor"): + OutputObservationModel(sensor, + scalars=(ScalarOutputFactor( + JOINT, GaussianOutputError(.8, .1)), )) + first = CheckedReadoutFactor( + ExactReadout(JOINT, DISPLAY, sensor.digest, "a" * 64), lambda x: x) + second = CheckedReadoutFactor( + ExactReadout(DISPLAY, EVENT, sensor.digest, "b" * 64), lambda x: x) + with pytest.raises(UnsupportedConditioning, match="Chained"): + OutputObservationModel(sensor, readouts=(first, second)) + model = OutputObservationModel(sensor, readouts=(first, )) + data = (Observation(0, ((JOINT, .1), (DISPLAY, .1))), ) + assert model.log_likelihood((Observation(0, ()), ), data) == 0. diff --git a/tests/code_sim_learning/test_inference_orientation.py b/tests/code_sim_learning/test_inference_orientation.py new file mode 100644 index 000000000..1813868d4 --- /dev/null +++ b/tests/code_sim_learning/test_inference_orientation.py @@ -0,0 +1,144 @@ +"""Independent probability checks for the native Euler pushforward.""" +import math + +import numpy as np +import pybullet as p +import pytest +from scipy.special import roots_legendre + +from predicators.code_sim_learning.inference_orientation import \ + QuaternionOutputError + + +@pytest.mark.parametrize("sigma", [.1, .5, 1.]) +def test_centered_gaussian_has_analytic_mixed_density(sigma: float) -> None: + """A spherical four-dimensional normal has a closed-form pushforward. + + Integrating the ordinary formula gives 1-exp(-c/(2*sigma^2)); each + pole's four-pi yaw integral gives half the remaining mass. + """ + process = QuaternionOutputError(sigma) + mean = (0., 0., 0., 0.) + for pitch in (0., .3, -1.1): + expected = (math.log(math.cos(pitch)) - + math.log(16 * math.pi**2 * sigma**2) - + abs(math.sin(pitch)) / (2 * sigma**2)) + actual = process.log_density(mean, (.4, pitch, -.6)) + assert actual == pytest.approx(expected, abs=1e-7) + pole = -process.pole_threshold / (2 * sigma**2) - math.log(8 * math.pi) + for sign in (-1, 1): + for yaw in (-4.7, 0., 4.7): + assert process.log_density(mean, (0., sign * math.pi / 2, yaw)) \ + == pytest.approx(pole, abs=1e-7) + ordinary_mass = -math.expm1(-process.pole_threshold / (2 * sigma**2)) + assert ordinary_mass + 8 * math.pi * math.exp(pole) == \ + pytest.approx(1., abs=1e-14) + + +def test_noncentral_pole_probability_matches_native_draws() -> None: + """Integrate a yaw event and compare with independent raw Gaussian draws. + + Native API calls check the vectorized event classification on a + subset, including non-unit quaternions and both antipodal signs. + """ + process = QuaternionOutputError(.15) + mean = np.array(p.getQuaternionFromEuler([.1, math.pi / 2, .4])) + rng = np.random.default_rng(901) + quaternions = mean + process.sigma * rng.normal(size=(200000, 4)) + quaternions *= rng.choice((-1, 1), size=(len(quaternions), 1)) + x, y, z, w = quaternions.T + positive = 2 * (w * y - x * z) >= process.pole_threshold + yaw = 2 * np.arctan2(-x, y) + inside = positive & (yaw > -.5) & (yaw < .8) + for q, is_pole, expected_yaw in zip(quaternions[:200], positive, yaw): + native = p.getEulerFromQuaternion(q.tolist()) + assert (native[1] == math.pi / 2) == bool(is_pole) + if is_pole: + assert native[0] == 0 + assert native[2] == pytest.approx(expected_yaw, abs=1e-14) + nodes, weights, _ = roots_legendre(24, mu=True) + integral = sum(weight * math.exp( + process.log_density(tuple(mean), (0., math.pi / 2, .15 + .65 * node))) + for node, weight in zip(nodes, weights)) * .65 + empirical = float(np.mean(inside)) + assert abs(integral - empirical) < .004 + assert .1 < empirical < .4 # The test exercises appreciable pole mass. + + +def test_noncentral_ordinary_probability_matches_native_draws() -> None: + """A three-dimensional Euler box agrees with a Gaussian sample count.""" + process = QuaternionOutputError(.35) + mean = (0., 0., 0., 1.) + rng = np.random.default_rng(902) + quaternions = np.array(mean) + process.sigma * rng.normal(size=(300000, 4)) + x, y, z, w = quaternions.T + sine = 2 * (w * y - x * z) + ordinary = abs(sine) < process.pole_threshold + roll = np.arctan2(2 * (y * z + w * x), w * w - x * x - y * y + z * z) + yaw = np.arctan2(2 * (x * y + w * z), w * w + x * x - y * y - z * z) + inside = ordinary & (abs(roll) < .4) & (abs(sine) < math.sin(.3)) & \ + (abs(yaw) < .4) + nodes, weights, _ = roots_legendre(6, mu=True) + integral = 0. + for i, roll_node in enumerate(nodes): + for j, pitch_node in enumerate(nodes): + for k, yaw_node in enumerate(nodes): + reading = (.4 * roll_node, .3 * pitch_node, .4 * yaw_node) + integral += weights[i] * weights[j] * weights[k] * \ + math.exp(process.log_density(mean, reading)) * .4*.3*.4 + assert abs(integral - float(np.mean(inside))) < .002 + for q in quaternions[:100]: + actual = p.getEulerFromQuaternion(q.tolist()) + if abs(actual[1]) < math.pi / 2: + assert math.isfinite(process.log_density(mean, tuple(actual))) + + +def test_raw_branch_and_quadrature_sensitivity() -> None: + """Sign marginalization preserves raw yaw measure and very small scores.""" + process = QuaternionOutputError(.02) + mean = tuple(p.getQuaternionFromEuler([.2, 1.56, .7])) + for reading in ((.2, 1.56, .7), (0., math.pi / 2, 4.7), + (0., -math.pi / 2, -.4), (1., -.4, -1.)): + value = process.log_density(mean, reading) + fine = process.log_density(mean, reading, 1e-9) + assert value == pytest.approx(fine, abs=2e-7) + assert value == pytest.approx(process.log_density( + tuple(-x for x in mean), reading), + abs=1e-10) + assert process.log_density(mean, (1., -.4, -1.)) < -700 + # These are two distinct raw observations with equal antipodal mass. + first = process.log_density(mean, (0., math.pi / 2, .2)) + second = process.log_density(mean, (0., math.pi / 2, .2 - 2 * math.pi)) + assert first == pytest.approx(second, abs=1e-9) + + +def test_invalid_support_is_distinct_from_invalid_input() -> None: + """Do not wrap, renormalize or soften exact contradictions.""" + process = QuaternionOutputError(.1) + mean = (0., 0., 0., 1.) + for observed in ((.1, math.pi / 2, 0.), (0., math.pi / 2, 7.), + (0., 1.57, 0.), (0., 2., 0.), (4., 0., 0.)): + assert process.log_density(mean, observed) == -math.inf + with pytest.raises(ValueError): + process.log_density(mean, (0., float("nan"), 0.)) + with pytest.raises(ValueError): + process.log_density(mean, (0., 0., 0.), 0.) + for sigma in (0., -.1, math.inf): + with pytest.raises(ValueError): + QuaternionOutputError(sigma) + with pytest.raises(ValueError): + QuaternionOutputError(.1, 1.) + assert process.digest != QuaternionOutputError(.2).digest + assert process.digest != QuaternionOutputError(.1, .99).digest + + +def test_recorded_near_pole_boundary_converges() -> None: + """Retain the Balloons action-23 witness that failed tighter quadrature.""" + process = QuaternionOutputError(.0001) + mean = tuple( + p.getQuaternionFromEuler( + [2.856054709212263, 1.564969365024118, -1.861413060789528])) + observed = (1.1358128734980024, 1.566274408012441, 2.705544777791995) + coarse = process.log_density(mean, observed, 1e-7) + fine = process.log_density(mean, observed, 1e-9) + assert coarse == pytest.approx(fine, abs=1e-7) diff --git a/tests/code_sim_learning/test_inference_output_error.py b/tests/code_sim_learning/test_inference_output_error.py new file mode 100644 index 000000000..15dfbef78 --- /dev/null +++ b/tests/code_sim_learning/test_inference_output_error.py @@ -0,0 +1,106 @@ +"""Dense Gaussian integration checks for the marginalized discrepancy +process.""" +import math + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_conditioning import \ + ConditioningNumericalError +from predicators.code_sim_learning.inference_output_error import \ + GaussianOutputError, output_error_likelihood + + +@pytest.mark.parametrize("persistence", [0., -.4, .8, 1.]) +@pytest.mark.parametrize("sensor_sigma", [0., .07]) +def test_filter_matches_dense_history_integration(persistence: float, + sensor_sigma: float) -> None: + """Marginal density and each causal conditional agree with a joint + normal.""" + process = GaussianOutputError(persistence, .12, .2) + predictions = (.1, .12, .17, .3, .45, .5) + observations = (.15, None, .3, .38, None, .6) + size = len(predictions) + transition = np.array( + [[persistence**(t - k) if k <= t else 0. for k in range(size)] + for t in range(size)]) + covariance = transition @ np.diag([.2**2] + [.12**2] * + (size - 1)) @ transition.T + result = output_error_likelihood(process, predictions, observations, + sensor_sigma) + assert result.status == "complete" + assert result.failed_step is None + indices = [i for i, value in enumerate(observations) if value is not None] + observed = np.array([observations[i] for i in indices]) + residual = observed - np.array(predictions)[indices] + marginal_cov = covariance[np.ix_( + indices, indices)] + sensor_sigma**2 * np.eye(len(indices)) + expected = -.5 * (len(indices) * math.log(2 * math.pi) + + np.linalg.slogdet(marginal_cov)[1] + + residual @ np.linalg.solve(marginal_cov, residual)) + assert result.log_likelihood == pytest.approx(expected, abs=1e-12) + for step, state in enumerate(result.steps): + used = [i for i in indices if i <= step] + cov = covariance[np.ix_(used, + used)] + sensor_sigma**2 * np.eye(len(used)) + cross = covariance[step, used] + values = np.array([observations[i] + for i in used]) - np.array(predictions)[used] + mean = cross @ np.linalg.solve(cov, values) + variance = covariance[step, step] - cross @ np.linalg.solve(cov, cross) + assert state.filtered_mean == pytest.approx(mean, abs=1e-12) + assert state.filtered_sigma**2 == pytest.approx(variance, abs=1e-12) + + +def test_exact_output_retains_density_and_unobserved_future_is_causal( +) -> None: + """Exact readings eliminate error uncertainty without deleting the error + law.""" + process = GaussianOutputError(.8, .1, .2) + result = output_error_likelihood(process, (0., ) * 4, (.3, .4, None, None), + 0.) + assert result.steps[0].filtered_sigma == 0 + assert result.steps[1].filtered_sigma == 0 + assert result.steps[2].predicted_mean == pytest.approx(.32) + assert result.steps[3].predicted_mean == pytest.approx(.256) + assert result.steps[3].predicted_sigma == pytest.approx(math.hypot( + .08, .1)) + assert result.steps[2].log_observation_factor is None + changed = output_error_likelihood(process, (0., ) * 4, (.3, .4, 20., -20.), + 0.) + assert changed.steps[:2] == result.steps[:2] + assert output_error_likelihood(process, (0., ) * 4, (.3, .4, None, None), + 0.) == result + + +def test_deterministic_reference_keeps_exact_contradictions() -> None: + """An absent discrepancy process does not silently introduce a noise + floor.""" + process = GaussianOutputError(.8, 0., 0.) + result = output_error_likelihood(process, (1., 2., 3.), (1., 2.1, 3.), 0.) + assert result.status == "exact_contradiction" + assert result.log_likelihood == -math.inf + assert result.failed_step == 1 + assert len(result.steps) == 1 + noisy = output_error_likelihood(process, (1., 2.), (1.1, 1.9), .2) + expected = -(.1 / .2)**2 - 2 * math.log(.2 * math.sqrt(2 * math.pi)) + assert noisy.log_likelihood == pytest.approx(expected) + assert all(step.filtered_sigma == 0 for step in noisy.steps) + + +def test_invalid_inputs_and_numeric_overflow_are_explicit() -> None: + """Unsupported numerical ranges are not ordinary zero-likelihood states.""" + for args in ((1.1, .1, .1), (.9, -.1, .1), (.9, .1, float("inf"))): + with pytest.raises(ValueError): + GaussianOutputError(*args) + process = GaussianOutputError(.8, .1) + with pytest.raises(ValueError): + output_error_likelihood(process, (0., ), (None, None), .1) + with pytest.raises(ValueError): + output_error_likelihood(process, (0., ), (float("nan"), ), .1) + with pytest.raises(ValueError): + output_error_likelihood(process, (0., ), (None, ), -.1) + with pytest.raises(ConditioningNumericalError): + output_error_likelihood(process, (-1e308, ), (1e308, ), .1) + assert process.digest != GaussianOutputError(.7, .1).digest + assert process.digest != GaussianOutputError(.8, .2).digest diff --git a/tests/code_sim_learning/test_inference_parameters.py b/tests/code_sim_learning/test_inference_parameters.py new file mode 100644 index 000000000..4585d5f24 --- /dev/null +++ b/tests/code_sim_learning/test_inference_parameters.py @@ -0,0 +1,222 @@ +"""Posterior consumers retain weights, correlations and assessment status.""" +from dataclasses import replace + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_assessment import \ + AssessedInference, AssessmentProtocol, InferenceCheck, assess_inference +from predicators.code_sim_learning.inference_data import InferenceIdentity, \ + content_digest +from predicators.code_sim_learning.inference_parameters import \ + ParameterPosterior, UnavailableParameterPosterior +from predicators.code_sim_learning.inference_sampling import BatchPosterior, \ + BoxPrior, SamplerConfig, sample_batch + + +def _assessment() -> AssessedInference: + # Exact enumerated reference: b = a**2 in three discrete modes. + # A zero-mass fourth row must not appear in intervals or ensembles. + prior = BoxPrior(("a", "episode0.start", "b"), + ((-10., 10.), (-100., 100.), (0., 100.))) + digest = content_digest(b"enumerated joint reference") + identity = InferenceIdentity(digest, digest, digest, prior.digest, digest) + candidate = BatchPosterior(identity, prior, SamplerConfig(particles=4), 0, + "complete", ((-9., 80., 81.), (-2., 3., 4.), + (1., 4., 1.), (3., 5., 9.)), + (0., .1, .3, .6), 4, 1., 3, (2., ), 0, 0, 3, 0) + protocol = AssessmentProtocol( + content_digest(b"enumerated reference checks"), ("exact", )) + checks = (InferenceCheck("exact", "pass", "Weights are specified exactly", + digest), ) + predictive = (InferenceCheck("future", "fail", "Incomplete dynamics", + content_digest(b"prediction failure")), ) + return assess_inference(candidate, protocol, checks, predictive) + + +def test_projection_and_quantiles_share_weighted_joint_reference() -> None: + """Projection preserves exact empirical masses and owned parameter maps.""" + result = ParameterPosterior(_assessment(), ("b", "a")) + ensemble = result.weighted_samples() + assert ensemble.names == ("b", "a") + assert ensemble.values == ((4., -2.), (1., 1.), (9., 3.)) + assert ensemble.weights == (.1, .3, .6) + assert ensemble.source_indices == (1, 2, 3) + assert ensemble.identity == result.assessment.identity + assert ensemble.predictive_checks == result.assessment.predictive_checks + assert ensemble.predictive_checks[0].status == "fail" + assert result.marginal_quantiles((0., .5, 1.)) == { + "a": (-2., 3., 3.), + "b": (1., 9., 9.) + } + assert ensemble.expectation([False, False, True]) == pytest.approx(.6) + assert ensemble.expectation([row[1] for row in ensemble.values]) == \ + pytest.approx(1.9) + maps = ensemble.as_dicts() + maps[0]["a"] = 999. + assert ensemble.as_dicts()[0]["a"] == -2. + assert result.weighted_samples() == ensemble + + +def test_resampling_preserves_modes_and_parameter_dependence() -> None: + """Whole-row draws retain a nonlinear relation across separated modes.""" + result = ParameterPosterior(_assessment(), ("a", "b")) + ensemble = result.resample(10000, 42) + assert ensemble == result.resample(10000, 42) + assert ensemble != result.resample(10000, 43) + assert ensemble.weights == (1 / 10000, ) * 10000 + assert ensemble.resampling_seed == 42 + assert set(ensemble.source_indices) == {1, 2, 3} + assert all(b == a**2 for a, b in ensemble.values) + assert ensemble.expectation([a == 3 for a, _ in ensemble.values]) == \ + pytest.approx(.6, abs=.02) + assert ensemble.expectation([a for a, _ in ensemble.values]) == \ + pytest.approx(1.9, abs=.06) + assert ensemble.predictive_checks[0].status == "fail" + + +def test_assessed_ensemble_drives_information_score() -> None: + """A posterior mode's probability, not its row count, weights a probe.""" + result = ParameterPosterior(_assessment(), ("a", "b")) + ensemble = result.weighted_samples() + # The proposed probe distinguishes only a=3, whose posterior mass is .6. + reads = np.array([[row["a"] == 3.] for row in ensemble.as_dicts()]) + entropy = -.6 * np.log2(.6) - .4 * np.log2(.4) + assert ensemble.atom_information(reads) == pytest.approx(entropy) + # A binary symmetric read-error channel with crossover probability .2. + noisy_reads = .2 + .6 * reads + marginal = .6 * .8 + .4 * .2 + reference = -marginal * np.log2(marginal) - \ + (1. - marginal) * np.log2(1. - marginal) + \ + .2 * np.log2(.2) + .8 * np.log2(.8) + assert ensemble.atom_information(noisy_reads) == pytest.approx(reference) + assert ensemble.predictive_checks[0].status == "fail" + with pytest.raises(ValueError, match="weight"): + ensemble.atom_information(reads[:2]) + + +def test_explicit_simulator_names_and_shared_coordinates() -> None: + """Simulator names are explicitly mapped to the joint inference schema.""" + result = ParameterPosterior(_assessment(), ("force", "drag"), ("b", "a")) + weighted = result.weighted_samples() + assert weighted.source_coordinates == ("b", "a") + assert weighted.as_dicts()[0] == {"force": 4., "drag": -2.} + assert result.marginal_quantiles((.5, )) == { + "force": (9., ), + "drag": (3., ) + } + assert result.resample(4, 1).source_coordinates == ("b", "a") + shared = ParameterPosterior(_assessment(), ("left", "right"), ("a", "a")) + assert all(left == right for left, right in shared.resample(16, 0).values) + with pytest.raises(ValueError, match="One joint coordinate"): + ParameterPosterior(_assessment(), ("force", "drag"), ("a", )) + with pytest.raises(ValueError, match="Unknown"): + ParameterPosterior(_assessment(), ("force", ), ("theta.missing", )) + + +@pytest.mark.parametrize("status", ["numerical_failure", "unevaluated"]) +def test_unavailable_inference_keeps_diagnostics_without_parameters(status): + """Incomplete numerical assessment cannot yield plausible-looking + widths.""" + source = _assessment() + unavailable = replace(source, availability=status, posterior=None) + result = ParameterPosterior(unavailable, ("a", )) + assert result.assessment.predictive_checks == source.predictive_checks + for operation in (result.weighted_samples, result.marginal_quantiles, + lambda: result.resample(2, 0)): + with pytest.raises(UnavailableParameterPosterior, match=status): + operation() + + +def test_projection_checks_assessment_identity_and_parameter_schema() -> None: + """Invalid declarations fail before any parameter row reaches a + consumer.""" + source = _assessment() + for names in (("a", "a"), ("missing", ), ("", )): + with pytest.raises(ValueError): + ParameterPosterior(source, names) + with pytest.raises(ValueError, match="needs a posterior"): + ParameterPosterior(replace(source, posterior=None), ("a", )) + with pytest.raises(ValueError, match="identity"): + ParameterPosterior( + replace(source, + identity=replace(source.identity, + data=content_digest(b"different data"))), + ("a", )) + with pytest.raises(ValueError, match="available posterior"): + ParameterPosterior(replace(source, numerical_checks=()), ("a", )) + assert source.posterior is not None + assert isinstance(source.posterior.prior, BoxPrior) + changed_prior = replace(source.posterior.prior, + bounds=((-11., 11.), (-100., 100.), (0., 100.))) + with pytest.raises(ValueError, match="identity"): + ParameterPosterior( + replace(source, + posterior=replace(source.posterior, prior=changed_prior)), + ("a", )) + result = ParameterPosterior(source, ("a", )) + for count, seed in ((0, 1), (True, 1), (1, -1), (1, True)): + with pytest.raises(ValueError): + result.resample(count, seed) + for probabilities in ((), (-.1, ), (float("nan"), )): + with pytest.raises(ValueError): + result.marginal_quantiles(probabilities) + + +def test_empty_parameter_projection_and_malformed_ensemble() -> None: + """Parameterless models remain explicit and malformed weights reject.""" + source = _assessment() + empty = ParameterPosterior(source, ()) + assert empty.marginal_quantiles() == {} + assert empty.weighted_samples().as_dicts() == ({}, {}, {}) + assert empty.weighted_samples().expectation([1., 1., 1.]) == 1. + ensemble = ParameterPosterior(source, ("a", )).weighted_samples() + for outcomes in ([1.], [1., 2., float("nan")]): + with pytest.raises(ValueError, match="outcome"): + ensemble.expectation(outcomes) + with pytest.raises(ValueError, match="normalized"): + replace(ensemble, weights=(.2, .3, .6)) + with pytest.raises(ValueError, match="dimensions"): + replace(ensemble, values=((0., 1.), ) * 3) + with pytest.raises(ValueError, match="source particle"): + replace(ensemble, source_indices=(-1, 2, 3)) + + +def test_sampled_inference_to_correlated_prediction() -> None: + """A noisy sum observation flows through assessment into joint + forecasts.""" + prior = BoxPrior(("a", "unobserved_start", "b"), ((0., 1.), ) * 3) + digest = content_digest(b"sum observation: one plus Gaussian noise .05") + identity = InferenceIdentity(digest, digest, digest, prior.digest, digest) + posterior = sample_batch( + prior, identity, lambda point: -.5 * + ((point[0] + point[2] - 1.) / .05)**2, + SamplerConfig(particles=1024, + temperatures=16, + moves=4, + max_evaluations=70000), 51) + assert posterior.status == "complete" + values = np.asarray(posterior.samples) + weights = np.asarray(posterior.weights) + # Symmetry about a+b=1 gives the first reference moment exactly. + # The unused coordinate is independent and retains its uniform prior. + assert np.dot(weights, values[:, 0] + values[:, 2]) == \ + pytest.approx(1., abs=.015) + assert np.dot(weights, values[:, 1]) == pytest.approx(.5, abs=.05) + protocol = AssessmentProtocol(content_digest(b"two analytic mean checks"), + ("sum_mean", "uninformed_mean")) + checks = tuple( + InferenceCheck(name, "pass", "Analytic mean matched", digest) + for name in protocol.required_numerical_checks) + result = ParameterPosterior(assess_inference(posterior, protocol, checks), + ("a", "b")) + weighted = result.weighted_samples() + success = [abs(a + b - 1.) < .15 for a, b in weighted.values] + assert weighted.expectation(success) > .97 + resampled = result.resample(5000, 16) + success = [abs(a + b - 1.) < .15 for a, b in resampled.values] + assert resampled.expectation(success) > .97 + # Independent marginal recombination destroys this predictive relation. + rows = np.asarray(resampled.values) + shuffled = np.random.default_rng(1).permutation(rows[:, 1]) + assert np.mean(np.abs(rows[:, 0] + shuffled - 1.) < .15) < .5 diff --git a/tests/code_sim_learning/test_inference_path_integral.py b/tests/code_sim_learning/test_inference_path_integral.py new file mode 100644 index 000000000..33c950561 --- /dev/null +++ b/tests/code_sim_learning/test_inference_path_integral.py @@ -0,0 +1,112 @@ +"""Conditional path integration retains density factors and sampling error.""" +import math + +import numpy as np +import pytest +from scipy.integrate import quad + +from predicators.code_sim_learning.inference_conditioning import \ + ConditioningNumericalError +from predicators.code_sim_learning.inference_discrepancy import \ + VelocityDiscrepancy +from predicators.code_sim_learning.inference_path_integral import \ + integrate_conditional_paths, summarize_path_integral + + +@pytest.mark.parametrize("constant", [-1e6, 0., 1e6]) +def test_constant_integrand_and_log_scale_stability(constant): + """Constant densities need no exponentiation at their original scale.""" + result = summarize_path_integral((constant, ) * 16) + assert result.log_density == constant + assert result.relative_standard_error == 0. + assert result.effective_terms == 16. + assert result.status == "finite_estimate" + + +def test_zero_contributions_remain_in_the_integral_and_error(): + """Deleting zero paths would inflate density and disguise uncertainty.""" + factors = (0., math.log(3.), -math.inf, -math.inf) + result = summarize_path_integral(factors) + values = np.array([1., 3., 0., 0.]) + assert result.log_density == pytest.approx(math.log(values.mean())) + assert result.relative_standard_error == pytest.approx( + values.std(ddof=1) / math.sqrt(4) / values.mean()) + assert result.effective_terms == pytest.approx(1.6) + assert result.log_factors == factors + shifted = summarize_path_integral(tuple(v - 10000 for v in factors)) + assert shifted.log_density == pytest.approx(result.log_density - 10000) + assert shifted.relative_standard_error == pytest.approx( + result.relative_standard_error) + assert shifted.effective_terms == pytest.approx(result.effective_terms) + + +@pytest.mark.parametrize("speed", [0., .4]) +def test_conditional_speed_future_density_matches_quadrature(speed): + """Radial density times a conditional downstream integral scores once.""" + law = VelocityDiscrepancy(.2, .3) + predicted = (0., 0., .3) + reading, sensor_sigma = .05, .2 + + def log_integrand(rng): + direction = tuple(rng.random(2)) if speed else () + conditional = law.condition_on_speed(predicted, speed, direction) + residual = (reading - conditional.velocity[2]) / sensor_sigma + output_factor = -.5 * residual**2 - math.log(sensor_sigma) - \ + .5 * math.log(2 * math.pi) + return conditional.log_observation_factor + output_factor + + result = integrate_conditional_paths(log_integrand, 16000, 86) + assert result == integrate_conditional_paths(log_integrand, 16000, 86) + radial = math.exp( + law.condition_on_speed(predicted, speed, (.5, .5) if speed else + ()).log_observation_factor) + if speed: + concentration = speed * predicted[2] / law.sigma**2 + + def integrand(cosine): + directional = concentration * math.exp(concentration * cosine) / \ + (2 * math.sinh(concentration)) + output = math.exp(-.5 * ((reading - speed * cosine) / + sensor_sigma)**2) / \ + (math.sqrt(2 * math.pi) * sensor_sigma) + return directional * output + + expected = radial * quad(integrand, -1., 1., epsabs=1e-12)[0] + assert result.relative_standard_error is not None + assert result.relative_standard_error > 0. + else: + expected = radial * math.exp(-.5 * (reading / sensor_sigma)**2) / \ + (math.sqrt(2 * math.pi) * sensor_sigma) + assert result.relative_standard_error == 0. + assert math.exp(result.log_density) == pytest.approx(expected, rel=.02) + + +def test_no_sample_support_does_not_claim_model_inconsistency(): + """An unseen rare supported path leaves the empirical error undefined.""" + result = integrate_conditional_paths( + lambda rng: 0. if rng.random() < 1e-12 else -math.inf, 16, 0) + assert result.status == "no_sample_support" + assert result.relative_standard_error is None + assert result.log_density == -math.inf + assert result.effective_terms == 0. + + +def test_invalid_integrands_and_callback_errors_are_not_dropped(): + """Numerical and replay failures propagate instead of becoming zeros.""" + for value in (math.nan, math.inf): + with pytest.raises(ConditioningNumericalError): + summarize_path_integral((0., value)) + for count, seed in ((1, 0), (True, 0), (2, -1), (2, True)): + with pytest.raises(ValueError): + integrate_conditional_paths(lambda rng: rng.random(), count, seed) + calls = [] + + def fail(rng): + calls.append(rng.random()) + if len(calls) == 3: + raise RuntimeError("replay failed") + return 0. + + with pytest.raises(RuntimeError, match="replay failed"): + integrate_conditional_paths(fail, 8, 0) + assert len(calls) == 3 diff --git a/tests/code_sim_learning/test_inference_prediction.py b/tests/code_sim_learning/test_inference_prediction.py new file mode 100644 index 000000000..41acdf7e9 --- /dev/null +++ b/tests/code_sim_learning/test_inference_prediction.py @@ -0,0 +1,215 @@ +"""Joint forecasts retain particle dependence, posterior mass and +provenance.""" +import math +from dataclasses import replace + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_assessment import \ + AssessmentProtocol, InferenceCheck, assess_inference +from predicators.code_sim_learning.inference_conditioning import \ + UnsupportedConditioning +from predicators.code_sim_learning.inference_data import EpisodeData, \ + InferenceData, InferenceIdentity, Observation, SensorFeature, \ + SensorModel, content_digest +from predicators.code_sim_learning.inference_observation import \ + OutputObservationModel +from predicators.code_sim_learning.inference_prediction import JointForecast +from predicators.code_sim_learning.inference_sampling import BatchPosterior, \ + BoxPrior, SamplerConfig + +POSITION = ("body", "body", "x") +EVENT = ("body", "body", "attached") + + +def _reference(): + """Specify an empirical joint measure with two anticorrelated modes.""" + model = OutputObservationModel( + SensorModel((SensorFeature(POSITION, 1.), SensorFeature(EVENT, 0.)))) + episode = EpisodeData("episode", (), + (Observation(0, ((POSITION, 0.), (EVENT, 0.))), )) + data = InferenceData((episode, )) + prior = BoxPrior(("velocity", "episode.start"), ((-5., 5.), (-5., 5.))) + digest = content_digest(b"specified empirical measure") + identity = InferenceIdentity(data.digest, model.digest, digest, + prior.digest, digest) + candidate = BatchPosterior(identity, prior, SamplerConfig(particles=4), 0, + "complete", ((4., 4.), (1., -1.), (-1., 1.)), + (0., .25, .75), 3, 1., 2, (2., ), 0, 0, 2, 0) + protocol = AssessmentProtocol(digest, ("specified", )) + checks = (InferenceCheck("specified", "pass", "Enumerated mixture", + digest), ) + predictive = (InferenceCheck("future", "fail", "Model error retained", + digest), ) + assessment = assess_inference(candidate, protocol, checks, predictive) + return assessment, data, model + + +def _replay(row, episode, actions): + assert row["velocity"] == -row["episode.start"] + assert len(episode.observations) == 1 + return tuple( + Observation(i, ((POSITION, row["episode.start"] + i * row["velocity"]), + (EVENT, float(row["velocity"] > 0) if i else 0.))) + for i in range(1 + len(episode.actions) + len(actions))) + + +def _forecast(): + assessment, data, model = _reference() + return JointForecast.replay(assessment, data, "episode", model, + ((0., ), (0., )), _replay) + + +def test_complete_history_mixture_matches_enumerated_reference(): + """Likelihoods mix once per whole future, never independently by time.""" + forecast = _forecast() + # Omit the event readings to compare the Gaussian mixture analytically. + future = (Observation(1, ((POSITION, .2), )), + Observation(2, ((POSITION, .7), ))) + left = -.5 * (.2**2 + (.7 - 1)**2) - math.log(2 * math.pi) + right = -.5 * (.2**2 + (.7 + 1)**2) - math.log(2 * math.pi) + expected = math.log(.25 * math.exp(left) + .75 * math.exp(right)) + assert forecast.log_likelihood(future) == pytest.approx(expected) + assert forecast.log_likelihood(future) != pytest.approx(.25 * left + + .75 * right) + assert forecast.assessment.predictive_checks[0].status == "fail" + # Each mode requires its own consistent event sequence. A per-time + # mixture would incorrectly assign positive mass to this switch. + switched = (Observation(1, + ((EVENT, 1.), )), Observation(2, ((EVENT, 0.), ))) + assert forecast.log_likelihood(switched) == -math.inf + supported = tuple(Observation(i, ((EVENT, 1.), )) for i in (1, 2)) + assert forecast.log_likelihood(supported) == pytest.approx(math.log(.25)) + # Very small component densities must be mixed without underflow. + remote = tuple(Observation(i, ((POSITION, 1e4), )) for i in (1, 2)) + assert math.isfinite(forecast.log_likelihood(remote)) + + +def test_draws_keep_whole_joint_modes_and_their_probabilities(): + """One source particle controls the entire sampled history.""" + forecast = _forecast() + draws = forecast.sample(3000, 41) + assert draws == forecast.sample(3000, 41) + assert draws != forecast.sample(3000, 42) + assert set(i for i, _ in draws) == {1, 2} + assert np.mean([i == 1 for i, _ in draws]) == pytest.approx(.25, abs=.025) + for index, history in draws: + assert [o.step for o in history] == [1, 2] + assert [dict(o.values)[EVENT] for o in history] == \ + [float(index == 1)] * 2 + assert forecast.assessment.predictive_checks[0].status == "fail" + for count, seed in ((0, 1), (True, 1), (1, -1), (1, True)): + with pytest.raises(ValueError): + forecast.sample(count, seed) + + +def test_replay_receives_owned_complete_rows_and_no_future_readings(): + """The callback gets all joint coordinates and only fitted observations.""" + assessment, data, model = _reference() + calls = [] + + def replay(row, episode, actions): + calls.append((dict(row), episode, actions)) + result = _replay(row, episode, actions) + row["episode.start"] = 999. + return result + + forecast = JointForecast.replay(assessment, data, "episode", model, + ((0., ), (0., )), replay) + assert len(calls) == 2 + assert [row for row, _, _ in calls] == [{ + "velocity": 1., + "episode.start": -1. + }, { + "velocity": -1., + "episode.start": 1. + }] + assert all(episode == data.episodes[0] for _, episode, _ in calls) + assert all(actions == ((0., ), (0., )) for _, _, actions in calls) + assert forecast.assessment == _reference()[0] + + +@pytest.mark.parametrize("availability", ["unevaluated", "numerical_failure"]) +def test_unavailable_assessment_cannot_start_replay(availability): + """Numerical checks gate prediction artifacts before simulation starts.""" + assessment, data, model = _reference() + unavailable = replace(assessment, + availability=availability, + posterior=None) + with pytest.raises(ValueError, match=availability): + JointForecast.replay(unavailable, data, "episode", model, (), _replay) + forged = replace(unavailable, availability="available") + with pytest.raises(ValueError, match="needs a posterior"): + JointForecast.replay(forged, data, "episode", model, (), _replay) + + +def test_ledger_model_episode_and_action_mismatches_reject_before_replay(): + """A suffix included in fitting cannot masquerade as unseen + observations.""" + assessment, data, model = _reference() + changed = InferenceData((replace(data.episodes[0], actions=((0., ), )), )) + other = OutputObservationModel(SensorModel((SensorFeature(POSITION, + 2.), ))) + for ledger, output in ((changed, model), (data, other)): + with pytest.raises(ValueError, match="identity differs"): + JointForecast.replay(assessment, ledger, "episode", output, (), + _replay) + with pytest.raises(ValueError, match="fitted reset episode"): + JointForecast.replay(assessment, data, "unknown", model, (), _replay) + for actions in (((math.nan, ), ), ((0., ), (0., 1.))): + with pytest.raises(ValueError): + JointForecast.replay(assessment, data, "episode", model, actions, + _replay) + + +def test_replay_failures_and_missing_particles_are_not_renormalized(): + """No partial ensemble is returned after an exception or lost particle.""" + assessment, data, model = _reference() + + def fail(row, episode, actions): + if row["velocity"] < 0: + raise RuntimeError("native replay failed") + return _replay(row, episode, actions) + + with pytest.raises(RuntimeError, match="native replay failed"): + JointForecast.replay(assessment, data, "episode", model, ((0., ), ), + fail) + forecast = _forecast() + with pytest.raises(ValueError, match="positive-weight joint row"): + replace(forecast, histories=forecast.histories[:1]) + with pytest.raises(ValueError, match="every action"): + replace(forecast, histories=tuple(h[:-1] for h in forecast.histories)) + bad_prefix = Observation(0, ((POSITION, 0.), (EVENT, 1.))) + with pytest.raises(UnsupportedConditioning, + match="zero-likelihood prefix"): + replace(forecast, + histories=tuple( + (bad_prefix, ) + h[1:] for h in forecast.histories)) + + +def test_empty_future_and_missing_initial_reading(): + """Empty futures retain unit probability; omitted reads stay omitted.""" + assessment, data, model = _reference() + forecast = JointForecast.replay(assessment, data, "episode", model, (), + _replay) + assert forecast.log_likelihood(()) == pytest.approx(0.) + assert all(history == () for _, history in forecast.sample(10, 3)) + missing = InferenceData((replace(data.episodes[0], observations=()), )) + assert assessment.posterior is not None + identity = replace(assessment.identity, data=missing.digest) + assessment = replace(assessment, + identity=identity, + posterior=replace(assessment.posterior, + identity=identity)) + + def replay(row, episode, actions): + assert episode.observations == () + del row + return tuple( + Observation(i, ((POSITION, 0.), (EVENT, 0.))) + for i in range(len(actions) + 1)) + + forecast = JointForecast.replay(assessment, missing, "episode", model, (), + replay) + assert forecast.log_likelihood(()) == pytest.approx(0.) diff --git a/tests/code_sim_learning/test_inference_readout.py b/tests/code_sim_learning/test_inference_readout.py new file mode 100644 index 000000000..948bf7874 --- /dev/null +++ b/tests/code_sim_learning/test_inference_readout.py @@ -0,0 +1,100 @@ +"""A redundant readout must neither add independent evidence nor hide +errors.""" +import math +from dataclasses import replace + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_conditioning import \ + UnsupportedConditioning +from predicators.code_sim_learning.inference_data import Observation, \ + SensorFeature, SensorModel, content_digest +from predicators.code_sim_learning.inference_output_error import \ + GaussianOutputError, output_error_likelihood +from predicators.code_sim_learning.inference_readout import ExactReadout, \ + reduce_exact_readout + +SOURCE = ("robot", "joint_positions", "7") +OUTPUT = ("robot", "robot", "fingers") + + +def _sensor() -> SensorModel: + return SensorModel((SensorFeature(SOURCE, 0.), SensorFeature(OUTPUT, 0.))) + + +def _rule(sensor: SensorModel) -> ExactReadout: + return ExactReadout(SOURCE, OUTPUT, sensor.digest, + content_digest(b"specified deterministic readout")) + + +def test_source_density_is_retained_once_without_readout_jacobian() -> None: + """Changing a redundant display's scale must not change source + inference.""" + sensor = _sensor() + process = GaussianOutputError(.8, .1, .2) + reference = output_error_likelihood(process, (0., ), (.4, ), 0.) + for scale in (2., 50.): + + def transform(value: float, factor: float = scale) -> float: + return factor * value + + observation = Observation(0, ((SOURCE, .4), (OUTPUT, scale * .4))) + result = reduce_exact_readout(observation, sensor, _rule(sensor), + transform) + assert result.status == "verified" + assert result.observation == Observation(0, ((SOURCE, .4), )) + assert result.log_factor == 0 + value = dict(result.observation.values)[SOURCE] + likelihood = output_error_likelihood(process, (0., ), (value, ), 0.) + assert likelihood.log_likelihood + result.log_factor == \ + reference.log_likelihood + assert dict(observation.values)[OUTPUT] == scale * .4 + + +def test_precision_and_inconsistent_readout_are_not_softened() -> None: + """A float32 display is a deterministic map, not an independent sensor.""" + sensor = _sensor() + joint = .0123456789 + displayed = float(np.float32(joint)) + observation = Observation(3, ((SOURCE, joint), (OUTPUT, displayed))) + result = reduce_exact_readout(observation, sensor, _rule(sensor), + lambda value: float(np.float32(value))) + assert result.status == "verified" + wrong = reduce_exact_readout(observation, sensor, _rule(sensor), + lambda value: value) + assert wrong.status == "exact_contradiction" + assert wrong.log_factor == -math.inf + assert wrong.observation is None + + +def test_missing_noisy_or_unknown_sources_cannot_be_discarded() -> None: + """A readout may still inform an absent or noisy source quantity.""" + sensor = _sensor() + observation = Observation(0, ((OUTPUT, .3), )) + with pytest.raises(UnsupportedConditioning, match="missing"): + reduce_exact_readout(observation, sensor, _rule(sensor), + lambda value: value) + noisy = SensorModel((SensorFeature(SOURCE, .1), SensorFeature(OUTPUT, 0.))) + with pytest.raises(UnsupportedConditioning, match="exact"): + reduce_exact_readout(observation, noisy, _rule(noisy), + lambda value: value) + with pytest.raises(ValueError, match="sensor"): + reduce_exact_readout(observation, noisy, _rule(sensor), + lambda value: value) + absent = Observation(1, ((SOURCE, .4), )) + result = reduce_exact_readout(absent, sensor, _rule(sensor), + lambda value: value) + assert result.status == "not_observed" + assert result.observation is absent + with pytest.raises(ValueError): + replace(_rule(sensor), output=SOURCE) + + +def test_malformed_callback_is_not_an_observation_contradiction() -> None: + """Invalid mapping implementations propagate as errors.""" + sensor = _sensor() + observation = Observation(0, ((SOURCE, .3), (OUTPUT, .3))) + with pytest.raises(ValueError, match="nonfinite"): + reduce_exact_readout(observation, sensor, _rule(sensor), + lambda _: float("nan")) diff --git a/tests/code_sim_learning/test_inference_recording.py b/tests/code_sim_learning/test_inference_recording.py new file mode 100644 index 000000000..8d1d79161 --- /dev/null +++ b/tests/code_sim_learning/test_inference_recording.py @@ -0,0 +1,219 @@ +"""Recording writer to offline likelihood, without launching an environment.""" +import json +from pathlib import Path + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_recording import ArtifactBundle, \ + RecordingProjection, SourceArtifact, combine_recorded_levels, \ + load_recorded_level +from predicators.observation_noise import ObservationNoise +from predicators.run.recording import LevelRecording +from predicators.structs import Action, Object, Type +from predicators.utils import PyBulletState + + +def _write_level(directory: Path) -> None: + obj = Object("box", Type("box", ["x", "attached"])) + truth = PyBulletState({obj: np.array([.3, 0.])}, + simulator_state={ + "joint_positions": [.1, .2], + "physics_client_id": 93, + "body_velocities": { + "box": ((0., 0., 0.), (0., 0., 0.)) + }, + }) + # The actual continual writer stores sanitized truth for replay. + frames = [truth.copy() for _ in range(3)] + action = Action(np.array([.1, .2], dtype=np.float32)) + writer = LevelRecording(str(directory)) + writer.begin_episode(0, "level_start") + writer.append_step(0, 0, action) + writer.append_step(0, 1, action) + writer.flush([{ + "episode": 0, + "end": "in_progress", + "states": frames, + "actions": [action, action] + }], frames[-1], 0, 2) + writer.close() + + +def _projection() -> RecordingProjection: + return RecordingProjection(excluded_metadata=( + ("body_velocities", + "Replay metadata excluded from this observation model"), )) + + +def test_recording_roundtrip_and_artifact_snapshot(tmp_path: Path) -> None: + """Load the actual writer format, keep joints, and freeze original + bytes.""" + directory = tmp_path / "L01" + _write_level(directory) + paths = [directory / "episodes.pkl", directory / "actions.jsonl"] + original = [path.read_bytes() for path in paths] + level = load_recorded_level(directory, + "run-1", + ObservationNoise(position=.1), + _projection(), + observation_seed=4, + level_index=0) + assert [path.read_bytes() for path in paths] == original + episode, = level.data.episodes + assert len(episode.actions) == 2 and len(episode.observations) == 3 + assert json.loads(episode.episode_id) == ["run-1", "L01", 0] + key = ("__proprioception__", "joint_positions", "1") + assert dict(episode.observations[0].values)[key] == .2 + assert all("body_velocities" not in feature.key + for feature in level.sensor.features) + predictions = [dict(frame.values) for frame in episode.observations] + assert np.isfinite( + level.data.log_likelihood(level.sensor, + {episode.episode_id: predictions})) + predictions[1][key] += .01 + assert level.data.log_likelihood( + level.sensor, {episode.episode_id: predictions}) == -np.inf + snapshot = level.source.save(tmp_path / "artifacts") + assert snapshot.read_bytes() == level.source.manifest + assert level.source.save(tmp_path / "artifacts") == snapshot + paths[0].write_bytes(b"later file edit") + episode_source = next(a for a in level.source.artifacts + if a.name == "episodes.pkl") + assert episode_source.content == original[0] + assert (tmp_path / "artifacts" / + episode_source.digest).read_bytes() == original[0] + + +def test_reset_and_flush_alignment(tmp_path: Path) -> None: + """Unflushed steps and missing reset markers cannot create new evidence.""" + directory = tmp_path / "L01" + _write_level(directory) + actions = directory / "actions.jsonl" + original = actions.read_text() + actions.write_text(original + json.dumps({ + "ep": 0, + "i": 2, + "a": [.1, .2] + }) + "\n") + with pytest.raises(ValueError, match="flush before snapshot"): + load_recorded_level(directory, + "run", + ObservationNoise(), + _projection(), + observation_seed=4, + level_index=0) + actions.write_text("\n".join(original.splitlines()[1:]) + "\n") + with pytest.raises(ValueError, match="reset marker"): + load_recorded_level(directory, + "run", + ObservationNoise(), + _projection(), + observation_seed=4, + level_index=0) + actions.write_text(original) + level = load_recorded_level(directory, + "run", + ObservationNoise(), + _projection(), + observation_seed=4, + level_index=0) + with pytest.raises(ValueError, match="Duplicate reset episode"): + combine_recorded_levels((level, level)) + other = load_recorded_level(directory, + "other-run", + ObservationNoise(), + _projection(), + observation_seed=4, + level_index=0) + combined = combine_recorded_levels((level, other)) + assert len(combined.data.episodes) == 2 + + +def test_metadata_requires_explicit_semantics(tmp_path: Path) -> None: + """No inferred memory or extra physical metadata silently enters a fit.""" + directory = tmp_path / "L01" + _write_level(directory) + with pytest.raises(ValueError, match="Unclassified recording metadata"): + load_recorded_level(directory, + "run", + ObservationNoise(), + RecordingProjection(), + observation_seed=4, + level_index=0) + with pytest.raises(ValueError, match="needs a reason"): + RecordingProjection(excluded_metadata=(("body_velocities", ""), )) + with pytest.raises(ValueError, match="cannot be excluded"): + RecordingProjection(excluded_metadata=(("joint_positions", + "ignore"), )) + state = PyBulletState({}, + simulator_state={"joint_positions": [.1]}, + latent={}) + with pytest.raises(ValueError, match="sanitized"): + RecordingProjection().observe(0, state) + state.latent = None + state.simulator_state = {"joint_positions": [np.nan]} + with pytest.raises(ValueError, match="finite"): + RecordingProjection().observe(0, state) + + +def test_artifact_identity_and_collision(tmp_path: Path) -> None: + """Changing a dependency changes identity; old snapshots cannot be + replaced.""" + program = SourceArtifact("simulator", b"program") + dependency = SourceArtifact("dependency", b"version1") + bundle = ArtifactBundle((program, dependency)) + assert bundle.digest == ArtifactBundle((dependency, program)).digest + assert bundle.digest != ArtifactBundle( + (program, SourceArtifact("dependency", b"version2"))).digest + manifest = bundle.save(tmp_path) + manifest.write_bytes(b"corrupted") + with pytest.raises(ValueError, match="Artifact content mismatch"): + bundle.save(tmp_path) + + +def test_recording_noise_coordinates_and_identity(tmp_path: Path) -> None: + """Keyed observations change with run seed and cannot use a wrong level.""" + directory = tmp_path / "L01" + _write_level(directory) + noise = ObservationNoise(position=.1) + first = load_recorded_level(directory, + "run", + noise, + _projection(), + observation_seed=4, + level_index=0) + again = load_recorded_level(directory, + "run", + noise, + _projection(), + observation_seed=4, + level_index=0) + other = load_recorded_level(directory, + "run", + noise, + _projection(), + observation_seed=5, + level_index=0) + assert first == again + assert first.data.digest != other.data.digest + assert first.sensor.digest == other.sensor.digest + assert first.source.digest != other.source.digest + key = ("box", "box", "x") + values = [dict(o.values)[key] for o in first.data.episodes[0].observations] + assert len(set(values)) == 3 + assert all(v != pytest.approx(.3) for v in values) + with pytest.raises(ValueError, match="Level directory"): + load_recorded_level(directory, + "run", + noise, + _projection(), + observation_seed=4, + level_index=1) + with pytest.raises(ValueError, match="nonnegative integers"): + load_recorded_level(directory, + "run", + noise, + _projection(), + observation_seed=-1, + level_index=0) diff --git a/tests/code_sim_learning/test_inference_replay.py b/tests/code_sim_learning/test_inference_replay.py new file mode 100644 index 000000000..044d3c998 --- /dev/null +++ b/tests/code_sim_learning/test_inference_replay.py @@ -0,0 +1,441 @@ +"""Offline candidates retain motion and memory without changing legacy fits.""" +# pylint: disable=protected-access +from dataclasses import replace +from typing import Any, ClassVar, Dict, List + +import numpy as np +import pybullet as p +import pytest + +from predicators import utils +from predicators.code_sim_learning.commands import ApplyForce, CommandBuffer +from predicators.code_sim_learning.fit_space import ParamSpec +from predicators.code_sim_learning.inference_replay import \ + capture_replay_state, replay_candidate, replay_initialized_candidate +from predicators.code_sim_learning.rollout_env import rollout_states +from predicators.envs.pybullet_balloons import PyBulletBalloonsEnv +from predicators.envs.pybullet_bridge import PyBulletBridgeEnv +from predicators.ground_truth_models.balloons.gt_simulator_env import \ + BalloonsResidualEnv +from predicators.pybullet_helpers.objects import create_object +from predicators.structs import Action + + +class _MovingModel(BalloonsResidualEnv): + """Visible physics with a recurrent counter and no hidden balloon force.""" + + AGENT_PARAM_SPECS: ClassVar[List[Any]] = [ParamSpec("rate", .25)] + MODEL_STATE_INIT: ClassVar[Dict[str, Any]] = {"events": []} + + @classmethod + def update_model_state(cls, observation, model_state, params, action): + del observation, action + model_state["events"].append(params["rate"]) + + def _domain_specific_step(self): + """Let ordinary engine gravity move the box.""" + + +class _MetadataModel(_MovingModel): + """A candidate environment that writes its own object metadata.""" + + def _set_state(self, state): + super()._set_state(state) + for obj in state: + obj.sim_data["replay_marker"] = "candidate" + + +class _GroupedModel(_MovingModel): + """A supplementary physical fan has no public object of its own.""" + + def __init__(self): + super().__init__() + self.rotor_body = create_object( + "urdf/partnet_mobility/fan/101450/mobility.urdf", + position=(5., 5., 5.), + scale=.08, + use_fixed_base=True, + physics_client_id=self._physics_client_id) + + +@pytest.fixture(name="moving_env") +def _moving_env(): + utils.reset_config({"env": "pybullet_balloons", "seed": 0}) + source = PyBulletBalloonsEnv(use_gui=False) + env = _MovingModel(use_gui=False) + try: + initial = source.level_state(0, [0, 1], (.7, .75)) + initial.set(source._box, "z", 1.1) + env._set_state(initial) + p.resetBaseVelocity(env._box.id, [0, 0, .5], [0, 0, 0], + physicsClientId=env._physics_client_id) + robot = env._pybullet_robot + joint = robot.arm_joints[0] + position = p.getJointState(robot.robot_id, + joint, + physicsClientId=env._physics_client_id)[0] + p.resetJointState(robot.robot_id, + joint, + position, + targetVelocity=.12, + physicsClientId=env._physics_client_id) + env._current_observation = env._get_state() + yield env + finally: + env.dispose() + source.dispose() + + +def test_moving_candidate_replay_and_branching(moving_env): + """Replay actual engine motion, a contact, and a recurrent memory + prefix.""" + env = moving_env + initial = capture_replay_state(env) + action = Action( + np.array(initial.state.simulator_state["joint_positions"], + dtype=np.float32)) + observed = [initial] + for _ in range(30): + env.step(action) + observed.append(capture_replay_state(env)) + first = replay_candidate(_MovingModel, initial, [action] * 30, {}) + second = replay_candidate(_MovingModel, initial, [action] * 30, {}) + assert len(first) == len(observed) + assert first[0].robot_joints == initial.robot_joints + assert first[0].state.simulator_state["body_velocities"] == \ + initial.state.simulator_state["body_velocities"] + for expected, actual, repeated in zip(observed, first, second): + assert actual.state.latent == expected.state.latent + assert actual.state.allclose(repeated.state) + assert actual.robot_joints == repeated.robot_joints + assert abs( + actual.state.get(env._box, "z") - + expected.state.get(env._box, "z")) < 1e-5 + assert first[-1].state.get(env._box, "z") < 1.0 # Reaches table contact. + # A state at a moving prefix is a valid branch, including accumulated + # memory. Mutating a returned branch cannot change its sibling/input. + resumed = replay_candidate(_MovingModel, first[3], [action] * 6, {}) + assert resumed[-1].state.latent == first[9].state.latent + assert abs(resumed[-1].state.get(env._box, "z") - + first[9].state.get(env._box, "z")) < 1e-3 + resumed[0].state.latent["events"].append(99) + assert first[3].state.latent == {"events": [.25] * 3} + assert initial.state.latent == {"events": []} + assert _MovingModel.MODEL_STATE_INIT == {"events": []} + # The incumbent estimator intentionally still assumes a rest start. + legacy = rollout_states(_MovingModel, initial.state, [action] * 6, {}) + assert max( + abs(a.state.get(env._box, "z") - b.get(env._box, "z")) + for a, b in zip(first[1:], legacy)) > .005 + + +@pytest.fixture(name="grouped_candidate") +def _grouped_candidate(moving_env): + env = _GroupedModel() + try: + env._set_state(moving_env._get_state()) + pcid = env._physics_client_id + clip = env._clips[0] + limit = p.getJointInfo(clip.id, clip.joint_id, physicsClientId=pcid)[9] + p.resetJointState(clip.id, + clip.joint_id, + .025 * limit, + targetVelocity=.02, + physicsClientId=pcid) + for joint in range(p.getNumJoints(env.rotor_body, + physicsClientId=pcid)): + if p.getJointInfo(env.rotor_body, joint, + physicsClientId=pcid)[2] != p.JOINT_FIXED: + p.resetJointState(env.rotor_body, + joint, + .7, + targetVelocity=.3, + physicsClientId=pcid) + initial = capture_replay_state(env) + assert env.rotor_body not in [obj.id for obj in env._objects] + assert env.rotor_body in [ + b.body_id for b in initial.articulated_bodies + ] + yield initial + finally: + env.dispose() + + +def test_nonrobot_joint_replay_including_unobserved_body(grouped_candidate): + """A moving lever and a grouped rotor survive a fresh candidate replay.""" + initial = grouped_candidate + action = Action( + np.asarray(initial.state.simulator_state["joint_positions"], + dtype=np.float32)) + first = replay_candidate(_GroupedModel, initial, [action] * 3, {}) + second = replay_candidate(_GroupedModel, initial, [action] * 3, {}) + assert first[0].articulated_bodies == initial.articulated_bodies + assert any(velocity != 0 for body in initial.articulated_bodies + for _, velocity in body.joints) + for left, right in zip(first, second): + assert left.articulated_bodies == right.articulated_bodies + assert left.state.allclose(right.state) + + +@pytest.mark.parametrize( + "problem", ["missing", "duplicate", "layout", "count", "nonfinite"]) +def test_nonrobot_joint_replay_rejects_incomplete_state( + grouped_candidate, problem): + """Malformed topology or motion cannot silently become a reset default.""" + initial = grouped_candidate + records = initial.articulated_bodies + if problem == "missing": + records = records[:-1] + elif problem == "duplicate": + records = records + (records[-1], ) + elif problem == "layout": + records = (replace(records[0], body_names=("other", "asset")),) + \ + records[1:] + else: + joints = records[0].joints[:-1] if problem == "count" else \ + ((float("nan"), 0.),) + records[0].joints[1:] + records = (replace(records[0], joints=joints), ) + records[1:] + with pytest.raises(ValueError, match="articulated body"): + replay_candidate(_GroupedModel, + replace(initial, articulated_bodies=records), [], {}) + + +@pytest.mark.parametrize("missing", ["velocity", "memory", "joints"]) +def test_candidate_requires_explicit_missing_state(moving_env, missing): + """Unavailable state fails explicitly and the temporary world is freed.""" + initial = capture_replay_state(moving_env) + if missing == "velocity": + initial.state.simulator_state["body_velocities"].pop( + moving_env._box.name) + elif missing == "memory": + initial.state.latent = None + else: + initial = replace(initial, robot_joints=()) + clients = [] + + def factory(): + env = _MovingModel() + clients.append(env._physics_client_id) + return env + + with pytest.raises(ValueError, match="Replay requires"): + replay_candidate(factory, initial, [], {}) + assert clients and not p.isConnected(clients[0]) + + +def test_candidate_joint_state_must_be_consistent(moving_env): + """Duplicated joint positions cannot silently disagree.""" + initial = capture_replay_state(moving_env) + initial.state.simulator_state["joint_positions"][0] += .1 + with pytest.raises(ValueError, match="joint positions disagree"): + replay_candidate(_MovingModel, initial, [], {}) + + +def test_candidate_owns_object_metadata(moving_env): + """A candidate's restore hook cannot modify its input or source world.""" + initial = capture_replay_state(moving_env) + output, = replay_candidate(_MetadataModel, initial, [], {}) + assert all(obj.sim_data["replay_marker"] == "candidate" + for obj in output.state if obj.type.name != "robot") + assert all("replay_marker" not in obj.sim_data for obj in initial.state) + assert all("replay_marker" not in obj.sim_data + for obj in moving_env._objects) + + +def test_captured_candidate_is_independent_of_later_source_changes(moving_env): + """A saved candidate cannot change when its source world moves on.""" + initial = capture_replay_state(moving_env) + box = next(obj for obj in moving_env._objects if obj.type.name == "box") + box.sim_data["later_epoch"] = 1 + captured_box = next(obj for obj in initial.state if obj == box) + assert "later_epoch" not in captured_box.sim_data + + +def test_candidate_rejects_unknown_attachment(moving_env): + """Inference must not silently drop an impossible commanded attachment.""" + initial = capture_replay_state(moving_env) + initial.state.simulator_state["command_welds"] = [(moving_env._box.name, + "missing_object")] + with pytest.raises(ValueError, match="two known physical objects"): + replay_candidate(_MovingModel, initial, [], {}) + + +def test_candidate_restores_command_attachments(): + """A moving welded assembly restores its topology and both velocities.""" + utils.reset_config({ + "env": "pybullet_bridge", + "seed": 0, + "num_train_tasks": 1, + "num_test_tasks": 0 + }) + + def factory(): + return PyBulletBridgeEnv(use_gui=False, skip_residual_dynamics=True) + + env = factory() + try: + state = env.get_train_tasks()[0].init.copy() + first, second = env._spans[:2] + for i, obj in enumerate((first, second)): + state.set(obj, "x", + .65 + i * (2 * env.span_half_extents[0] + .002)) + state.set(obj, "y", 1.30) + state.set(obj, "z", env.table_height + env.span_half_extents[2]) + state.set(obj, "yaw", 0.) + env._set_state(state) + env._current_observation = env._get_state() + action = Action( + np.array(env._current_observation.joint_positions, + dtype=np.float32)) + for _ in range(6): + commands = CommandBuffer() + commands.attach(first, second) + commands.apply_force(first, (0., 0., 3.)) + env.queue_residual_commands(commands.commands) + env.step(action) + initial = capture_replay_state(env) + assert initial.state.simulator_state["command_welds"] + restored, = replay_candidate(factory, initial, [], {}) + assert restored.state.simulator_state["command_welds"] == \ + initial.state.simulator_state["command_welds"] + assert restored.state.simulator_state["body_velocities"] == \ + initial.state.simulator_state["body_velocities"] + for obj in (first, second): + for feature in ("x", "y", "z"): + assert abs( + restored.state.get(obj, feature) - + initial.state.get(obj, feature)) < 1e-6 + finally: + env.dispose() + + +class _QueuedForceModel(_MovingModel): + """A force and attachment remain queued at each action boundary.""" + + def _domain_specific_step(self): + commands = CommandBuffer() + commands.apply_force(self._box, (0., 0., 4.)) + commands.attach(self._balloons[0], self._box) + self.queue_residual_commands(commands.commands) + + +def test_replay_retains_pending_commands_and_unobserved_orientation(): + """Continue a real tilted, welded assembly with an outstanding force.""" + utils.reset_config({"env": "pybullet_balloons", "seed": 0}) + source = PyBulletBalloonsEnv(use_gui=False) + env = _QueuedForceModel() + try: + state = source.level_state(0, [0, 1], (.7, .75)) + state.set(source._box, "z", 1.1) + env._set_state(state) + p.resetBasePositionAndOrientation( + env._box.id, + (state.get(source._box, "x"), state.get(source._box, "y"), 1.1), + p.getQuaternionFromEuler((.1, .2, .3)), + physicsClientId=env._physics_client_id) + env._current_observation = env._get_state() + action = Action( + np.array(env._current_observation.joint_positions, + dtype=np.float32)) + for _ in range(3): + env.step(action) + initial = capture_replay_state(env) + predicted = replay_candidate(_QueuedForceModel, initial, [], {})[0] + # These quantities are candidate state, never public observations. + assert predicted.pending_commands == initial.pending_commands + assert predicted.command_welds == initial.command_welds + for name, pose in initial.body_poses.items(): + assert np.allclose(predicted.body_poses[name][0], + pose[0], + rtol=0, + atol=1e-12) + assert np.allclose(predicted.body_poses[name][1], + pose[1], + rtol=0, + atol=1e-12) + finally: + env.dispose() + source.dispose() + + +def test_prefix_replay_keeps_engine_history_and_memory(moving_env): + """A continued contact trajectory equals its uninterrupted candidate.""" + initial = capture_replay_state(moving_env) + hold = Action( + np.array(initial.state.simulator_state["joint_positions"], + dtype=np.float32)) + full = replay_candidate(_MovingModel, initial, [hold] * 30, {}) + branch = replay_candidate(_MovingModel, + initial, [hold] * 19, {}, + prefix=[hold] * 11) + assert len(branch) == 20 + for expected, actual in zip(full[11:], branch): + for obj in expected.state: + np.testing.assert_array_equal(expected.state[obj], + actual.state[obj]) + assert expected.robot_joints == actual.robot_joints + assert expected.body_poses == actual.body_poses + assert expected.pending_commands == actual.pending_commands + assert expected.command_welds == actual.command_welds + assert expected.state.latent == actual.state.latent + assert initial.state.latent == {"events": []} + + +@pytest.mark.parametrize("invalid", ["poses", "command", "weld"]) +def test_replay_rejects_incomplete_physical_state(moving_env, invalid): + """Missing physical quantities must not become implicit zero defaults.""" + initial = capture_replay_state(moving_env) + if invalid == "poses": + initial = replace(initial, body_poses={}) + elif invalid == "command": + initial = replace(initial, + pending_commands=(ApplyForce("missing", + (0., 0., 1.)), )) + else: + initial.state.simulator_state["command_welds"] = [ + (moving_env._balloons[0].name, moving_env._box.name) + ] + with pytest.raises(ValueError, match="Replay"): + replay_candidate(_MovingModel, initial, [], {}) + + +def test_explicit_initializer_replays_prefix_under_candidate_parameters( + moving_env): + """The initializer and full prefix use the requested candidate + parameters.""" + initial = capture_replay_state(moving_env) + hold = Action( + np.array(initial.state.simulator_state["joint_positions"], + dtype=np.float32)) + seen = [] + + def initialize(env): + seen.append(env.agent_param("rate")) + env._set_state(initial.state) + + first = replay_initialized_candidate(_MovingModel, + initialize, [hold], {"rate": .5}, + prefix=[hold] * 3) + second = replay_initialized_candidate(_MovingModel, + initialize, [hold], {"rate": .75}, + prefix=[hold] * 3) + assert seen == [.5, .75] + assert first[0].state.latent == {"events": [.5] * 3} + assert first[1].state.latent == {"events": [.5] * 4} + assert second[0].state.latent == {"events": [.75] * 3} + assert second[1].state.latent == {"events": [.75] * 4} + assert initial.state.latent == {"events": []} + + +def test_initializer_failure_disposes_fresh_world(): + """A rejected candidate root must release its engine client.""" + utils.reset_config({"env": "pybullet_balloons", "seed": 0}) + clients = [] + + def initialize(env): + clients.append(env._physics_client_id) + raise ValueError("invalid candidate root") + + with pytest.raises(ValueError, match="invalid candidate root"): + replay_initialized_candidate(_MovingModel, initialize, [], {}) + assert clients and not p.isConnected(clients[0]) diff --git a/tests/code_sim_learning/test_inference_result.py b/tests/code_sim_learning/test_inference_result.py new file mode 100644 index 000000000..a2d07ecf1 --- /dev/null +++ b/tests/code_sim_learning/test_inference_result.py @@ -0,0 +1,32 @@ +"""Compatibility at the versioned legacy inference boundary.""" +import pickle +from dataclasses import asdict + +import numpy as np + +from predicators.code_sim_learning.fit_space import FitResult +from predicators.code_sim_learning.orchestrator import SysIdOutcome + + +def test_no_fit_and_historical_outcomes_remain_unpublished(): + """Pinned estimates must not become selected values or invented certainty. + + Historical outcome dictionaries have no inference member. The + adapter must work after loading them without changing their stored + payload. + """ + result = FitResult(["gain"], np.array([[1.0]]), np.zeros(1)) + outcome = SysIdOutcome(result, {}, {"gain": 1.0}, {}, 2, 0, [3.0, 4.0], + 5.0, float("nan")) + before = pickle.dumps(outcome) + restored = pickle.loads(before) + inference = restored.inference + assert inference.point_estimate == {"gain": 1.0} + assert inference.selected_parameters == {} + assert inference.parameter_diagnostics == {} + assert inference.num_survivors == 0 + assert inference.num_segments == 2 + assert asdict(inference)["schema_version"] == 1 + assert asdict(inference)["uncertainty_kind"] == "legacy_widths" + assert "inference" not in restored.__dict__ + assert pickle.dumps(outcome) == before diff --git a/tests/code_sim_learning/test_inference_runtime.py b/tests/code_sim_learning/test_inference_runtime.py new file mode 100644 index 000000000..2988afec8 --- /dev/null +++ b/tests/code_sim_learning/test_inference_runtime.py @@ -0,0 +1,111 @@ +"""Offline workers must not inherit mutable sidecars or ambient settings.""" +import json +import os +import subprocess +import sys +from dataclasses import replace +from pathlib import Path + +import pytest + +from predicators.code_sim_learning.inference_runtime import RuntimeFile, \ + RuntimeInputs + + +def test_isolated_worker_input_identity_and_source_edits( + tmp_path: Path) -> None: + """Actual child processes get frozen inputs and a complete environment.""" + program = b"""import json, os +from pathlib import Path +p = Path('model_params.json') +data = json.loads(p.read_text()) if p.exists() else {'lift': 1.} +print(json.dumps([data['lift'], os.environ.get('INFERENCE_AMBIENT_TEST'), + os.environ.get('DECLARED_MODE')])) +""" + source = tmp_path / "source.json" + source.write_text(json.dumps({"lift": 2.})) + present = RuntimeInputs((RuntimeFile( + "worker.py", program), RuntimeFile.read("model_params.json", source)), + (("DECLARED_MODE", "offline"), )) + absent = replace(present, + files=(RuntimeFile("worker.py", program), + RuntimeFile("model_params.json", None))) + assert absent.digest != present.digest + assert absent.digest != replace(absent, environment=()).digest + source.write_text(json.dumps({"lift": 999.})) + old = os.environ.get("INFERENCE_AMBIENT_TEST") + os.environ["INFERENCE_AMBIENT_TEST"] = "must not be inherited" + try: + for label, inputs, expected in (("present", present, 2.), + ("absent", absent, 1.)): + work = tmp_path / label + inputs.materialize(work) + result = subprocess.run([sys.executable, "worker.py"], + cwd=work, + env=inputs.environment_dict, + check=True, + capture_output=True, + text=True) + assert json.loads(result.stdout) == [expected, None, "offline"] + inputs.verify(work) + manifest = inputs.artifacts.save(tmp_path / "artifacts") + assert manifest.read_bytes() == inputs.artifacts.manifest + finally: + if old is None: + os.environ.pop("INFERENCE_AMBIENT_TEST", None) + else: + os.environ["INFERENCE_AMBIENT_TEST"] = old + + +@pytest.mark.parametrize( + "mutation", ["change", "remove", "add", "absent", "link", "directory"]) +def test_runtime_mutation_is_detected(tmp_path: Path, mutation: str) -> None: + """Changed or newly created inputs invalidate a candidate result.""" + inputs = RuntimeInputs( + (RuntimeFile("nested/data.json", + b"{}"), RuntimeFile("optional.json", None))) + work = tmp_path / "worker" + inputs.materialize(work) + target = work / "nested/data.json" + if mutation == "change": + target.write_bytes(b"changed") + elif mutation == "remove": + target.unlink() + elif mutation == "add": + (work / "new.json").write_bytes(b"new") + elif mutation == "absent": + (work / "optional.json").write_bytes(b"{}") + elif mutation == "link": + target.unlink() + target.symlink_to(tmp_path / "outside") + else: + (work / "new-directory").mkdir() + with pytest.raises(ValueError, match="runtime input"): + inputs.verify(work) + + +def test_runtime_contract_validation(tmp_path: Path) -> None: + """Missing source is not a silently recorded absent dependency.""" + with pytest.raises(ValueError, match="present regular"): + RuntimeFile.read("optional", tmp_path / "missing") + for name in ("", ".", "../escape", "a/../b", "/absolute", "a//b", "a\\b"): + with pytest.raises(ValueError, match="relative paths"): + RuntimeFile(name, b"") + with pytest.raises(ValueError, match="Duplicate runtime file"): + RuntimeInputs((RuntimeFile("a", None), RuntimeFile("a", b""))) + with pytest.raises(ValueError, match="contain one another"): + RuntimeInputs((RuntimeFile("a", None), RuntimeFile("a/b", b""))) + with pytest.raises(ValueError, match="Duplicate runtime environment"): + RuntimeInputs((), (("A", "x"), ("A", "y"))) + with pytest.raises(ValueError, match="environment entry"): + RuntimeInputs((), (("A=B", "x"), )) + inputs = RuntimeInputs(()) + work = tmp_path / "nonempty" + work.mkdir() + (work / "ambient.json").write_bytes(b"{}") + with pytest.raises(ValueError, match="must be empty"): + inputs.materialize(work) + link = tmp_path / "link" + link.symlink_to(work, target_is_directory=True) + with pytest.raises(ValueError, match="symlink"): + inputs.materialize(link) diff --git a/tests/code_sim_learning/test_inference_sampling.py b/tests/code_sim_learning/test_inference_sampling.py new file mode 100644 index 000000000..f6eac518b --- /dev/null +++ b/tests/code_sim_learning/test_inference_sampling.py @@ -0,0 +1,221 @@ +"""Statistical references for the offline fixed-prior batch sampler.""" +import math +from dataclasses import replace + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_data import EpisodeData, \ + InferenceData, InferenceIdentity, Observation, SensorModel, \ + content_digest +from predicators.code_sim_learning.inference_sampling import BoxPrior, \ + SamplerConfig, sample_batch +from predicators.observation_noise import ObservationNoise, step_rng +from predicators.structs import Object, State, Type + + +def _identity(prior: BoxPrior) -> InferenceIdentity: + digest = content_digest(b"fixed synthetic reference") + return InferenceIdentity(digest, digest, digest, prior.digest, digest) + + +def test_correlated_dynamics_and_initial_state_against_grid() -> None: + """A velocity and uncertain starting position retain their tradeoff. + + The third coordinate has no observations and must retain its + original uniform prior. Repeating the entire batch gives identical + output, not a second use of the data with the previous posterior as + prior. + """ + prior = BoxPrior(("theta.velocity", "episode0.position", "theta.unused"), + ((-1, 1), (-1, 1), (-1, 1))) + times = np.arange(4) + data = np.array([.19, .48, .68, 1.03]) + sigma = .15 + + def likelihood(v: np.ndarray) -> float: + return float(-.5 * np.sum(((data - v[1] - v[0] * times) / sigma)**2)) + + axis = np.linspace(-1, 1, 401) + velocity, start = np.meshgrid(axis, axis, indexing="ij") + grid_log = np.zeros_like(velocity) + for t, observed in zip(times, data): + grid_log -= .5 * ((observed - start - velocity * t) / sigma)**2 + grid_weights = np.exp(grid_log - grid_log.max()) + grid_weights /= grid_weights.sum() + means = np.array([(grid_weights * velocity).sum(), + (grid_weights * start).sum()]) + covariance = np.array([[(grid_weights * (a - ma) * (b - mb)).sum() + for b, mb in zip((velocity, start), means)] + for a, ma in zip((velocity, start), means)]) + config = SamplerConfig(particles=1800, + temperatures=32, + moves=5, + proposal_scale=.04, + max_evaluations=300000) + result = sample_batch(prior, _identity(prior), likelihood, config, seed=8) + assert result.status == "complete" + samples = np.array(result.samples) + weights = np.asarray(result.weights) + np.testing.assert_allclose(np.average(samples[:, :2], + axis=0, + weights=weights), + means, + atol=.018) + np.testing.assert_allclose(np.cov(samples[:, :2].T, aweights=weights), + covariance, + rtol=.25, + atol=.001) + cov = np.cov(samples[:, :2].T, aweights=weights) + assert cov[0, 1] / math.sqrt(cov[0, 0] * cov[1, 1]) < -.7 + unused_mean = np.average(samples[:, 2], weights=weights) + assert abs(unused_mean) < .1 + assert abs( + np.average((samples[:, 2] - unused_mean)**2, weights=weights) - + 1 / 3) < .06 + assert sum(result.weights) == pytest.approx(1) + quantiles = result.marginal_quantiles("theta.velocity") + for quantile, probability in zip(quantiles, (.05, .5, .95)): + assert weights[samples[:, 0] < quantile].sum() < probability + assert weights[samples[:, 0] <= quantile].sum() >= probability + again = sample_batch(prior, _identity(prior), likelihood, config, seed=8) + assert again == result + + +@pytest.mark.parametrize("seed", [19, 0, 1, 2]) +def test_bimodal_reference_and_prior_support(seed: int) -> None: + """Broad initialization can represent two modes without clipping bounds.""" + prior = BoxPrior(("theta", ), ((-2, 2), )) + result = sample_batch( + prior, _identity(prior), lambda x: -.5 * ((x[0]**2 - 1) / .12)**2, + SamplerConfig(particles=1400, moves=4, max_evaluations=200000), seed) + assert result.status == "complete" + values = np.array(result.samples)[:, 0] + assert np.all(np.abs(values) <= 2) + assert .35 < np.average(values > 0, weights=result.weights) < .65 + assert .95 < np.average(np.abs(values), weights=result.weights) < 1.04 + assert min(result.effective_sample_sizes) < result.config.particles + + +def test_failure_results_do_not_publish_partial_posteriors() -> None: + """Finite support failure and budget exhaustion are numerical outcomes.""" + prior = BoxPrior(("x", ), ((0, 1), )) + identity = _identity(prior) + config = SamplerConfig(particles=16, max_evaluations=3) + result = sample_batch(prior, identity, lambda _: 0., config, 0) + assert result.status == "budget_exhausted" and result.evaluations == 3 + assert not result.samples and not result.weights + assert result.initial_finite == 3 + with pytest.raises(ValueError, match="No completed"): + result.marginal_quantiles("x") + config = replace(config, max_evaluations=19) + result = sample_batch(prior, identity, lambda _: 0., config, 0) + assert result.status == "budget_exhausted" and result.evaluations == 19 + assert result.completed_temperature == 0 + assert not result.samples + config = replace(config, max_evaluations=5000) + result = sample_batch(prior, identity, lambda _: -math.inf, config, 0) + assert result.status == "no_particle_support" + assert result.initial_finite == 0 and result.evaluations == 16 + assert not result.samples + + def broken(_: np.ndarray) -> float: + raise RuntimeError("simulator setup failed") + + with pytest.raises(RuntimeError, match="setup failed"): + sample_batch(prior, identity, broken, config, 0) + with pytest.raises(ValueError, match="Prior differs"): + sample_batch(prior, replace(identity, prior=content_digest(b"edit")), + lambda _: 0., config, 0) + with pytest.raises(ValueError, match="positive widths"): + BoxPrior(("x", ), ((1, 1), )) + with pytest.raises(ValueError, match="positive integers"): + SamplerConfig(moves=0) + + +def test_stationary_injector_to_posterior() -> None: + """Recorded noise plus the ledger recovers the stationary Gaussian mean. + + This uses the actual sensor injector and full batch likelihood. The + starting position is unknown, not pinned to the first noisy reading. + A broad uniform prior makes its boundary correction negligible here. + """ + obj = Object("box", Type("box", ["x"])) + truth = State({obj: np.array([.35])}) + noise = ObservationNoise(position=.2) + sensor = SensorModel.from_state(truth, noise) + frames = tuple( + Observation.from_state(t, noise.perturb(truth, step_rng(42, 0, 0, t))) + for t in range(12)) + ledger = InferenceData((EpisodeData("reset0", ((0., ), ) * 11, + frames + (frames[0], )), )) + prior = BoxPrior(("reset0.position", ), ((-2., 2.), )) + identity = replace(_identity(prior), + data=ledger.digest, + sensor=sensor.digest) + key = ("box", "box", "x") + + def likelihood(candidate: np.ndarray) -> float: + return ledger.log_likelihood( + sensor, {"reset0": [{ + key: float(candidate[0]) + }] * len(frames)}) + + result = sample_batch(prior, + identity, + likelihood, + SamplerConfig(particles=1000, + temperatures=24, + moves=3), + seed=7) + assert result.status == "complete" + samples = np.array(result.samples)[:, 0] + expected_mean = np.mean([dict(frame.values)[key] for frame in frames]) + mean = np.average(samples, weights=result.weights) + variance = np.average((samples - mean)**2, weights=result.weights) + assert abs(mean - expected_mean) < .012 + assert math.sqrt(variance) == pytest.approx(.2 / math.sqrt(12), rel=.15) + + +def test_small_complete_batch_and_owned_candidates() -> None: + """Exercise completed-result plumbing without a statistical accuracy + claim.""" + prior = BoxPrior(("x", ), ((0., 1.), )) + identity = _identity(prior) + config = SamplerConfig(particles=32, + temperatures=3, + moves=2, + max_evaluations=300) + + def constant(candidate: np.ndarray) -> float: + candidate[:] = math.nan # Callback cannot mutate retained samples. + return 0. + + result = sample_batch(prior, identity, constant, config, 0) + assert result.status == "complete" + assert result.completed_temperature == 1. + assert 0 < result.accepted_moves <= result.attempted_moves == 192 + assert np.all(np.isfinite(result.samples)) + assert np.all((np.array(result.samples) >= 0) + & (np.array(result.samples) <= 1)) + assert len(result.weights) == len(result.samples) == 32 + assert sum(result.weights) == 1 + assert result.effective_sample_sizes == (32., ) * 3 + assert result.resampling_count == 0 + assert result.surviving_ancestors == 32 + assert result.prior == prior + lo, mid, hi = result.marginal_quantiles("x") + assert 0 <= lo <= mid <= hi <= 1 + with pytest.raises(ValueError, match="Quantile probabilities"): + result.marginal_quantiles("x", (1.5, )) + with pytest.raises(ValueError): + result.marginal_quantiles("unknown") + + +@pytest.mark.parametrize("value", [math.inf, math.nan]) +def test_invalid_likelihood_values(value: float) -> None: + """Invalid arithmetic is not a zero-likelihood model candidate.""" + prior = BoxPrior(("x", ), ((0., 1.), )) + with pytest.raises(ValueError, match="Likelihood returned"): + sample_batch(prior, _identity(prior), lambda _: value, + SamplerConfig(particles=2), 0) diff --git a/tests/code_sim_learning/test_inference_schedule.py b/tests/code_sim_learning/test_inference_schedule.py new file mode 100644 index 000000000..f66d125db --- /dev/null +++ b/tests/code_sim_learning/test_inference_schedule.py @@ -0,0 +1,58 @@ +"""Nonlinear tempering retains the conditional target and default behavior.""" +from dataclasses import replace + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_data import InferenceIdentity, \ + content_digest +from predicators.code_sim_learning.inference_sampling import BoxPrior, \ + ConditionedPrior, PriorPoint, SamplerConfig, sample_batch + + +def test_nonlinear_schedule_against_gaussian_reference() -> None: + """A narrow informed marginal and an uninformed prior both survive.""" + box = BoxPrior(("theta", "unused"), ((-1., 1.), ) * 2) + digest = content_digest(b"nonlinear temperature reference") + prior = ConditionedPrior(box.names, digest, digest, box) + identity = InferenceIdentity(digest, digest, digest, prior.digest, digest) + config = SamplerConfig(particles=1200, + temperatures=32, + moves=8, + max_evaluations=320000, + proposal_scale=.025, + proposal_blocks=((0, ), (1, )), + temperature_schedule=tuple( + (stage / 32)**3 for stage in range(1, 33))) + result = sample_batch(prior, + identity, + lambda x: -.5 * ((x[0] - .27) / .025)**2, + config, + 45, + condition=lambda x: PriorPoint(tuple(x), .8 * x[0])) + assert result.status == "complete" + samples = np.asarray(result.samples) + mean = np.average(samples, axis=0, weights=result.weights) + variance = np.average((samples - mean)**2, axis=0, weights=result.weights) + # Exponentially tilting a Gaussian shifts its mean by tilt * variance. + # The [-1, 1] truncation is over 29 standard deviations from this mean. + assert mean[0] == pytest.approx(.27 + .8 * .025**2, abs=.003) + assert variance[0] == pytest.approx(.025**2, rel=.15) + assert mean[1] == pytest.approx(0., abs=.08) + assert variance[1] == pytest.approx(1 / 3, abs=.05) + + +def test_schedule_validation_and_default_parity() -> None: + """Explicit linear stages exactly reproduce the original default.""" + for schedule in ((.5, ), (0., 1.), (.4, .9), (.8, .7), (1., 1.), + (float("nan"), 1.), (float("inf"), 1.), (True, 1.)): + with pytest.raises(ValueError, match="Temperature schedule"): + SamplerConfig(temperatures=2, temperature_schedule=schedule) + box = BoxPrior(("theta", ), ((-1., 1.), )) + digest = content_digest(b"default schedule parity") + identity = InferenceIdentity(digest, digest, digest, box.digest, digest) + config = SamplerConfig(particles=40, temperatures=4, moves=3) + explicit = replace(config, temperature_schedule=(.25, .5, .75, 1.)) + first = sample_batch(box, identity, lambda x: -20 * x[0]**2, config, 12) + second = sample_batch(box, identity, lambda x: -20 * x[0]**2, explicit, 12) + assert first == replace(second, config=config) diff --git a/tests/code_sim_learning/test_inference_support.py b/tests/code_sim_learning/test_inference_support.py new file mode 100644 index 000000000..6c7f282c5 --- /dev/null +++ b/tests/code_sim_learning/test_inference_support.py @@ -0,0 +1,66 @@ +"""Evidence checking distinguishes proven contradictions from search +failure.""" +from dataclasses import replace + +import pytest + +from predicators.code_sim_learning.inference_data import EpisodeData, \ + InferenceData, Observation, SensorFeature, SensorModel, content_digest +from predicators.code_sim_learning.inference_support import ConstantOutputs, \ + SupportAssessment, audit_constant_outputs + +KEY = ("block", "block", "glue") +DIGEST = content_digest(b"reviewed constant model and runtime") + + +def _audit(data: InferenceData, sensor: SensorModel) -> SupportAssessment: + declaration = ConstantOutputs(DIGEST, DIGEST, DIGEST, (KEY, )) + return audit_constant_outputs(data, + sensor, + declaration, + program_digest=DIGEST, + runtime_digest=DIGEST) + + +def test_exact_changes_give_witnesses_without_sampling() -> None: + """A frozen constant output cannot match both recorded exact values.""" + observations = tuple( + Observation(i, ((KEY, v), )) for i, v in enumerate((0., .2, 1.))) + data = InferenceData((EpisodeData("reset0", ((0., ), ) * 2, + observations), )) + result = _audit(data, SensorModel((SensorFeature(KEY, 0.), ))) + assert result.status == "model_inconsistent" + witness, = result.contradictions + assert (witness.first_step, witness.later_step) == (0, 1) + assert (witness.first_value, witness.later_value) == (0., .2) + assert result.data == data.digest + + +def test_resets_and_missing_data_do_not_manufacture_contradictions() -> None: + """A different reset may have a different constant and missing reads.""" + data = InferenceData( + tuple( + EpisodeData(str(i), ((0., ), ), (Observation(0, ()), + Observation(1, ((KEY, v), )))) + for i, v in enumerate((0., 1.)))) + result = _audit(data, SensorModel((SensorFeature(KEY, 0.), ))) + assert result.status == "not_disproved" and not result.contradictions + + +def test_invariant_scope_and_evidence_semantics_are_checked() -> None: + """Noise and conditioned external inputs cannot prove this + contradiction.""" + data = InferenceData(()) + for feature in (SensorFeature(KEY, .1), SensorFeature(KEY, 0., True)): + with pytest.raises(ValueError, match="exact predicted"): + _audit(data, SensorModel((feature, ))) + declaration = ConstantOutputs(DIGEST, DIGEST, DIGEST, (KEY, )) + with pytest.raises(ValueError, match="program/runtime"): + audit_constant_outputs(data, + SensorModel((SensorFeature(KEY, 0.), )), + replace(declaration, + program=content_digest(b"edited")), + program_digest=DIGEST, + runtime_digest=DIGEST) + with pytest.raises(ValueError, match="distinct"): + replace(declaration, keys=(KEY, KEY)) diff --git a/tests/code_sim_learning/test_inference_velocity_prior.py b/tests/code_sim_learning/test_inference_velocity_prior.py new file mode 100644 index 000000000..54718b588 --- /dev/null +++ b/tests/code_sim_learning/test_inference_velocity_prior.py @@ -0,0 +1,85 @@ +"""Physical velocity conditioning references with explicit rest support.""" +import math + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_conditioning import \ + ConditioningNumericalError, RestOrGaussianVelocityPrior, \ + UnsupportedConditioning + + +def test_speed_distribution_has_unit_mass() -> None: + """The atom and integrated positive radial density form one proper + prior.""" + prior = RestOrGaussianVelocityPrior(.3, .7) + rest = prior.condition_on_speed(0.) + assert rest.velocity == (0., 0., 0.) + assert rest.free_dimensions == 0 + assert math.exp(rest.log_observation_factor) == pytest.approx(.3) + step = 8 * prior.moving_sigma / 4000 + radii = (np.arange(4000) + .5) * step + moving_mass = sum( + math.exp( + prior.condition_on_speed(float(r), (.5, + .5)).log_observation_factor) + for r in radii) * step + assert moving_mass == pytest.approx(.7, abs=1e-10) + + +def test_positive_speed_preserves_uncertain_direction() -> None: + """Correct sphere coordinates are isotropic and keep the observed speed.""" + prior = RestOrGaussianVelocityPrior(.4, 2.) + count = 64 + grid = (np.arange(count) + .5) / count + points = [ + prior.condition_on_speed(1.7, (float(u), float(v))) for u in grid + for v in grid + ] + velocities = np.asarray([p.velocity for p in points]) + np.testing.assert_allclose(np.mean(velocities, axis=0), 0., atol=1e-12) + np.testing.assert_allclose(velocities.T @ velocities / len(points), + np.eye(3) * 1.7**2 / 3, + atol=3e-4) + assert all(p.free_dimensions == 2 for p in points) + assert max(p.speed_residual for p in points) < 1e-15 + assert len({p.log_observation_factor for p in points}) == 1 + + +def test_rest_evidence_informs_mixture_weight_not_moving_sigma() -> None: + """A uniform prior on rest mass becomes density 2*rho after exact rest.""" + mass = (np.arange(1000) + .5) / 1000 + weights = np.asarray([ + math.exp( + RestOrGaussianVelocityPrior( + float(rho), 1.).condition_on_speed(0.).log_observation_factor) + for rho in mass + ]) + assert np.average(mass, weights=weights) == pytest.approx(2 / 3, abs=1e-6) + for sigma in (.01, 1., 100.): + assert RestOrGaussianVelocityPrior(.3, sigma).condition_on_speed( + 0.).log_observation_factor == pytest.approx(math.log(.3)) + + +def test_speed_zero_is_not_an_epsilon_band() -> None: + """Zero-density boundary, zero prior support and invalid input differ.""" + with pytest.raises(UnsupportedConditioning, match="extension"): + RestOrGaussianVelocityPrior(0., 1.).condition_on_speed(0.) + point = RestOrGaussianVelocityPrior(1., + 1.).condition_on_speed(1., (.2, .7)) + assert point.log_observation_factor == -math.inf + prior = RestOrGaussianVelocityPrior(.3, 1.) + tiny = prior.condition_on_speed(1e-150, (.2, .7)) + assert tiny.free_dimensions == 2 + assert math.isfinite(tiny.log_observation_factor) + assert tiny.velocity != (0., 0., 0.) + assert prior.digest != RestOrGaussianVelocityPrior(.4, 1.).digest + with pytest.raises(ConditioningNumericalError, match="range"): + prior.condition_on_speed(1e200, (.2, .7)) + for mass, sigma in ((-.1, 1.), (1.1, 1.), (.2, 0.), (.2, math.inf)): + with pytest.raises(ValueError): + RestOrGaussianVelocityPrior(mass, sigma) + for speed, direction in ((-1., ()), (math.nan, ()), (0., (.2, .3)), + (1., ()), (1., (.2, math.nan)), (1., (.2, 1.1))): + with pytest.raises(ValueError): + prior.condition_on_speed(speed, direction) diff --git a/tests/code_sim_learning/test_observation_state.py b/tests/code_sim_learning/test_observation_state.py new file mode 100644 index 000000000..4bec921f5 --- /dev/null +++ b/tests/code_sim_learning/test_observation_state.py @@ -0,0 +1,60 @@ +"""Legacy comparison frames preserve only the supplied public observations.""" +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_data import Observation +from predicators.code_sim_learning.inference_recording import \ + RecordingProjection +from predicators.structs import Object, Type +from predicators.utils import PyBulletState + + +def test_public_frame_roundtrip_and_ownership() -> None: + """Reversed input handles and double-digit joint indices retain values.""" + kind = Type("body", ["y", "x"]) + a, b = Object("a", kind), Object("b", kind) + source = PyBulletState({ + b: np.array([.2, .1]), + a: np.array([.4, .3]) + }, + simulator_state={ + "joint_positions": list(range(12)), + "base_pose": ((1., 2., 3.), (0., 0., 0., 1.)) + }) + projection = RecordingProjection() + observation = projection.observe(9, source) + result = projection.to_state(observation, [b, a]) + assert list(result.data) == [a, b] + assert isinstance(result.simulator_state, dict) + assert result.simulator_state["joint_positions"] == list(range(12)) + assert result.privileged is None and result.latent is None + assert projection.observe(9, result) == observation + result.data[a][0] = 123. + assert source.data[a][0] == .4 + assert projection.observe(9, source) == observation + + +def test_incomplete_or_unknown_frames_are_rejected() -> None: + """A comparison must not silently supply missing state or drop evidence.""" + obj = Object("body", Type("body", ["x"])) + projection = RecordingProjection() + feature = ((obj.name, obj.type.name, "x"), .1) + joint = (("__proprioception__", "joint_positions", "0"), .2) + observation = Observation(0, (feature, joint)) + with pytest.raises(ValueError, match="Duplicate"): + projection.to_state(observation, [obj, obj]) + with pytest.raises(ValueError, match="Missing measured"): + projection.to_state(Observation(0, (joint, )), [obj]) + with pytest.raises(ValueError, match="Missing public joint"): + projection.to_state(Observation(0, (feature, )), [obj]) + for extra, message in (((("other", "body", "x"), .3), "Unknown measured"), + ((("__proprioception__", "velocity", "0"), .3), + "Unknown measured"), ((("__proprioception__", + "joint_positions", "02"), + .3), "Noncanonical"), + ((("__proprioception__", "joint_positions", "2"), + .3), "Discontinuous"), ((("__proprioception__", + "base_position", "0"), + .3), "Incomplete")): + with pytest.raises(ValueError, match=message): + projection.to_state(Observation(0, (feature, joint, extra)), [obj]) diff --git a/tests/code_sim_learning/test_orchestrator.py b/tests/code_sim_learning/test_orchestrator.py index 3d1c241cb..3c7a3d530 100644 --- a/tests/code_sim_learning/test_orchestrator.py +++ b/tests/code_sim_learning/test_orchestrator.py @@ -89,6 +89,21 @@ def test_run_rollout_sysid_fit_cache_and_report_isolation(): assert outcome.post_sse < outcome.pre_sse assert len(fit_cache) == 1 + # Reading the versioned adapter must not run inference or consume the + # caller's RNG; mutating its diagnostics cannot corrupt the cache. + count = num_rollouts_run() + inference = outcome.inference + assert num_rollouts_run() == count + assert inference.schema_version == 1 + assert inference.estimator == "legacy_rollout_sysid" + assert inference.uncertainty_kind == "legacy_widths" + assert inference.point_estimate == outcome.fitted + assert inference.selected_parameters == outcome.applied + inference.selected_parameters["gain"] = 99.0 + inference.parameter_diagnostics["gain"]["verdict"] = Verdict.INCONSISTENT + assert outcome.applied["gain"] != 99.0 + assert outcome.report["gain"]["verdict"].applies_fitted + # Identical call: zero new rollouts, same applied values. n_before = num_rollouts_run() outcome2 = run_rollout_sysid(env, [traj], [spec], @@ -121,6 +136,9 @@ def demote(_result, report, sse_fn): held={"gain": 1.5}) assert outcome3.from_cache assert outcome3.applied == {"gain": 1.5} + assert outcome3.inference.selected_parameters == {"gain": 1.5} + assert outcome3.inference.parameter_diagnostics["gain"]["verdict"] == \ + Verdict.INCONSISTENT assert outcome.report["gain"]["verdict"].applies_fitted # A different artifact key recomputes. diff --git a/tests/code_sim_learning/test_threshold_joints.py b/tests/code_sim_learning/test_threshold_joints.py new file mode 100644 index 000000000..0d8f8e75e --- /dev/null +++ b/tests/code_sim_learning/test_threshold_joints.py @@ -0,0 +1,112 @@ +"""Threshold observations retain endpoint mass and uncertain joint motion.""" +import math + +import numpy as np +import pytest + +from predicators.code_sim_learning.inference_data import InferenceIdentity, \ + content_digest +from predicators.code_sim_learning.inference_joints import \ + IncompatibleJointObservation, JointCoordinateBoundary, RestingJointPrior +from predicators.code_sim_learning.inference_sampling import BoxPrior, \ + ConditionedPrior, PriorPoint, SamplerConfig, sample_batch + + +@pytest.mark.parametrize("observed", [False, True]) +def test_threshold_conditional_moments(observed: bool) -> None: + """The exact event changes both the endpoint and motion mixture weights.""" + prior = RestingJointPrior("lever", -1., 2., (-1., 2.), .6, .4) + conditional = prior.condition_above(-.3, observed) + lower, upper = (-.3, 2.) if observed else (-1., -.3) + probability = .3 + .4 * (upper - lower) / 3 + rest = .3 / probability + endpoint = 2. if observed else -1. + expected_mean = rest * endpoint + (1 - rest) * (lower + upper) / 2 + expected_second = rest * endpoint**2 + \ + (1 - rest) * (lower**2 + lower * upper + upper**2) / 3 + assert math.exp(conditional.log_observation_factor) == \ + pytest.approx(probability) + values = np.array([ + conditional.lift(point) + for point in np.random.default_rng(42).random((8192, 2)) + ]) + assert np.all((values[:, 0] > -.3) == observed) + assert values[:, 0].mean() == pytest.approx(expected_mean, abs=.03) + assert (values[:, 0]**2).mean() == pytest.approx(expected_second, abs=.05) + assert np.mean(values[:, 0] == endpoint) == pytest.approx(rest, abs=.02) + assert values[:, 1].mean() == pytest.approx(0., abs=.01) + assert (values[:, 1]**2).mean() == \ + pytest.approx((1 - rest) * .4**2 / 3, abs=.003) + + +def test_parameter_dependent_threshold_keeps_event_evidence() -> None: + """The joint posterior matches analytic moments when the cut is unknown.""" + lever = RestingJointPrior("lever", 0., 1., (0., 1.), .6, .4) + box = BoxPrior(("threshold", "position_unit", "velocity_unit"), + ((0., 1.), ) * 3) + digest = content_digest(b"parameter dependent threshold observation") + prior = ConditionedPrior(("threshold", "position", "velocity"), + lever.digest, digest, box) + identity = InferenceIdentity(digest, digest, digest, prior.digest, digest) + + def condition(point: np.ndarray) -> PriorPoint: + distribution = lever.condition_above(float(point[0]), True) + position, velocity = distribution.lift(point[1:]) + return PriorPoint((float(point[0]), position, velocity), + distribution.log_observation_factor) + + result = sample_batch(prior, + identity, + lambda _: 0., + SamplerConfig(particles=2048, + temperatures=8, + moves=4, + max_evaluations=70000), + 19, + condition=condition) + assert result.status == "complete" + values = np.asarray(result.samples) + assert np.all(values[:, 1] > values[:, 0]) + means = np.average(values, axis=0, weights=result.weights) + np.testing.assert_allclose(means, [13 / 30, 13 / 15, 0.], atol=.02) + endpoint_mass = np.dot(values[:, 1] == 1., result.weights) + assert endpoint_mass == pytest.approx(.6, abs=.03) + + +def test_threshold_prior_limits_and_boundaries() -> None: + """Deterministic rests and pure moving components remain explicit.""" + for mass in (0., 1.): + prior = RestingJointPrior("lever", 0., 1., (0., 1.), mass, .4) + off, on = (prior.condition_above(.2, flag) for flag in (False, True)) + assert math.exp(off.log_observation_factor) + \ + math.exp(on.log_observation_factor) == pytest.approx(1.) + point = np.array([.5, .75]) + if mass == 1.: + assert off.lift(point) == (0., 0.) + assert on.lift(point) == (1., 0.) + else: + assert on.lift(point) == pytest.approx((.6, .2)) + with pytest.raises(JointCoordinateBoundary): + on.lift(np.array([0., .5])) + for threshold in (0., 1., float("nan")): + with pytest.raises(ValueError, match="interior"): + prior.condition_above(threshold, True) + with pytest.raises(ValueError, match="Invalid resting"): + RestingJointPrior("lever", 0., 1., (0., 1.), 1.1, .4) + + +def test_controller_poses_inside_mechanical_travel() -> None: + """Conditioning can select zero, one or both interior resting poses.""" + prior = RestingJointPrior("lever", 0., 1., (.2, .8), .6, .4) + on = prior.condition_above(.9, True) + assert math.exp(on.log_observation_factor) == pytest.approx(.04) + assert on.rest_probability == 0. + assert on.lift(np.array([.5, .75])) == pytest.approx((.95, .2)) + off = prior.condition_above(.9, False) + assert math.exp(off.log_observation_factor) == pytest.approx(.96) + assert off.rest_probability == pytest.approx(.625) + assert off.lift(np.array([.1, .75])) == (.2, 0.) + assert off.lift(np.array([.5, .75])) == (.8, 0.) + resting = RestingJointPrior("lever", 0., 1., (.2, .8), 1., .4) + with pytest.raises(IncompatibleJointObservation): + resting.condition_above(.9, True) diff --git a/tests/code_sim_learning/test_weighted_information.py b/tests/code_sim_learning/test_weighted_information.py new file mode 100644 index 000000000..e933768f3 --- /dev/null +++ b/tests/code_sim_learning/test_weighted_information.py @@ -0,0 +1,92 @@ +"""Weighted information scores agree with enumerated observation channels.""" + +import numpy as np +import pytest + +from predicators.code_sim_learning.active_experiment import \ + mean_bernoulli_entropy, noisy_read_information + + +def _enumerated_information(channel, weights): + # Sum P(member, read) log(P(member, read) / (P(member) P(read))). + scores = [] + for probabilities in np.asarray(channel).T: + conditional = np.column_stack((1. - probabilities, probabilities)) + joint = np.asarray(weights)[:, None] * conditional + independent = np.asarray(weights)[:, None] * joint.sum(axis=0) + positive = joint > 0. + scores.append( + np.sum(joint[positive] * + np.log2(joint[positive] / independent[positive]))) + return float(np.mean(scores)) + + +def test_weighted_noisy_reads_match_enumerated_joint_law(): + """Unequal member mass enters both marginal and conditional entropy.""" + weights = [.1, .3, .6, 0.] + channel = np.array([[.1, 0., .5], [.8, 1., .5], [.6, 0., .5], [1., 1., + 0.]]) + score = noisy_read_information(channel, weights=weights) + assert score == pytest.approx(_enumerated_information(channel, weights)) + assert score != pytest.approx(noisy_read_information(channel)) + assert score == pytest.approx( + noisy_read_information(channel[:3], weights=weights[:3])) + # Splitting a member into identical copies cannot create information. + split_channel = channel[[0, 1, 2, 2]] + assert score == pytest.approx( + noisy_read_information(split_channel, weights=[.1, .3, .2, .4])) + assert score == pytest.approx( + noisy_read_information(channel[::-1], weights=weights[::-1])) + + +def test_weighted_exact_reads_and_uninformative_channel(): + """Exact reads recover entropy and identical channels carry no signal.""" + exact = np.array([[True, False], [False, True], [False, False]]) + weights = [.1, .3, .6] + reference = _enumerated_information(exact.astype(float), weights) + assert mean_bernoulli_entropy(exact, weights=weights) == \ + pytest.approx(reference) + assert noisy_read_information(exact, weights=weights) == \ + pytest.approx(reference) + assert noisy_read_information(np.tile([.2, .8], (3, 1)), + weights=weights) == pytest.approx(0., + abs=1e-15) + assert noisy_read_information(exact, weights=[1., 0., 0.]) == 0. + assert mean_bernoulli_entropy(exact, weights=[1., 0., 0.]) == 0. + assert noisy_read_information(np.zeros((3, 0)), weights=weights) == 0. + assert mean_bernoulli_entropy(np.zeros((3, 0)), weights=weights) == 0. + + +@pytest.mark.parametrize("weights", + [[.5], [.2, .2], [-.1, 1.1], [float("nan"), 1.], + [float("inf"), 0.], [[.5], [.5]], [0., 0.]]) +def test_weighted_scores_reject_invalid_measure(weights): + """Neither invalid masses nor a wrong row count are silently repaired.""" + for score in (mean_bernoulli_entropy, noisy_read_information): + with pytest.raises(ValueError, match="weight"): + score(np.array([[0.], [1.]]), weights=weights) + + +@pytest.mark.parametrize( + "channel", [[[float("nan")], [1.]], [[-.1], [1.]], [[0.], [1.1]], [0., 1.], + np.empty((0, 0))]) +def test_weighted_scores_reject_invalid_channel(channel): + """Probability errors and empty ensembles fail before reporting a score.""" + for score in (mean_bernoulli_entropy, noisy_read_information): + with pytest.raises(ValueError): + score(np.asarray(channel), weights=[.5, .5]) + with pytest.raises(ValueError, match="binary"): + mean_bernoulli_entropy(np.array([[.2], [.8]]), weights=[.5, .5]) + + +def test_equal_weights_recover_incumbent_scores(): + """Explicit uniform weighting has the same statistical interpretation.""" + rng = np.random.default_rng(13) + for members in (1, 2, 7, 32): + probabilities = rng.uniform(size=(members, 4)) + exact = probabilities > .5 + weights = np.full(members, 1. / members) + for matrix, score in ((probabilities, noisy_read_information), + (exact, mean_bernoulli_entropy)): + assert score(matrix, + weights=weights) == pytest.approx(score(matrix)) diff --git a/tests/envs/test_domino_task_cache.py b/tests/envs/test_domino_task_cache.py new file mode 100644 index 000000000..87fb04748 --- /dev/null +++ b/tests/envs/test_domino_task_cache.py @@ -0,0 +1,74 @@ +"""A task cache preserves the exact robot configuration across fresh worlds.""" +# pylint: disable=protected-access +import json +from typing import cast + +import numpy as np +import pytest + +from predicators import utils +from predicators.envs import create_new_env +from predicators.envs.pybullet_domino.env import PyBulletDominoComposedEnv +from predicators.envs.pybullet_domino.task_generators import \ + min_block_generation as task_cache +from predicators.structs import Action, EnvironmentTask + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_task_cache_preserves_joint_configuration(tmp_path, legacy): + """Save, load, reset, and act through the real simulator and disk cache.""" + utils.reset_config({ + "env": "pybullet_domino", + "seed": 0, + "num_train_tasks": 1, + "num_test_tasks": 0, + "domino_min_block_tasks": False, + "domino_initialize_at_finished_state": False, + }) + source = cast(PyBulletDominoComposedEnv, + create_new_env("pybullet_domino", do_cache=False)) + fresh = cast(PyBulletDominoComposedEnv, + create_new_env("pybullet_domino", do_cache=False)) + try: + task = source.get_train_tasks()[0] + source.reset("train", 0) + joints = source._pybullet_robot.get_joints() + joints[0] += .15 + source._pybullet_robot.set_joints(joints) + state = source._get_state() + cache_task = EnvironmentTask(state, task.goal, goal_nl=task.goal_nl) + cache = tmp_path / "tasks.json" + task_cache._save_min_block_cache(cache, [cache_task], 1) + if legacy: + payload = json.loads(cache.read_text()) + payload["tasks"][0].pop("simulator_state", None) + cache.write_text(json.dumps(payload)) + loaded = task_cache._load_min_block_cache(fresh, cache) + assert loaded is not None and len(loaded) == 1 + assert loaded[0].goal == task.goal + if legacy: + return + # Exact joint data is observed and must survive, even where the + # end-effector feature vector admits multiple inverse solutions. + assert loaded[0].init.simulator_state[ + "joint_positions"] == state.simulator_state["joint_positions"] + source._train_tasks = [cache_task] + fresh._train_tasks = loaded + left = source.reset("train", 0) + right = fresh.reset("train", 0) + assert left.simulator_state[ + "joint_positions"] == right.simulator_state["joint_positions"] + hold = Action( + np.array(state.simulator_state["joint_positions"], + dtype=np.float32)) + for _ in range(3): + left = source.step(hold) + right = fresh.step(hold) + np.testing.assert_allclose( + left.simulator_state["joint_positions"], + right.simulator_state["joint_positions"], + rtol=0, + atol=1e-10) + finally: + source.dispose() + fresh.dispose() diff --git a/tests/run/test_continual.py b/tests/run/test_continual.py index dbd12270c..1d70becba 100644 --- a/tests/run/test_continual.py +++ b/tests/run/test_continual.py @@ -7,6 +7,7 @@ import json import os import pickle +from pathlib import Path from typing import Any, Dict, List import numpy as np @@ -14,6 +15,9 @@ from predicators import observation_noise, utils from predicators.approaches import create_approach +from predicators.code_sim_learning.inference_data import Observation +from predicators.code_sim_learning.inference_recording import \ + RecordingProjection, load_recorded_level from predicators.envs import create_new_env from predicators.ground_truth_models import get_gt_options from predicators.run import paths @@ -865,6 +869,18 @@ def play_level(self, session: ProtocolSession) -> None: rec.close() assert recorded.allclose(seen["truth0"]) assert not recorded.allclose(seen["frame0"]) + # An offline fit must see the same noise draws as the acting agent, + # rather than the sanitized truth stored by the replay recorder. + offline = load_recorded_level( + Path(run.run_dir) / "L01", + "noise-channel-test", + observation_noise.ObservationNoise(position=.002), + RecordingProjection(require_joints=False), + observation_seed=CFG.seed, + level_index=0) + observations = offline.data.episodes[0].observations + assert observations[0] == Observation.from_state(0, seen["frame0"]) + assert observations[1] == Observation.from_state(1, seen["frame1"]) def test_scorecard_records_the_observation_noise() -> None: