Skip to content

Ivan/feat/planning-trajectory - #3352

Draft
leshy wants to merge 210 commits into
mainfrom
ivan/feat/trajectory_ctrl
Draft

Ivan/feat/planning-trajectory#3352
leshy wants to merge 210 commits into
mainfrom
ivan/feat/trajectory_ctrl

Conversation

@leshy

@leshy leshy commented Aug 4, 2026

Copy link
Copy Markdown
Member

No description provided.

leshy added 30 commits August 2, 2026 15:04
Vendor the motion2 referee (worlds, gold oracle, judge, scoring, rust
candidate crate) from ivan/feat/body_obstacle @ d7c1b7c88 into
dimos/navigation/motion/autoresearch/ as its canonical home, made fully
standalone: the package runs on numpy + scipy + pydantic alone, with
relative imports throughout, so a plain directory copy works anywhere.

Two substitutions close the dependency knots:

- types.py replaces dimos.msgs (Vector3/Quaternion/Pose/PoseStamped/
  Path/PointCloud2/SolidPrimitive) with minimal classes whose numeric
  formulas are copied op-for-op from the originals.
- geometry.py vendors the pure-geometry slice of motion/obstacle.py
  (CollisionShape, DistanceField, scored_clearance, station_poses,
  turn_mask, sweep_samples, near_field_diff, ...) with
  BodyAwareAvoidanceConfig reparented to a plain-pydantic
  AvoidanceConfig. Re-vendor deliberately if obstacle.py's scoring
  math ever changes (the source branch is not merged here).

Dropped relative to motion2: planners/core.py and the core-keyed test
suites (they need the production motion stack, which is not on this
branch); default --planner is now target. sim2d.py is renamed sim.py.
The pyo3 module name dimos_motion2_target and python.rs plan()
signature are frozen interface and unchanged.

Verified against the pinned referee at d7c1b7c88: 56-world battery
(--gen 40 --seed 0), per-world scored/truth/veto/flicker/consist
columns BIT-IDENTICAL for both --planner target (gold 0.9624,
consistency 0.8041, dq 0) and --planner gold (gold 1.0, dq 0, no
self-veto). Speed pillar differs only by machine load, as expected.
…ssion test

- __main__.py is now the canonical entry (python -m ...autoresearch): it
  pins OPENBLAS/OMP/MKL/NUMEXPR/VECLIB pools to 1 thread BEFORE numpy
  loads. avoid_ms is time.process_time(), which sums CPU across all
  threads, and OpenBLAS's spinning pool was being charged to the
  candidate whenever a spin window overlapped a timed plan() call —
  measured on this battery, pinning takes identical-code score spread
  from 0.334 to 0.0069.

- --jobs N: battery fan-out over worker processes, each pinned to its
  own core, each measuring its own per-process CPU time — so parallel
  workers cannot contaminate each other's timings (threads in one
  process would). The parent warms both pickle caches serially, then
  spawns workers cache-read-only. 56-world battery: ~20 s at --jobs 8
  vs ~2.5 min serial; gold/consistency bit-identical to serial.

- scenarios.py cache contract: AUTORESEARCH_CACHE_DIR redirects both
  pickle caches (shared cache dir for lab worktrees),
  AUTORESEARCH_CACHE_RO suppresses writes, and all cache writes are
  atomic (tmp + os.replace) so a crash can never leave a torn pickle.

- --json: one JSON document with per-world records (score + verdict
  fields, non-finite floats nulled) plus the summary — machine-readable
  for eval harnesses. Default output stays byte-compatible.

- --build: build + install the rust candidate first (maturin develop
  --release), so "score the latest planner" is one command.

- test_gold.py: for every curated world plus generated seeds 0/28/30,
  --planner gold must never be vetoed by its own judge and scores ~1.0
  against itself — the regression test for the densify_states
  truncation bug fixed at d7c1b7c88 (gen028 self-veto).

- README.md: quick-start for build/score/view/tests, the timing model,
  and vendoring provenance.

Verified: serial battery per-world columns remain bit-identical to the
d7c1b7c88 baseline; --jobs 8 matches serial on all deterministic
columns; 19 gold tests pass; --view writes a valid sim2d.rrd.
Replace the brute-force seed planner with the autoresearch lab's best
candidate: the exp_0008 + exp_0009 + exp_0010 merge with the two-tier
yaw publication gate (lab worktree exp_0016, base commit d911a39; the
merged planner.rs was uncommitted on the lab branch due to an evo
bookkeeping accident, hence the worktree provenance). rustfmt applied;
two clippy allows added on test fns; no functional edits.

What it adds over the seed, cumulatively earned across the run:
lazy demand-driven distance field + per-(yaw,cell) clearance, CSR point
buckets, precomputed gait tables, 16-byte A* heap nodes, goal-anchored
shortcut smoothing, start-pose-invariant working area + exact grid
index arithmetic, a demand-driven backward 2D Dijkstra differential
heuristic, ceil-based fine yaw publication, and a two-tier yaw gate
that publishes coarse yaw only where measured chord clearance affords
the swept-box window.

