Skip to content

Maploc: mapping & localization as a robotd-hosted subcrate β€” and why it never worked - #127

Open
apirrone wants to merge 17 commits into
mainfrom
maploc
Open

Maploc: mapping & localization as a robotd-hosted subcrate β€” and why it never worked#127
apirrone wants to merge 17 commits into
mainfrom
maploc

Conversation

@apirrone

Copy link
Copy Markdown
Contributor

Absorbs microduck_maploc_rs (already Rust) as the maploc/ subcrate β€” and, per the brief, spends most of its effort on why the results were "kinda ok but noisy" and relocalization never worked. Every fix below was found and verified offline against the prototype's own recorded .mdlg sessions, using a replay bench that ships with the crate.

The bugs, each with the measurement that exposed it

  1. Sensor-origin asymmetry. Maps were inked from the sensor world pose (10–15 cm ahead of the body, moving with head yaw), but MCL and brute-force relocalize scored beams from the body pose. Scan is now per-beam origin β†’ endpoint pairs; every consumer derives world beams identically. The shape also makes scans composable β€” frames at one body pose with different head yaws merge exactly.
  2. Off-map cherry-picking. Relocalize/MCL silently skipped out-of-bounds beams, so a wrong pose that threw 95% of its scan off the map was scored on the agreeing remainder β€” measured beating the true pose with mean residuals near zero from a tenth of the beams. Every beam now counts (off-map = full clamp).
  3. See-through degeneracy. Endpoints-only scoring let poses dump whole scans into dense wall blobs at ~0 residual. One mid-ray occupancy sample per beam breaks it.
  4. The ~9 cm map noise floor. Stop-and-scan inked every raw frame; walking-passer-by transients and the sensor's far-range noise tail set a floor above every downstream gate (loop closures gated at 10 cm never fired; the true pose scored worse than the 5 cm relocalize acceptance β€” the "never locks" mechanism, jointly with Fix two health-check defects slice 1 shippedΒ #2 accepting lies). New accumulator: a still window's frames vote per endpoint cell; beams confirmed by β‰₯3 distinct frames survive; ranges cap at 2 m; one wide composite comes out per stop.
  5. The correction that never reached tracking. The current submap's pose-graph node was created with no edge attached β€” invisible to the optimizer β€” and the post-closure tracked-pose correction rides that node. Closures moved every frozen anchor while live tracking sailed on uncorrected (the prototype wired it identically). Nodes now chain at open time; pinned by a synthetic walked-loop test.
  6. Over-closing / room-folding. 43 "closures" in one 5-minute session folded the two recorded rooms onto each other: aliased matches pass every local gate in blobby rooms. Three data-derived defenses: witnesses are the largest scans (β‰₯150 beams β€” the 12-beam scraps were vetoing the 6000-beam composites' consensus), a closure needs two agreeing strong witnesses, and its correction must be plausible for the odometry drift accumulable over the gap it spans. Also: coverage is measured against beams landing in cells the target submap actually observed.

Bench result on the recorded room: brute-force relocalize went 0/13 β†’ 6/20 probes within 30 cm/20Β° β€” every miss a narrow-wedge probe, every wide head-sweep composite a hit β€” and MCL stopped locking onto wrong clusters. The clear operational lesson, now quantified: relocalization wants a deliberate head sweep, which the robot can do on demand (follow-up).

Integration

  • [maploc] in robotd.toml, off by default (documented in deploy/robotd.toml): enabled, mode = "stop_and_scan" | "continuous" (the requested toggle), map_path, wipe_on_boot. Sessions autosave once a minute + on shutdown, restore on boot.
  • robotd hosts it on a niced (+10) worker thread: the control loop pays one try_send per tick; depth frames arrive via a reconnecting subscription to tofd's socket; frames go through the Posture-aware (lean-corrected) reprojection.
  • robot.map (API v12 β†’ v13): a robot.state-style subscription; map.frame notifications carry the trinary grid as base64 (tiny RFC 4648 module in the proto), map-frame pose, tracking flag, submap/loop counts, at 1 Hz while subscribed.
  • Monitor: the path panel becomes " map " when frames flow β€” walls in braille, free space a sparse stipple, the robot its yellow marker (magenta ? while searching) β€” and stays the odometry track otherwise. m puts either view over the whole terminal.
  • Trimmed from the satellite: TCP telemetry (later, per decision), rand/rayon (pinned xoshiro β€” relocalize runs replay bit-for-bit β€” and std::thread::scope), the fixed-mount projection.

Testing

  • Workspace green (46 suites), clippy clean. New: 15+ maploc unit tests including the synthetic walked-loop closure test, accumulator voting, session round-trip (format bumped to v2 for the Scan reshape), b64 RFC vectors, map render.
  • Replay bench: cargo run -p maploc --release --example replay -- <session.mdlg> β€” builds the map (PGM dump), measures window self-consistency, probes relocalization with truth-pose scores. The prototype's recorded sessions in ~/MISC/microduck_maploc-rs/sessions/ drive it.
  • Hardware: enable [maploc], robotctl monitor, walk stop-and-scan around a room and watch the map build live; restart robotd and see the session restore. CPU: watch top β€” the worker is niced, and the loop's hz in the monitor should not move.

Follow-ups noted, not included: head-sweep-on-relocalize (the robot pans its own head to build the wide composite that provably relocalizes), robot.look-style goal/navigation (planner + follower are ported and dormant), MCL boot relocalize wiring in robotd (the crate machinery is fixed and tested; the robotd flow wants the head sweep first).

πŸ€– Generated with Claude Code

apirrone and others added 4 commits August 21, 2026 16:19
The satellite was already Rust; this is integration plus the bug hunt
it never got, run offline against its own recorded sessions via the new
replay bench (examples/replay.rs + kinematics dev-dep). Found and fixed,
each with the measurement that exposed it:

- Sensor-origin asymmetry: maps were inked from the sensor pose but MCL
  and relocalize scored beams from the body pose β€” a 10-15 cm systematic
  disagreement on a head-mounted sensor. Scan is now per-beam
  origin→endpoint pairs; every consumer derives world beams identically.
  The pair shape also makes scans composable: frames at one body pose
  with different head yaws merge exactly.

- Off-map cherry-picking: relocalize and MCL skipped out-of-bounds
  beams, so a wrong pose that threw 95% of its scan off the map was
  scored on the agreeing remainder β€” measured beating the true pose
  with mean residuals near zero from a tenth of the beams. Every beam
  now counts (off-map = full clamp).

- See-through degeneracy: endpoints-only scoring let poses dump whole
  scans into dense wall blobs at ~0 residual. One mid-ray occupancy
  sample per beam breaks it.

- The map noise floor itself: stop-and-scan inked every raw frame, so
  walking-person transients and the sensor's far-range noise tail set a
  ~9 cm floor that no gate survived (loop closures gated at 10 cm never
  fired; the true pose scored above the 5 cm relocalize acceptance).
  New `accumulator`: a still window's frames vote per endpoint cell,
  beams confirmed by β‰₯3 distinct frames survive, ranges cap at 2 m, one
  wide composite comes out. Bench result on the recorded room: probe
  hit-rate 0/13 β†’ 6/20 pre-closure, and loop closures fire at all.

- Witness quality in the loop closer: verification picked scans spread
  across the buffer, so 12-beam scraps vetoed the consensus of
  6000-beam composites β€” and after that fix, a lone weak witness could
  still swear in a wrong edge that warped every anchor. Witnesses are
  now the largest scans, minimum 150 beams, no fallback.

Trimmed relative to the satellite: no TCP telemetry (the monitor will
read robotd's socket), no rand/rayon (pinned xoshiro β€” a relocalize run
replays bit-for-bit β€” and std::thread::scope), no fixed-mount model
(kinematics::tof::flatten projects through the live head FK and the
IMU-levelled frame, with the new per-frame sensor origin). Session
format bumped to v2 for the Scan reshape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Struct-init over post-default assignment, the kernel index-loop lint
allowed once at crate level with its reasoning (the loops mirror the
matrix maths they implement), and the loop-closure tests pass a config
that admits their single 64-beam raycasts past the witness gate that is
tuned for the accumulator's composites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`pipeline::Slam` assembles submaps + graph + loop closure + optimizer
behind the calls a host makes (observe_odom / tick / integrate / render
/ save), so robotd and the replay bench drive the same code instead of
two copies that drift. The bench now runs on it.

Fixed on the way, each measured in the bench:

- The current submap's graph node was added with no edge attached β€” an
  unconnected node is invisible to the optimizer, and the tracked-pose
  correction after a loop closure rides that node. Closures moved every
  frozen anchor while live tracking sailed on uncorrected; the prototype
  wired it the same way. Nodes are now chained at open time. Pinned by
  a synthetic walked-loop test: drift 0.27 m, closure fires, tracking
  ends within a quarter of it.

- Loop-closure coverage was measured against every valid beam; a 360Β°
  composite matched against a small submap parks most beams outside
  what that submap ever OBSERVED, and honest matches got vetoed as
  low-coverage. The denominator is now beams landing in observed cells.

- Over-closing folded the two recorded rooms onto each other (43
  "closures" in a 5-minute session): aliased matches in blobby rooms
  pass every local-quality gate. Two defenses, both from the data: a
  closure needs at least two strong witnesses in agreement (lone-witness
  closures were the wrong ones), and its correction must be plausible
  for the odometry drift accumulable over the gap it spans.

Bench on the recorded room: 1 conservative closure, brute-force
relocalize 6/20 probes within 30 cm/20Β° β€” every miss a narrow-wedge
probe, every wide head-sweep composite a hit β€” and MCL no longer locks
onto wrong clusters (streak gates hold it at "searching", which is the
correct answer to ambiguous data).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[maploc] in robotd.toml (off by default β€” mapping is the most CPU-hungry
thing the robot can do): enabled, mode = stop_and_scan | continuous,
map_path, wipe_on_boot. When on, robotd spawns a niced (+10) worker
thread driving maploc::pipeline::Slam; the control loop's entire cost is
one try_send per tick (odometry pose, gravity, trunk height, head
joints, the moving flag), and depth frames arrive from a tokio task
subscribed to tofd's socket like any other client, reconnecting with
backoff. Frames go through the Posture-aware reprojection; stop-and-scan
mode routes them through the still-window accumulator so only vetted
composites ink the map. Sessions autosave once a minute and on shutdown,
and restore on boot.

robot.map (API v13) is a subscription on robotd's socket like
robot.state: the answer says whether this robot maps, then map.frame
notifications carry the rendered grid β€” trinary cells as base64 (a
small hand-rolled RFC 4648 module in the proto, so no client needs a
dependency to read a map) β€” plus the map-frame pose, tracking state and
submap/loop counts, at 1 Hz while anyone listens.

robotctl monitor subscribes on its existing robotd connection: the path
panel becomes " map " when frames flow (walls in braille, free space a
sparse stipple, the robot its yellow marker β€” or a magenta ? while the
pose is not to be trusted), and stays the odometry track otherwise.
`m` puts either one over the whole terminal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Coverage

74.08% lines on this branch, against a floor of 72%.

Per-file
Filename                           Regions    Missed Regions     Cover   Functions  Missed Functions  Executed       Lines      Missed Lines     Cover    Branches   Missed Branches     Cover
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
btd/src/adv.rs                          79                 0   100.00%           9                 0   100.00%          42                 0   100.00%           0                 0         -
btd/src/bluez.rs                       399               399     0.00%          30                30     0.00%         266               266     0.00%           0                 0         -
btd/src/framing.rs                     217                 7    96.77%          17                 1    94.12%          97                 5    94.85%           0                 0         -
btd/src/gatt.rs                          3                 0   100.00%           1                 0   100.00%           3                 0   100.00%           0                 0         -
btd/src/link.rs                         13                 0   100.00%           1                 0   100.00%          17                 0   100.00%           0                 0         -
btd/src/main.rs                         77                77     0.00%          11                11     0.00%          77                77     0.00%           0                 0         -
btd/src/pairing.rs                     149                 2    98.66%          12                 1    91.67%          81                 1    98.77%           0                 0         -
btd/src/route.rs                       194                24    87.63%          13                 0   100.00%         239                18    92.47%           0                 0         -
btd/src/session.rs                     936                68    92.74%          54                 4    92.59%         554                38    93.14%           0                 0         -
btd/src/upstream.rs                    294                43    85.37%          29                 8    72.41%         183                25    86.34%           0                 0         -
configd/src/bluez.rs                   441               441     0.00%          78                78     0.00%         297               297     0.00%           0                 0         -
configd/src/identity.rs                111                 5    95.50%          12                 1    91.67%          52                 3    94.23%           0                 0         -
configd/src/main.rs                    494               494     0.00%          38                38     0.00%         338               338     0.00%           0                 0         -
configd/src/net.rs                     246                12    95.12%          38                 2    94.74%         181                 4    97.79%           0                 0         -
configd/src/nm.rs                      271               213    21.40%          43                39     9.30%         171               120    29.82%           0                 0         -
configd/src/pad.rs                     301                12    96.01%          33                 2    93.94%         186                 8    95.70%           0                 0         -
configd/src/power.rs                    30                30     0.00%           6                 6     0.00%          28                28     0.00%           0                 0         -
configd/src/store.rs                   350                25    92.86%          28                 2    92.86%         173                 9    94.80%           0                 0         -
configd/src/units.rs                   119               119     0.00%          18                18     0.00%          83                83     0.00%           0                 0         -
duck-control/src/bus.rs                408               299    26.72%          33                21    36.36%         276               203    26.45%           0                 0         -
duck-control/src/imu.rs                370                 8    97.84%          20                 0   100.00%         195                 5    97.44%           0                 0         -
duck-control/src/io.rs                 127                19    85.04%          20                 5    75.00%         115                17    85.22%           0                 0         -
duck-control/src/model.rs              113                 1    99.12%          14                 0   100.00%          68                 0   100.00%           0                 0         -
duck-control/src/obs.rs                260                 3    98.85%          23                 1    95.65%         174                 5    97.13%           0                 0         -
duck-control/src/policy.rs             347               239    31.12%          41                24    41.46%         229               150    34.50%           0                 0         -
duck-control/src/safety.rs             459                17    96.30%          34                 3    91.18%         311                 9    97.11%           0                 0         -
duck-ipc-proto/src/lib.rs             1548               103    93.35%         104                11    89.42%        1128                67    94.06%           0                 0         -
kinematics/src/head.rs                 381                 9    97.64%          20                 0   100.00%         188                 4    97.87%           0                 0         -
kinematics/src/lib.rs                  229                11    95.20%          19                 2    89.47%         115                 7    93.91%           0                 0         -
kinematics/src/math.rs                 171                 0   100.00%          16                 0   100.00%          79                 0   100.00%           0                 0         -
kinematics/src/mjcf.rs                 225                32    85.78%          17                 1    94.12%         132                15    88.64%           0                 0         -
kinematics/src/tof.rs                  411                19    95.38%          15                 0   100.00%         223                12    94.62%           0                 0         -
maploc/src/accumulator.rs              303                 6    98.02%          15                 1    93.33%         154                 5    96.75%           0                 0         -
maploc/src/follower.rs                 155                 5    96.77%          11                 1    90.91%          99                 7    92.93%           0                 0         -
maploc/src/global_render.rs            328                 6    98.17%           5                 0   100.00%         168                 3    98.21%           0                 0         -
maploc/src/grid.rs                     865                79    90.87%          44                 3    93.18%         502                33    93.43%           0                 0         -
maploc/src/loop_closer.rs              626                21    96.65%          14                 0   100.00%         381                42    88.98%           0                 0         -
maploc/src/mapper.rs                   813                59    92.74%          34                 4    88.24%         597                49    91.79%           0                 0         -
maploc/src/mcl.rs                     1130               213    81.15%          44                11    75.00%         709               142    79.97%           0                 0         -
maploc/src/optimizer.rs                405                 3    99.26%           9                 0   100.00%         239                 7    97.07%           0                 0         -
maploc/src/pipeline.rs                 468                45    90.38%          18                 3    83.33%         286                21    92.66%           0                 0         -
maploc/src/planner.rs                  501                47    90.62%          16                 0   100.00%         307                27    91.21%           0                 0         -
maploc/src/pose_graph.rs               167                11    93.41%          15                 2    86.67%          92                 9    90.22%           0                 0         -
maploc/src/record.rs                   194                20    89.69%           7                 0   100.00%         115                 8    93.04%           0                 0         -
maploc/src/relocalize.rs               535                14    97.38%          20                 1    95.00%         340                 9    97.35%           0                 0         -
maploc/src/replay.rs                   472                49    89.62%          17                 1    94.12%         276                53    80.80%           0                 0         -
maploc/src/rng.rs                      125                 0   100.00%          10                 0   100.00%          73                 0   100.00%           0                 0         -
maploc/src/scan_matcher.rs             425                10    97.65%           8                 0   100.00%         237                 8    96.62%           0                 0         -
maploc/src/session.rs                  273                27    90.11%           7                 1    85.71%         151                13    91.39%           0                 0         -
maploc/src/submap.rs                   315                15    95.24%          21                 0   100.00%         166                 6    96.39%           0                 0         -
maploc/src/submap_manager.rs           335                 4    98.81%          22                 1    95.45%         210                 4    98.10%           0                 0         -
odometry/src/lib.rs                    321                 5    98.44%          22                 1    95.45%         192                 7    96.35%           0                 0         -
padd/src/main.rs                       398               398     0.00%           9                 9     0.00%         287               287     0.00%           0                 0         -
padd/src/tap.rs                        693               455    34.34%          42                25    40.48%         419               267    36.28%           0                 0         -
pet-detect/src/bin/detect.rs            61                61     0.00%           1                 1     0.00%          38                38     0.00%           0                 0         -
pet-detect/src/bin/features.rs          46                46     0.00%           2                 2     0.00%          20                20     0.00%           0                 0         -
pet-detect/src/lib.rs                  358               180    49.72%          27                13    51.85%         217               115    47.00%           0                 0         -
pet-detect/src/worker.rs               293               293     0.00%          17                17     0.00%         223               223     0.00%           0                 0         -
robotctl/src/duck.rs                  1211               144    88.11%          63                 1    98.41%         604                47    92.22%           0                 0         -
robotctl/src/main.rs                  2674              1256    53.03%         159                71    55.35%        1943               909    53.22%           0                 0         -
robotctl/src/monitor.rs               3586               690    80.76%         198                38    80.81%        2234               448    79.95%           0                 0         -
robotctl/src/path_map.rs               703                72    89.76%          40                 2    95.00%         389                46    88.17%           0                 0         -
robotd/src/control.rs                  298               261    12.42%          17                12    29.41%         258               211    18.22%           0                 0         -
robotd/src/intents.rs                  301                11    96.35%          30                 2    93.33%         226                 9    96.02%           0                 0         -
robotd/src/main.rs                    3675              1135    69.12%         176                44    75.00%        2274               734    67.72%           0                 0         -
robotd/src/maploc.rs                   514               514     0.00%          17                17     0.00%         359               359     0.00%           0                 0         -
robotd/src/params.rs                   431                11    97.45%          37                 0   100.00%         346                 4    98.84%           0                 0         -
robotd/src/soc.rs                       47                25    46.81%           4                 1    75.00%          30                15    50.00%           0                 0         -
robotd/src/sound.rs                    479               217    54.70%          27                12    55.56%         269               136    49.44%           0                 0         -
sounds/src/lib.rs                      162                30    81.48%          14                 6    57.14%          84                18    78.57%           0                 0         -
sounds/src/main.rs                     200               200     0.00%           7                 7     0.00%          99                99     0.00%           0                 0         -
sounds/src/personality.rs              174                 0   100.00%           6                 0   100.00%          84                 0   100.00%           0                 0         -
sounds/src/rng.rs                      173                 0   100.00%          18                 0   100.00%          96                 0   100.00%           0                 0         -
sounds/src/synth.rs                    353                 7    98.02%          29                 0   100.00%         197                 3    98.48%           0                 0         -
sounds/src/voices.rs                   679                 2    99.71%          26                 0   100.00%         353                 2    99.43%           0                 0         -
test-support/src/lib.rs                272                 2    99.26%          22                 0   100.00%         167                 0   100.00%           0                 0         -
tof/src/lib.rs                          88                 0   100.00%           9                 0   100.00%          54                 0   100.00%           0                 0         -
tof/src/main.rs                        476               436     8.40%          23                18    21.74%         303               281     7.26%           0                 0         -
tof/src/sensor.rs                      230               148    35.65%          22                15    31.82%         163               114    30.06%           0                 0         -
tof/src/status.rs                       76                 2    97.37%           7                 1    85.71%          58                 1    98.28%           0                 0         -
updater/src/config.rs                  428                24    94.39%          37                 4    89.19%         349                17    95.13%           0                 0         -
updater/src/engine.rs                 2419               357    85.24%         168                20    88.10%        1571               207    86.82%           0                 0         -
updater/src/faults.rs                   69                 7    89.86%           7                 0   100.00%          51                 0   100.00%           0                 0         -
updater/src/fsutil.rs                  104                30    71.15%          10                 6    40.00%          51                24    52.94%           0                 0         -
updater/src/hooks.rs                   388                 9    97.68%          30                 1    96.67%         326                 8    97.55%           0                 0         -
updater/src/ipc.rs                     755               219    70.99%          60                16    73.33%         489               126    74.23%           0                 0         -
updater/src/journal.rs                 831                78    90.61%          57                10    82.46%         482                53    89.00%           0                 0         -
updater/src/lib.rs                      42                12    71.43%           4                 0   100.00%          32                12    62.50%           0                 0         -
updater/src/main.rs                    566               240    57.60%          37                13    64.86%         416               154    62.98%           0                 0         -
updater/src/manifest.rs                159                 6    96.23%          16                 0   100.00%         110                 1    99.09%           0                 0         -
updater/src/orphan.rs                  288                 6    97.92%          25                 0   100.00%         181                 3    98.34%           0                 0         -
updater/src/preflight.rs               380                14    96.32%          46                 3    93.48%         282                 9    96.81%           0                 0         -
updater/src/reconcile.rs               292                19    93.49%          25                 5    80.00%         172                 7    95.93%           0                 0         -
updater/src/robot.rs                   212                20    90.57%          31                 4    87.10%         128                10    92.19%           0                 0         -
updater/src/source/github.rs           474               169    64.35%          51                26    49.02%         304                99    67.43%           0                 0         -
updater/src/source/hf_hub.rs           136                72    47.06%          20                12    40.00%          81                41    49.38%           0                 0         -
updater/src/source/http.rs             319                82    74.29%          29                11    62.07%         260                88    66.15%           0                 0         -
updater/src/source/local.rs            346                37    89.31%          36                 9    75.00%         192                26    86.46%           0                 0         -
updater/src/source/mod.rs               39                30    23.08%           4                 2    50.00%          30                24    20.00%           0                 0         -
updater/src/spawn.rs                    94                13    86.17%           5                 0   100.00%          49                 4    91.84%           0                 0         -
updater/src/store.rs                   593                41    93.09%          43                 7    83.72%         271                42    84.50%           0                 0         -
updater/src/verify.rs                  786                95    87.91%          55                18    67.27%         424                75    82.31%           0                 0         -
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
TOTAL                                46900             11546    75.38%        3051               820    73.12%       29640              7683    74.08%           0                 0         -

apirrone and others added 2 commits August 21, 2026 17:11
Field test one: enable maploc, stand the robot up, watch the monitor β€”
nothing. Three causes, three fixes:

- The still-window only integrated on the still→moving TRANSITION, so a
  robot standing still accumulated forever and never inked. Windows now
  flush every 3 s while the stand continues.

- Empty submaps froze on age β€” nine husks in the graph after a couple
  of minutes of standing, each a dead node dragging the optimizer.
  A submap with no content re-anchors at the current pose instead of
  freezing (TickOutcome::Reanchored; the pipeline moves its node and
  updates the inbound edge measurement to match).

- Nothing said what mapping was doing. map.frame now carries `windows`
  (integrated so far) and `still` (a window is accumulating right now);
  the panel caption reads "N windows Β· M submaps Β· K loops Β· scanning",
  so "no walls yet" and "no scans ever reached the map" stop looking
  identical. Window integration logs at info.

And per request: the odometry path draws in red β€” in the path view and
overlaid on the live map (same world frame until loop closures diverge
them, and worth seeing together even then).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field test two: "0 windows Β· 71 submaps" β€” the 71 are husks restored
from the previous build's session, but the zero needs data, not another
theory. The worker now logs a status line every 5 s (odom samples,
frames received/kept, windows, the still verdict with the moving flag
and the 500 ms odometry deltas it was decided from, window size,
submaps), says when it connects to tofd, and no longer swallows the
connect error silently β€” that silence already cost an afternoon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
apirrone and others added 11 commits August 21, 2026 17:32
Field test three, with the new status line: odom flowing, stillness
perfect, frames=0 β€” the depth feed never delivered a single frame. The
task was spawned on the control loop's runtime, which is deliberately
built time-only: no IO driver, so the UnixStream connect panicked the
task on its first poll, silently, inside a JoinHandle nobody reads.

The feed now runs on its own thread with its own current_thread runtime
built with enable_io β€” which is also simply better: tofd's socket I/O
and 15 Hz of JSON parsing never belonged on the control thread at all.
Niced like the worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four field-test-two fixes:
 - the age rule only fires once the robot has moved 15 cm from the
   anchor β€” standing (or sitting) still accrues no drift to bound, and
   the old rule minted an identical submap every 8 s (24 -> 53 over one
   seated coffee break, all junk for the loop closer);
 - windows under 60 beams are discarded, not inked: a seated robot's
   3-second windows distilled to 2-27 beams of floor clutter;
 - a vetted window inks twice: one pass wrote log-odds 85 per wall cell
   and the wire frame calls a wall at 150, so a lap that stopped once
   per spot painted the whole walk invisibly β€” the 'scattered white
   points' were the rare twice-visited cells;
 - the map view's red path is now the tracked pose history, not raw
   odometry: after a session resume or a closure the two live in
   different frames, and the odometry path diverging from the robot
   marker reads as a bug when it is a frame.

Also: loop closures now log their correction, and a fruitless window
flush disarms the ripe timer instead of thrashing single-frame flushes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The moving flag fed maploc's stillness gate through
`twist_magnitude() > 0.0` β€” and a gamepad twist idles at a
not-quite-zero value below the walking threshold, so the flag stayed
latched true through an entire stop-and-scan lap: the robot stood,
odometry read zero, and mapping refused every stop (kept=124 frozen
for two minutes in the field-test journal). The policy already decides
standing versus walking; `moving` now follows that decision.

Also makes safeToRestart stop saying no to a robot that is only
holding a drifting stick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bench case

Three things, one commit because they are one design:

Mapper (maploc::mapper) β€” the mapping host loop moves out of robotd's
worker into the crate: stillness, window vetting, and a new
lost/relocalize state machine. Before a window inks it is scored against
the map at the tracked pose (relocalize::score_pose); a window the map
can judge but flatly contradicts flips tracking to lost β€” a kidnapped
robot's scans land in territory the map knows and disagree everywhere,
while a robot exploring a new room lands in cells the map cannot judge
and keeps mapping. While lost nothing inks, and every window becomes a
brute-force relocalize attempt (decimated to 256 beams β€” full composites
would cost seconds a try); an accepted pose snaps tracking back and
mapping resumes. The same watchdog heals a resumed session whose robot
moved while the daemon was down. robotd's worker is now a thin host:
channel in, log lines and map frames out.

Recorder (maploc::record, [maploc] record_dir) β€” .mdlg format v2:
everything the mapper consumes (odometry, gravity, trunk height, head,
moving/sitting, raw ToF zones) appended to a timestamped log, ~6 KB/s.

Ground-truth bench (examples/evaluate) β€” replays a v2 log through the
SAME Mapper against a tape-measured room (truth.toml, centimetres and
degrees): return-to-start error vs raw odometry, kidnap detection and
relocalization latency + pose error, map-vs-room wall statistics, PGMs
with the truth walls burned in. The protocol's kidnap marker is a SIT:
odometry cannot see a carry, but it cannot miss a sit β€” and the mapper
now refuses to map from sitting height anyway. room_lab.toml carries the
lab digitized from the CAD sketch; the start/kidnap poses are scaled off
the drawing and should be corrected with a tape measure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replayed the first ground-truth recording (lap + sit-carry kidnap in the
tape-measured lab) through the bench until the kidnap recovered. Four
measured failure modes, four fixes:

 - The see-through clamp punished beams for hitting the wall they
   measured: a robot 10 cm from a divider had 4200 of 10800 beams
   'seeing through' it (midpoint of a short or grazing beam lands in
   the wall's own cells) β€” the false LOST at t=35 s. The watchdog is
   now endpoint-only (at a trusted pose there is no blob degeneracy to
   exploit); the search keeps the clamp but only for crossings well
   away from the beam's endpoint.
 - A young sparse map let a wrong relocalize basin score 0.022 and
   poisoned the whole run (return-to-start went 0.43 m / 95Β° while raw
   odometry was 0.14 m / 0.1Β°). A candidate now needs the NEXT window,
   carried by odometry, to confirm it β€” with a coverage floor, because
   a keyhole wall wedge aliases onto any wall at the same range
   (measured: 204/1680 kidnapped beams landed on old walls at residual
   0.005, 0.3 m from the truth).
 - A kidnapped stand vouched for itself: its first window painted the
   kidnapper's room and every later window 'agreed with the map'.
   Windows are now judged against a snapshot of the map from when the
   stand began, contradictions quarantine before they ink, and lost
   takes two consecutive contradicting windows.
 - Geometry cannot detect a carry through a 45Β° keyhole at all when
   the scene aliases or lands in unknown (measured: both). But the
   robot KNOWS it sat: sitting arms the lost machinery with 'I was not
   moved' pre-seeded as the candidate β€” an unmoved robot confirms in
   one window, a kidnapped one is refused by the coverage floor and
   falls through to the search.

On the recording: kidnap recovered 8.5 s after the stand, a loop
closure fired after recovery, the final sit-confirm found the returned
robot 0.2 m / 10Β° from its boot pose, and map-vs-room went from mean
0.57 m / p90 1.24 to mean 0.14 m / p90 0.28.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cion can expire

The relocalization triggers are now every moment the robot stops being
able to vouch for its pose:

 - a SIT (it cannot feel a carry),
 - a FALL (it can be dragged and spun β€” new 'fallen' bit through
   OdomSample and the .mdlg flags byte),
 - a SESSION RESUME (it may have been moved, or booted in another room,
   while the daemon was off β€” previously the saved pose was trusted
   outright),
 - and the scan watchdog, for displacements the scans can prove.

All four arm the same machinery: tracking continues on odometry,
nothing inks, and each still window either CONFIRMS the carried
'I was not moved' hypothesis (one window when true β€” a reboot in place
costs ~3 s of paused mapping), REFUTES it (evidence of displacement:
suspicion hardens and the brute-force search takes over), or cannot
judge it. Soft suspicion that stays unjudgeable for 10 windows expires
and tracking resumes unverified at the odometry pose β€” without that
escape, a robot that sits facing an unmapped corner says 'searching'
forever; with evidence of displacement the escape never applies.

Bench regression on the recorded kidnap session: identical (recovery
8.5 s after the stand, map-vs-room mean 0.143 m).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s a room

Two things the last field round asked for:

Head sweep while searching ([maploc] search_sweep, default on): a
single static 45Β° wedge aliases onto any wall at the same range β€”
measured, it is why kidnap confirmation needs a coverage floor at all β€”
and the bench scored 0-for-13 relocalizes on wedges vs 6-for-10 on
wide composites. While the mapper cannot vouch for its pose and the
robot stands, the control loop sweeps head yaw Β±0.9 rad over 6 s (slow
enough that every cell survives the accumulator's 3-frame vote); the
window accumulator already merges the pan into one ~150Β° composite via
per-beam origins. Only head yaw, only while searching; the command EMA
glides the takeover and the handback. The worker publishes searching
through an AtomicBool on the Host.

Monitor map render: confirmed floor is now a solid dark background
fill and walls fill their whole cell footprint in bright braille,
instead of one dot per wall cell over a 1-in-5 floor stipple β€” a lap
used to render as scattered stars; a room should read as a shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
robot.map_wipe (API v14): resets the mapping session in place β€” map,
pose graph, tracked pose and any suspicion β€” and deletes the saved
session file. Mapping starts fresh from wherever the robot stands; no
ssh, no daemon restart. The field workflow was
'sudo rm /var/lib/robot/maploc.session && systemctl restart robotd'
between every experiment, which is three moving parts too many for the
thing done most often.

The worker takes it as an event on its existing channel; the IPC side
reaches it through a OnceLock<Host> the control loop sets at spawn.
Refused (as an intent, not an error) when mapping is off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t map

Field test five's second kidnap 'failed to relocalize' for three
minutes: the stand press after the carry never reached robotd (no
'sit toggle direction=stand' in the journal, trunk kinematics frozen
at sit height for 200 s β€” likely a pad/BT drop while the robot was
carried out of range), so the controller stayed in Sitting and the
mapper β€” correctly β€” refused to map or relocalize from the floor. The
monitor said only 'searching', which reads as a relocalization failure
when it is a robot waiting to be stood up.

MapFrame gains an additive 'seated' flag (serde default, no API bump);
the map caption now says 'seated β€” stand the robot to map', and the 5 s
status log spells out sitting/fallen alongside moving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field test five, second carry, decoded from its recording: the 'I was
not moved' seed confirmed on a single static wedge (residual 0.010 at
the OLD pose β€” the new spot's wall matched the old spot's wall), and
half of that confirmation was free: the still window that flushes at
the sit describes the world BEFORE the carry and trivially agrees with
the pre-carry pose.

Two changes, both verified by replaying that recording:

 - the soft seed now needs TWO consecutive agreeing windows before
   tracking resumes on it (the search path already had two windows by
   construction). The head sweep keeps running between them, so the
   second window covers a different arc β€” independent evidence instead
   of the same wedge twice;
 - suspicion arms AFTER the window flush, so the pre-sit window inks
   as ordinary tracked data instead of counting as agreement #1.

On the recording: the seed no longer false-confirms; the search finds
the true post-carry pose, the next window confirms it, a loop closure
lands, and window-vs-room agreement drops from 0.40 m to 0.028 m.
Also folds the candidate check into a method β€” the four-parameter
closure was the construct two rustfmt versions disagree about (CI red).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The prototype panned the head 130-230 degrees at every mapping stop β€”
that width is where its map quality came from, and the port only swept
while searching: an ordinary stop kept whichever 45-degree wedge the
head happened to face and threw the rest of the stop away. A messy
office at ankle height needs every degree of that arc to read as more
than scattered blobs.

Same sweep, same param, same accumulator (per-beam origins were built
for the pan); the head comes back to the commanded pose as soon as the
robot moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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