Conversation
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>
Coverage74.08% lines on this branch, against a floor of 72%. Per-file |
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Absorbs
microduck_maploc_rs(already Rust) as themaploc/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.mdlgsessions, using a replay bench that ships with the crate.The bugs, each with the measurement that exposed it
Scanis now per-beamorigin β endpointpairs; every consumer derives world beams identically. The shape also makes scans composable β frames at one body pose with different head yaws merge exactly.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.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 indeploy/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.try_sendper 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): arobot.state-style subscription;map.framenotifications 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.?while searching) β and stays the odometry track otherwise.mputs either view over the whole terminal.rand/rayon(pinned xoshiro β relocalize runs replay bit-for-bit β andstd::thread::scope), the fixed-mount projection.Testing
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.[maploc],robotctl monitor, walk stop-and-scan around a room and watch the map build live; restart robotd and see the session restore. CPU: watchtopβ the worker is niced, and the loop'shzin 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