Battery (56 worlds, --gen 40 --seed 0) vs the seed planner it replaces:
referee 104.4 -> 106.7/111, gold 0.9624 -> 0.9629, consistency
0.8041 -> 0.9473, speed pillar 0.12 -> 0.89 (at the 20 ms budget),
dq 0, gen028's rotation-cylinder veto cleared. gold/consistency match
the lab's recorded exp_0016 numbers exactly.

Also: crate tests from the lab land as tests/invariants.rs — behavioral
black-box properties (bit-determinism, no cross-call memoization,
sealed-world refusal, thin walls never hopped, open-world routing).
They are developer aids, deliberately NOT wired into any harness gate:
the benchmark treats the crate as a black box and gates behavior
through the referee. lib.rs's header now states the CPU-time threading
rule and the judge-constant coupling of the yaw gate.
…loops

python -m dimos.navigation.motion.autoresearch.export <dest> scaffolds a
complete, self-contained research lab: the referee copied as referee/
(relative imports make the package name-independent), the current rust
planner seeded as candidate/, the bench/ gate harness, generated locks,
warmed caches, a uv venv, and a git repo -- ready for /evo:discover.
gates.json carries the benchmark command, the recommended gates with
epoch-3 thresholds, and the seeded baseline, so a lab agent registers
them mechanically.

The harness is the proven one from the source lab (autoresearch-planner
run_0000), adapted for standalone operation:

- trust chain unchanged in shape: tracked bench/trust.lock -> .evo/
  {bench_guard.py, harness.lock, referee.lock} -> every harness byte.
- referee pinning goes content-hash: referee.lock pins every referee/
  file by sha256 (+ stray-file ban), the live co_code of judge/
  score_world/summarize/se2_path, and the scoring constants by value --
  the old lab's git-head pin has no meaning without a dimos checkout.
- check_rules.py keeps the full anti-cheat ban list (fs/env/net/pyo3-
  reach/subprocess/cross-call state; threading deliberately permitted
  under CPU-time scoring) with frozen-surface hashes generated at
  export into bench/frozen.json.
- fitness weights move to bench/fitness.py with the retune procedure
  in its docstring (they were retuned twice in the source lab).
- bench/run drops DIMOS_REPO: lab-root walk-up (EVO_LAB_ROOT override),
  the lab venv, PYTHONPATH staging of the .so, baked-in BLAS pinning,
  and AUTORESEARCH_CACHE_DIR pointing at the shared .evo/cache.
- caches: the gold cache ships from the source package (plain dict
  pickle, key-compatible; warmth probed at export), world caches are
  REBUILT under the lab import name (they pickle Scenario module paths).
- policy: no cargo-test gates. candidate/tests/invariants.rs ships as
  optional crate tests; behavior is gated through the referee.

Templates are byte-copied, lintable files under export/templates/
(excluded from repo ruff/mypy -- lab conventions, not repo code); all
export-varying data lives in generated files.

Self-test: fast tier (9 tests, ~1 s, CI-safe) exports with build/venv/
warm skipped and proves the tree, standalone import, the trust chain,
and four tamper scenarios (referee edit, stray file beside the judge,
harness edit, banned construct in the candidate). Full smoke behind
AUTORESEARCH_FULL_EXPORT=1.

Verified end-to-end on a real export: uv sync, gold cache warm (3 ms
probe), locks green, cargo build, 2-world battery fitness 25.0/26,
./eval curated 108.13/111 dq 0, ext_invariants + parity gates pass --
with no dimos checkout involved anywhere in the lab.
referee.lock pins live co_code hashes and CPython bytecode changes
across minor versions, so a lab uv-synced under a different python
would fail ext_invariants for what is really an interpreter mismatch.
Write .python-version from the exporting interpreter so uv provisions
the same minor everywhere (surfaced deploying to a python-3.13 host).
Standalone flat-ground MuJoCo Go2 for the sim-to-real work. Deliberately does
not build on unitree/mujoco_connection or the existing sim stack -- those are
reference only.

  model.py    menagerie unitree_go2/scene.xml; Unitree<->MuJoCo motor
              permutation resolved by name, not by assumed index order
  replay.py   re-runs the on-board law tau = kp(q_des-q) + kd(dq_des-dq) +
              tau_ff against simulated joint state, scores vs recorded lowstate
  __main__.py CLI

Timestep matches the recordings exactly (0.002 s = 500 Hz), so it is one
command per sim step with no resampling. Actuators are torque with gear=1,
so ctrl is N.m directly.

Ships two recordings with the exact networks that produced them, as the
ml-trajectory-research LFS archive:

  unitree_himloco01           freewalk (HIMLoco)  <-> freewalk_mcf.bin
  unitree_v11_gait_height01   v11-final           <-> v11_final.bin

FINDINGS.md records why joint-level replay does not work on this data: across
every recording, lowcmd/policy_lowcmd carry q_des=0, kd=0, kp=1.0, tau_ff=40.0
constant over all twelve joints -- template defaults the publisher never fills.
lowstate.dq and .tau_est are identically zero and ~25% of lowstate.q rows are
outside physical joint range. Verified four ways (dimos codec, hand CDR parse,
both channels, three files), so the harness is kept but comparison should be
made at body-pose level against vive_pose until the publisher is fixed.
Wall-clock paced replay in mujoco.viewer.launch_passive, --speed to scale.
Same control loop as the headless path.
…dependency

policy.py reads the "FREE" v1 blob directly -- speed-banded HIMLoco experts
plus shared normalization -- and runs the HIM forward pass in numpy. Verified
against MNN on all three bands: max |ours - MNN| = 1.1e-4, well under the
2e-3 fp32 floor the exporter validates at.

walk.py drives that policy on the flat menagerie scene, commanded either by a
constant or by a recording's control_log (zero-order held), so a run is
directly comparable to its vive_pose. Sanity: vx=0.5 -> 0.536 achieved,
upright; turn-in-place stays put.

    --policy <bin>   drive from control_log instead of replaying lowcmd
    --view           interactive MuJoCo window

Two values the blob does not carry are pinned here as constants with the cfg
keys they came from: CONTROL_DT (cfg "dt") and TORQUE_LIMITS
(cfg "torque_limits").

MNN is added as a dep so the port can be re-checked against the original, but
nothing on the runtime path imports it.
Drop the cd, the $D shorthand and the direnv exec prefix -- direnv is
already loaded, so the commands should be copy-pasteable as-is.
… the sim

A translucent mocap box follows the recorded pose while the policy walks, so
sim and hardware can be compared by eye before any error metric exists.

Two Vive conventions were read off the data, not assumed (test_vive.py pins
the reasoning):

  quaternion is wxyz  -- under that reading the tracker z axis holds
                         0.997 +/- 0.003 alignment with world z over a run;
                         xyzw gives 0.725 +/- 0.279, impossible for a walker
  frame is z-up       -- 0.09 m of z range against 1.0 and 1.6 m in x and y

Where the tracker sits relative to base_link is still unknown. It is mounted
roughly 15 cm above the body, so the default offset is (0, 0, -0.15) with
--tracker-z to adjust; the in-plane component is not modelled. The track is
anchored at t=0, which cancels the unknown room origin and mounting rotation
but not the lever arm -- that shows up as soon as the body turns, which is
exactly what makes a wrong offset visible.
…the sim

The ghost looked mirrored because it was. Two mounting facts, both read off
the data:

  tracker is inverted   R[2,2] = -0.997 (himloco01), -0.996 (v11) while the
                        robot is upright throughout -- so a base offset that
                        should point down in world is +z in tracker frame
  yaw is ~94 deg        robot forward lands along the tracker's +y, not +x

The yaw is fitted sim-free, two independent ways that agree: circular mean of
travel direction under a pure +vx command gives 93.6 deg (concentration 0.88,
n=2347), and maximizing cos(body velocity, commanded direction) gives 94.0 at
0.840 on a clean unimodal curve.

Fitting it against the simulator does not work, and the failure is worth
recording: the policy rollout diverges from the real robot within a second or
two, so a sim-vs-ghost displacement score on 2 s windows spans only 0.79-1.09 m
against ~1 m of travel, and its argmin lands at 285 deg -- about 180 deg wrong.
The mount is a property of the recording and has to be fitted from it.

Also corrects the ghost box, which was 2x too wide: the trunk collision from
the official URDF is 0.3762 x 0.0935 x 0.114 at origin 0 0 0, so `base` sits at
the trunk's geometric centre, level with the hips. menagerie inherits that body
one-to-one, so MuJoCo qpos[0:3] is exactly the URDF `base` frame -- there is no
mapping to do, and no `base_link` (the root link is called `base`).
…te claim

The simulator always begins standing: walk() resets to the menagerie `home`
keyframe and overwrites the leg joints with the policy's default_pose. Nothing
in a recording can change that, because there are no joint angles in one --
so the first seconds of himloco01, where the tracker climbs from 0.166 m to a
steady 0.229-0.245 m as the robot stands up, cannot be compared against it.
--start anchors the ghost and the command schedule past that.

Corrects an earlier claim in FINDINGS that ~75% of lowstate.q rows were usable.
That came from a |q| < 4 rad filter, which is far too loose. Against the real
mechanical limits (calf is [-2.72, -0.84], always negative) the recorded calf
angles are in range in 0.0% of rows on himloco01 and 0.1% on v11, with mean
+0.007 and a +/-14 rad span. lowstate.q is not joint angles.

Also fixes a variable shadowed between an index and a rotation matrix in
base_track, caught by mypy.
… fixed

Trajectory error cannot be the objective. Perturbing only the initial joint
angles and replaying the same commands diverges by 136 cm at 12 s for a 3 deg
perturbation and 58 cm for a 17 deg one -- non-monotonic, i.e. chaotic. Against
that floor the sim-vs-real windowed error is 1.5x at 0.5-2 s and 0.88x at 10 s,
so a long-horizon trajectory score carries no information about physics.

metrics.py computes what does survive: speed, speed gain, yaw rate gain, body
height mean/std, gait frequency, plus chaos_spread() to measure each
statistic's own noise across perturbed rollouts. Nothing should be fitted
against a statistic whose sim-real difference does not clearly exceed it.

First results over 40 s of himloco01: speed (0.410 sim vs 0.389 real) and speed
gain (0.780 vs 0.759) agree to within the noise -- the policy's translational
response transfers. height_std is the one clear discriminator so far, the
simulated body bobbing 50% more than the real one at 3x the noise floor.

Two estimator bugs, both mine, both recorded in FINDINGS:

  sample-window filtering  differentiating a 253 Hz recording and a 50 Hz
                           rollout with the same 25-sample window smooths them
                           by 0.1 s and 0.5 s respectively; with Vive dt jitter
                           as large as the interval itself this reported the
                           robot walking at 3.87 m/s
  mean of ratios           mean(achieved/commanded) explodes near zero command
                           and cancels across sign flips, reporting the
                           simulator turning backwards; now a least-squares
                           slope through the origin

vive.read_vive_pose now clocks off the payload's t_host: monotonic, worst gap
15 ms against 92 ms for log_time, while the payload's own ts goes backwards at
91 points.

Still not trustworthy and marked as such: yaw_rate_gain (correct on a constant
command, wrong against the fast-alternating recorded schedule -- needs lag
compensation) and gait_hz (locking onto drift at 0.6 Hz instead of the ~2 Hz
gait). height_mean is not comparable at all while the tracker offset is a guess.
It died with "IndexError: index -1 is out of bounds for axis 0 with size 0"
from deep inside the summary print, because a start beyond the last command
makes the duration negative and the rollout empty. Validate up front and name
the actual span, which is the number the user needs.
…imators

--start was not reaching the commands. A patch adding the offset to cmd_at()
silently failed to apply while the same offset did land on the ghost lookup, so
the simulator ran the first seconds of a recording and was scored against a
ghost six seconds later. Every --start number reported before this was wrong.
The tell: applied commands had vx=0 where the schedule said vx=0.422. Pinned by
test_start_offset_reaches_the_command_schedule.

gait_hz was locking onto the robot drifting around the room, reporting 0.58 Hz.
Now high-passed by subtracting a 1 s moving average, Hann-windowed, and searched
from 1.0 Hz. No ground truth exists to calibrate against -- HIMLoco free_walk
has no clocked gait, and the only explicit rate in the fleet is 1.5 Hz on an
experimental trot-clock policy -- so this is plausibility, not calibration.
(50 Hz is the control rate, himloco.rs:185, confirming walk.CONTROL_DT.)

Command gains now find the policy-to-body lag by cross-correlation and regress
at that lag, reporting it too. The lag is itself the strongest discriminator
found so far.

With commands aligned the picture is coherent and matches the viewer: the
simulated gait runs at 3.30 Hz against 1.75 Hz real, bobs 33% higher, and
answers a turn command in 0.07 s against 0.49 s while turning less per unit
command. Translation still transfers -- speed 0.441 vs 0.389 and speed_gain
0.887 vs 0.839, both within about twice the chaos noise.

Fitting targets, by SNR: yaw_lag 14.0, yaw_rate_gain 6.7, gait_hz 3.4,
height_std 2.8.
… sweep

gait_hz is window-dependent: the same configuration reads 1.54, 1.52, 3.30 and
1.51 Hz on 15/25/40/45 s windows. The 3.30 was an outlier -- a harmonic winning
one FFT -- and it is what the previous commit's "simulated gait is twice as
fast" headline rested on. Read across windows sim sits near 1.5 Hz and the real
robot wanders 1.1-1.75; they are not clearly different. gait_hz is now marked
unusable as a fitting target until estimated by autocorrelation or a median
across sub-windows.

Stable and discriminating, still: height_std, yaw_rate_gain, yaw_lag, with
speed and speed_gain agreeing between sim and hardware.

First sweep of leg-joint physics against the real targets shows no single
parameter closes the gap -- raising armature pulls gait_hz toward real but
pushes height_std away and collapses speed past 0.1; damping costs speed for
little gain. The menagerie defaults are the best row tested. That is the honest
case for a multi-objective search, and also evidence that whatever looks wrong
in the viewer is not simple leg-joint under-damping.
…cs search

gait_hz now takes the first peak of the height autocorrelation instead of an
FFT peak. It reads 3.33/3.23/3.33/3.33 Hz on 15/25/40/45 s windows against
1.52-1.69 real, where the FFT swung between fundamental and harmonic. That
un-retracts the earlier "simulated gait is twice as fast" finding: the claim
was right, the estimator was not.

evaluate.py scores one configuration -- runs the policy under the recorded
commands, summarizes both sides, and divides each statistic by its own noise
floor so a term cannot be won by driving something the simulator cannot
resolve. search.py drives it with Optuna's CMA-ES sampler, the right choice for
a continuous, low-dimensional, noisy objective (TPE would suit categorical or
conditional spaces, which this is not).

Measuring the noise floor once and reusing it takes a trial from 7.3 s to 1.6 s,
so 200 trials is about five minutes. It also keeps losses comparable: recomputed
per trial, a trial could win by getting noisier.

30 trials say joint friction is the missing physics. frictionloss 0.2 -> 1.23
(6x) with armature and damping essentially unmoved takes gait_hz from 3.333 to
1.562 against 1.695 real (SNR 5.2 -> 0.4) and height_std from 0.033 to 0.026
against 0.023 (SNR 4.9 -> 1.5). Loss 4.97 -> 4.27. Consistent with real
gearboxes; menagerie's 0.2 N*m models a much freer joint than the robot has.

yaw_lag does not move and cannot: 0.04-0.06 s in sim against 0.46 s on hardware
is command transport and filtering, not a leg-joint property. It is the largest
remaining loss term and needs an explicit command delay, otherwise the next
search will drag every other parameter around trying to compensate for it.
Ivan's alternative -- that the real robot is simply heavier and slower to swing
round -- tests cleanly and comes out negative. Tripling the trunk's rotational
inertia moves sim yaw_lag not at all (0.06 -> 0.06 against 0.46 real), while a
0.5 s command delay takes it to 0.55. The policy is a closed loop at 50 Hz: it
feels a heavier body on the first tick and compensates, but cannot compensate
for a command it has not received. The measured cross-correlation is a sharp
single peak (0.88 at 0.50 s, against 0.66 at zero lag) -- the signature of a
shift rather than a filter. Inertia is not useless, x3 improves total loss
4.97 -> 4.43; it just does not explain this.

walk() gains command_delay, and the search space gains command_delay plus trunk
mass/inertia scaling so the two hypotheses compete on the data.

80 trials: loss 4.97 -> 2.39, with yaw_lag SNR 10.0 -> 1.0, height_std
4.9 -> 1.0, yaw_rate_gain 4.3 -> 1.7. The search settled on command_delay
0.451 s with no knowledge of the cross-correlation that independently measured
0.46-0.50 s, and left trunk_inertia_scale at 1.039 -- unchanged, rejecting the
inertia hypothesis on its own.

Caveats recorded in FINDINGS: frictionloss fell from 1.23 to 0.307 once delay
was available, so the earlier friction figure was partly standing in for the
missing delay. And gait_hz is now the worst term (SNR 4.9) where the
physics-only search had driven it to 0.4 by leaning on friction, with speed_lag
regressing 0.7 -> 3.0. The two optima disagree, so the scalar loss is trading
gait accuracy against the rest; next step is weighting or a multi-objective
study with a Pareto front.
…e groups

NSGA-II over gait / translation / rotation, each the RMS of its statistics' SNR.
Seven separate objectives would leave nearly every trial non-dominated; these
three are the ones that actually trade.

150 trials, 24 non-dominated. corr(gait, rotation) = -0.42 across the front:
they genuinely pull against each other, which is what the scalar loss was
averaging away. The physics-only search sat at the gait end (gait 0.4, rotation
10) and the delay search near the rotation end (rotation ~1, gait 4.9) -- both
were real points on this curve reported as if they were the answer.

Most of the space is smeared across its full range, as expected when the
optimum is a curve rather than a point. Two parameters stand out: armature
wants ~7x the menagerie default (median 0.071 against 0.01, where the scalar
search had said 0.0158), and command_delay's median of 0.369 s spans the 0.46 s
measured independently by cross-correlation.

Best balanced point, minimizing the worst objective rather than the sum:
gait 2.41, translation 2.05, rotation 2.71 -- every term under 2.8 noise floors
against a baseline where yaw_lag alone was 10.

The read on sim-to-real: no point in this six-parameter space matches gait and
rotation together, which points at a missing mechanism (actuator bandwidth,
contact behaviour) rather than a mis-tuned constant. The front is also the test
for whether adding one helps -- a better model should collapse the curve toward
the origin rather than slide along it.

Two tests earned their place while writing this: the objective groups must
partition the comparable statistics, or a statistic goes silently unoptimized;
and log scaling is only allowed where a range spans a decade, which caught
trunk_mass_scale set to log over 0.6-2.0.
…e lag

A MuJoCo motor delivers the requested torque on the same step; a real BLDC
through a gearbox reaches it over a few milliseconds of current-loop
bandwidth. actuator_step() applies that lag, tau=0 reproducing the ideal
actuator exactly.

The Pareto front said no scalar on an existing term could match gait and
rotation together, which pointed at a missing mechanism rather than a
mis-tuned constant. This is the first candidate. A quick probe is promising:
5 ms takes gait_hz from 3.33 to 1.52 against 1.70 real and height_std from
0.033 to 0.029 against 0.023, loss 4.97 -> 4.53. Past ~30 ms it destabilises
the gait entirely, which is the expected shape for a bandwidth limit.

Added to the search space as actuator_tau over 0-50 ms. Whether it is a real
improvement is a question for the front: a better model should collapse the
curve toward the origin rather than slide along it.
Same 150 trials, same objectives, only actuator_tau added:

  min distance to origin   3.82 -> 2.87
  best sum                 6.01 -> 4.49
  best worst-objective     2.71 -> 2.20
  best gait                1.05 -> 0.67
  best translation         0.44 -> 0.22
  best rotation            0.81 -> 0.94

The curve moved toward the origin rather than sliding along it -- every
aggregate improved together and two of three objectives got a better
attainable minimum. That was the agreed test for a real model improvement
against a new way to trade one error for another.

The strongest evidence is what the search did not choose. actuator_tau is
searched over 0-50 ms, so an ideal actuator is available for free, and not one
of the twenty Pareto-optimal trials sits at zero: min 0.0094, median 0.0219,
max 0.0453 s. A spurious knob would leave some optimal points at the identity
value. Ten to forty-five milliseconds is also the right order for a BLDC
current loop through a gearbox.

Best balanced point is now gait 0.67, translation 2.20, rotation 2.06 against
2.41 / 2.05 / 2.71 before -- gait inside one noise floor, nothing above 2.2,
where the untouched baseline had yaw_lag alone at 10.

Rotation remains the worst term and did not move. Delay and actuator lag both
model when torque arrives; nothing yet models what the foot does once it lands.
The menagerie feet use condim=1, i.e. frictionless point contacts that generate
no tangential force at all, which for a turning quadruped is the obvious next
suspect.
FINDINGS had grown to 767 lines across sixteen appended sections, several of
which documented intermediate states that later work superseded -- a gait_hz
retraction that was itself retracted, a friction figure that changed once
command delay existed. Readable as a diary, not as a place to find out where
the project stands.

Rewritten to 200 lines organised by what a reader actually needs: current
status and best configuration up front, then what the recordings contain, what
is calibrated, why the judge is distributional, and the two mechanisms found.

The bulk of the new document is proposals. Foot contact is the standing
hypothesis for the remaining rotation error -- the menagerie feet use condim=1,
frictionless point contacts that generate no tangential force, which is a
strange way to model a robot that turns by shearing its feet against the
ground. Then pinning the tracker translation (a ruler beats a weakly
conditioned fit), validating on the held-out v11 recording, and what to capture
next time.

Superseded narrative is dropped, but the estimator mistakes are kept as a short
"traps" list. Every one of them produced a plausible, finite, wrong number, and
they are the most reusable thing here.

README now points at it rather than duplicating.
Three changes, each killing a wrong number the judge was fitting:

- Shared epoch: vive t_host and control_log log_time are the same clock,
  but each stream was zeroed at its own first message, pairing every real
  pose with a command 0.31 s in its future (4.4 s on v11). The fitted
  "command delay" of 0.317 s was this artifact to within 4 ms; genuine
  epoch-corrected turn latency is ~0.17 s.

- Sensor-space height: inverting the guessed tracker offset put 11.4 mm
  of lever-arm swing into the "ground truth" z (the tracker itself only
  bobs 5.6 mm). Real keeps raw tracker z; sim mounts a virtual tracker
  with the same guess, so the guess cancels. Adds offset-immune
  pitch_std/roll_std statistics; height_std is detrended.

- Hardware command slew: go2web ramps operator commands per-axis before
  the policy sees them (VEL_DV 0.05/0.04/0.10 per 20 ms tick). Known
  constants, not a fitted knob; walk() now applies the same ramp. This
  alone puts sim yaw_lag at 0.170 vs real 0.170 with default physics --
  the axis-dependent lag no uniform delay could fit.

Also: foot_friction / foot_friction_torsional search dimensions (feet
are condim=6 priority=1, so friction values are the open question, not
contact dimensionality), command_delay range shrunk to genuine-transport
scale, pitch/roll join the gait objective group.
…oco01

The 300-trial NSGA-II front collapsed from 36 points to effectively one:
gait 0.23 / translation 0.85 / rotation 0.16 in default-floor noise
units, every statistic sub-noise, and the fitted parameters physical
(armature at spec, trunk +33% mass for the tracker payload, 23 ms
transport delay). Rewrites FINDINGS around the matched state: three
mechanisms, sensor-space judge, and next steps led by held-out v11
validation and a joint-data capture.
--view/--ghost previously always ran default menagerie physics -- the
configuration measured to oscillate ~2.5x harder than the real robot --
because --physics only wired into --eval. The Pareto winner is now a
named preset (FITTED_* in evaluate.py); --fitted applies it in both view
and eval, and --physics/--command-delay/--actuator-tau pass through to
the viewer for A/B against it.

Also verified two execution-fidelity details against the go2web
executor: the action path matches tick() exactly (clip, last_action =
clipped raw, target = act*act_scale + act_mean, blob kp/kd), and
MuJoCo free-joint qvel[3:6] is body-frame, matching the IMU gyro the
policy was trained on.
…knobs

Track now records joint angles and foot heights (clearance()) every
control tick, and _physics grows trunk_com_x / leg_mass_scale, so leg
behavior is measurable instead of eyeball-only.

Findings from the front-leg investigation (sim visibly high-steps its
front legs while every base statistic matches): the recording DOES carry
joint-space commands after all -- policy/lowcmd (2466 msgs, 44 Hz,
kp=40/kd=1) is the executor's commanded targets; only rt/lowcmd is
zeroed. FK on those targets: real front clearance p95 6.5 cm vs sim
9.6 cm, rear identical -- and sim commanded == sim executed, so the
plant is exonerated: the policy itself asks for the extra lift under
sim observations. Falsified as causes: contact solimp, torque limits,
com shift, leg mass, joint friction/damping, dq smoothing (each null
or worse). Next: leg-space statistics in the judge scored against
policy/lowcmd, and let the search fit the plant that makes the policy
choose the real gait.
read_policy_lowcmd() decodes the executor's commanded joint targets
(the recording's only joint-space ground truth) onto the shared epoch;
front_lift / rear_lift are p95 FK foot clearance of commanded targets,
compared command-to-command so the unrecorded real leg state and the
instability of open-loop replay never enter. Track records sim targets;
Report and the noise floor carry the leg statistics; the search gets a
fourth 'legs' objective plus trunk_com_x / leg_mass_scale dimensions,
and the damping floor drops to 0.05 (the old front leaned on 0.2).

At default physics the new statistic reads front_lift 0.217 sim vs
0.065 real (SNR 25) -- the visible front-leg prancing, now measurable.
… 0.065

The 400-trial four-objective search's min-max point holds gait 1.06 /
translation 0.98 / rotation 0.45 / legs 0.81 at once: commanded front
foot lift 0.060 vs 0.065 real (the base-only fit commanded 0.217), rear
0.039 vs 0.035, base statistics intact. Parameters became physically
coherent -- com +4.4 cm forward where the lidar and head sit, torsional
friction 0.030 vs the shipped 0.02, trunk +20% payload. FITTED_* and
FINDINGS updated; the base-only episode is recorded as the "a judge
only constrains what it can see" trap.
…stats

Three gaps Ivan's viewing session exposed:

- _physics never patched load_with_ghost, so --view --ghost ran stock
  physics under --fitted -- the "almost falling" was the unfitted model.
  Both loaders are patched now, with a regression test.
- The judge scored only t=6-26 of a 48 s recording; unscored time is
  unconstrained time. seconds=None (now the default everywhere) scores
  the entire run.
- tilt_p99 joins the gait group: the stability tail, so a config that
  occasionally almost falls pays even when its oscillation statistics
  look right. Real reference: p99 8.6 deg, max 16.7.
- Leg statistics widen from two clearance scalars to thigh/calf command
  spans per front/rear pair plus the command-space gait frequency --
  using the joint-angle streams instead of collapsing them.
…e, basin limit

The fitted config is visually confirmed on the robot's recording; the
follow-up full-run search found no dominating point, and re-scoring the
same config across processes showed the 4-seed noise floor drifting
(BLAS-order chaos), so single-recording fitting has hit its resolution
limit. The discriminator from here is the held-out v11 recording.
leshy added 24 commits August 7, 2026 02:52
The per-heading envelope made feasibility motion-conditioned but left the seed
witness on the all-gait union, so the planner threaded a gap only a drift row
clears, the robot followed the plan in, and the next replan from mid-gap
refused forever. Standing occupies the intersection of the envelope's rows
(0.781 x 0.416 at -0.039 for the go2, mirrored in both drift signs), which is
nested in every shape an edge can be cleared by -- so a pose the route
justified always passes the witness. Turn-in-place edges and the smoothing
floor keep the union: those are real motion sweeping the full shape.

Pinned by test_replan_invariance: every 5th published pose of every route on
25 worlds, replayed as a fresh query. On the union gold refused on 5 worlds /
11 poses and the candidate on 2 / 7; on the intersection, none.
Same shape, same formula as embodiment.py: Emb::stand_box intersects the
envelope rows in both drift signs, Fps interns it, and the seed reads it --
both at the true start pose and in fit_bin's repair, which was asking the
same question on a stricter shape. Union first there as everywhere: the
standing box is nested in it, so open space pays nothing and an embodiment
with no measured rows keeps id 0 and the old reading exactly.
…ying

The go2 bridge stamps clouds and odometry off the same sweep clock, so the
right pose matches a cloud EXACTLY -- every accepted cloud in
20260807-190044.mcap sits on an odometry stamp to the nanosecond. What it does
not do is deliver them in that order. Both inputs land in one tokio::select!
loop, which polls its ready arms in random order, so a cloud reaches the
handler ahead of the odometry queued beside it whatever the load.

That alone would be survivable if the previous sweep's pose were an acceptable
fallback. It is not, by 0.8 ms: the sweep measures 100.801 ms, not the nominal
100, and POSE_MATCH_TOLERANCE_S is 0.1. So the loser of that coin flip found
nothing inside the gate and the cloud was discarded outright -- 13 of 213
clouds, 6.1%, on an idle laptop with odometry flowing at a clean 30 Hz and no
holes in the stamp grid.

So hold it. An unmatched cloud waits for the pose of its own sweep rather than
being matched loosely against the wrong one, which keeps registration exact
where widening the tolerance would have bought the drops back as motion smear.
At most one sweep is ever held -- a newer cloud retires the older -- and
pose_still_due drops it once odometry has run past its window unmatched, so a
pose that genuinely never comes cannot wedge the queue.

deployment_plan.md blamed this on load and the 3% it cost on the robot. Load
was never the whole story, and saying so hid how little margin there was.
Corrected there too (markdown table alignment rode along).
… robot runs

Two things the baked host needed and did not have.

reset_carrot_m crossed into the rust planner config with the commitment work,
and native_config forbids serde defaults -- python owns them and sends them --
so a field absent from the stdin blob is not defaulted, it is fatal. With
Restart=on-failure the unit would have crashlooped rather than said so once.
Caught by cross-checking all four baked configs against their Config structs
and against `bake --emit-config`; the other three matched.

max_speed said 0.95 here while the robot has been running 0.6. The 0.95 is
sim-calibrated against freewalk_mcf and, per the blueprint's own warning, not
re-measured against the deployed gait -- so the 0.6 is the deliberate number
and this file was the stale one. Deploying it as written would have raised the
ceiling 58% on speeds nothing has verified. The authoritative file now says
what actually runs.
…dy filled

The commitment path ran a full lattice search to carry the incumbent to a
moved goal BEFORE asking whether the trimmed route was still walkable at
all -- 199 ms on the door2 tick where the map closed the corridor, spent on
a route the very next test threw away. The two are independent tests joined
by an AND, so ask the cheap one first.

And the extension no longer builds its own clearance table. `Clear` is a
pure memo of (world, footprints, margin, lattice), so the table the fresh
search just filled answers the extension's questions too: its own footprint
scans fall ~94%.

door2.zenoh, incumbent chained every tick: 26.5 -> 16.8 ms/tick, worst tick
198.6 -> 59.6. Fresh-search-only unchanged at 12.4. Bit-stable: 63/63 plans
identical across the two builds, crate tests 19/19, referee 118/118, battery
114.80 with 40/40 goals and 0 rerolls.
Connect endpoints only ever came from --robot-ip, and only when the stack
was zenoh-transported -- both right for a process that owns its robot, both
wrong for one that just wants to watch someone else's. `dimos spy` is the
case: it observes every transport at once, so it has no DIMOS_TRANSPORT to
set, and pointing it at a Go2 meant setting one anyway.

--zenoh-connect names endpoints outright and is honoured whatever the
transport -- naming an endpoint is itself the intent to dial it. It feeds
_default_connect_endpoints(), so the native children inherit it through the
DIMOS_ZENOH_CONNECT the launcher already derives from there.

    dimos --zenoh-connect go2 --zenoh-mode client spy --transport zenoh

Client mode stays the load-bearing half: the robot's helper runs a router,
and a router forwards to clients, never to peers.
leshy added 2 commits August 12, 2026 22:01
…ctrl

# Conflicts:
#	dimos/msgs/test_msg_construction.py
mcap is an extra, not a default dep -- importorskip the two writer imports
so the vive tests skip instead of erroring where it is absent. The pure
math in that file still runs.

The banner comments go: the rule wants files, not subfiles. Prose kept,
decoration dropped.

object.__new__ stays in the adapter tests, now whitelisted with the
reason: the real constructors stand up an LCM RPC transport per instance
(a run_forever + _lcm_loop pair) that pure geometry tests cannot tear
down -- 21s and a leaked-thread failure against 0.5s.

Lab code is not a runnable module. The blueprint scan matched
ActorCritic(nn.Module) because it compares the last dotted segment of a
base class, so a torch net was about to be registered as a dimos module;
excluding research/ keeps the registry to production modules.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant