Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ benchmark_results/
bag_replay_results/
# Parameter-sweep outputs (timestamped rankings/reports/merged params)
sweep_results/
# Map-build outputs (a candidate revision, its bundle and its build report)
map_build_results/

# Rendered cloud-init (`pixi run provision`): carries a Tailscale auth key and a
# wifi passphrase, so it must never be committed even by accident.
Expand Down
61 changes: 60 additions & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

15 changes: 11 additions & 4 deletions docs/design/mapping-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,17 @@ Sized so each is one dispatchable task; existing tasks noted.
`slam_toolbox_build_params.yaml` beside the live file, one divergent key,
held to it by `test_slam_build_params.py`. It also records what was measured
and rejected, so the same sweep is not run twice.
4. **`map-build` orchestrator** — the stage-2 chain as one command on the
fleet box, emitting a validated candidate + build report. Depends on the
lockstep harness landing (task 295 / PR 91), prominence picking (337), and
item 10 for the alignment step's gate.
4. **`map-build` orchestrator** — landed, less its alignment step:
`mote_simulation/tools/map_build/`, `pixi run map-build`. Solve, assemble,
declutter, segment, validate, score against a baseline, package — one
command, and on the 2026-08-02 bag it reproduces the hand-built map's loop
drift to a millimetre in 21 s of solve
(`docs/tuning/2026-09-01-map-build.md`). **Step 2 is not in it**: with no
estimator that can say which of two solves is squarer, the step is
undecidable rather than merely ungated, so birth-alignment stays an
operator's `--frame X Y YAW` — recorded in the revision's meta — until item
10 lands. Steps 5 and 7's upload wait on items 6 and 5 respectively; both
gaps are reported on the candidate rather than left silent.
5. **Build identity** — the registry accepts candidate uploads from a
credentialed builder, not only enrolled robots; audit rows name it.
6. **Vocabulary carry-forward** — same-frame rebinding by containment +
Expand Down
124 changes: 124 additions & 0 deletions docs/tuning/2026-09-01-map-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# `map-build` against the map it was written to reproduce

The 2026-08-02 flat map was built by hand: a lockstep re-solve of the session's
bag under corrected parameters, a measured-then-injected frame rotation, a
declutter pass with a hand-tuned threshold, room segmentation, and a
hand-assembled revision side-loaded onto the robot. `pixi run map-build` is
that chain as one command. This is what it produces on the same bag.

Everything below is in `2026-09-01-map-build/`: the build's own report, the map
it emitted, the map it is compared against, and `build.json`.

## 0. The two artifacts

| | promoted (by hand, 2026-08-02) | built (`map-build`, 2026-09-01) |
| --- | --- | --- |
| revision | `home/ground/20260802T203339` | `20260901T180343` |
| bag | `20260802_142539` | the same, sha256 `099f9d06…` |
| params | live file + chain 10 + `coarse_angle_resolution` 0.0175 | committed `slam_toolbox_build_params.yaml` (chain 10, `coarse_angle_resolution` 0.0349) |
| frame | `--frame 0 0 -3.0` | the same, passed on the command line |
| declutter | `peak_rel_threshold` 0.55, hand-tuned | the committed default, prominence-picked (task 337) |
| size | 236x181 @ 0.05 m/px | 240x182 @ 0.05 m/px |

The two parameter differences are not slips. `coarse_angle_resolution` 0.0175
was adopted in the hand build on the reasoning that the live 2.0° value snapped
solutions to an orientation lattice; there is no such lattice, and the finer
sweep beat the live value on nothing
(`2026-08-25-slam-build-params.md` §2), so the committed build file keeps the
live value. The hand-tuned declutter threshold is what task 337 replaced.

## 1. What the build did

```
solve ok — 340 pose-graph nodes from 542 scans in 21 s
assemble ok — 240x182 @ 0.050 m/px, origin (-5.316, -5.211, 0.000)
declutter ok — -1244 cells, +277
segment ok — 9 room zone(s) proposed
validate ok — valid
score ok — 1 metric worse than the baseline: map.speckle_frac
package ok — 7101101 bytes
```

**21 seconds of solve for a 21-minute bag**, unattended, from `--bag` to a
validated candidate and a build report. That is the economy the whole design
rests on, and it is the lockstep harness's (task 295), not this tool's.

## 2. It reproduces the map

| metric | built | promoted | verdict |
| --- | --- | --- | --- |
| loop drift (start↔end, m) | **0.0992** | 0.098 (recorded in the hand build's meta) | reproduced |
| drift ratio | 0.00071 | — | |
| mean wall thickness (m) | 0.0602 | 0.0607 | same |
| speckle fraction | 0.00364 | 0.00277 | worse |
| explored area (m²) | 63.3 | 62.8 | same |
| wall directions | 4 | 4 | same |
| second wall frame | 18.5° off dominant, 0.219 of energy | — | the flat's angled wing |

Loop drift lands within a millimetre of the hand build's, which is the number
that matters: it is the only one of these that measures the *solve*. The
second orthogonal frame at 18.5° carrying about a fifth of the energy is the
flat's angled wing, exactly as the 2026-08-25 report found it.

The speckle difference — 0.0009 of occupied cells — is the declutter
thresholds, not the solve: the hand build stripped more short structure at
`peak_rel_threshold` 0.55 than the committed prominence-picked default does.
It is reported as review evidence and gates nothing, which is what the design
asks for.

Side by side, `map.png` and `promoted-map.png`: the same flat, the same
layout, the built one a degree or two rotated and holding slightly more
short-wall detail.

## 3. Two ways the report was wrong before it was right

Both were found by running the acceptance and reading the numbers, and both
would have told a reviewer to reject a good map.

**The candidate was scored from the raw solve and the baseline from disk.**
The replay leg carries map metrics, but they describe the image *before* the
declutter pass, while a stored revision only ever keeps the cleaned one. The
first acceptance run reported the candidate's speckle as 0.0137 against the
baseline's 0.0028 — five times worse — when the two *served* maps are
0.0036 and 0.0028. Both sides now go through one function that reads a
revision's own `map.png` at the thresholds its own `map.yaml` declares.

**`unknown_frac` moves with the bounding box.** It is a fraction of the grid,
so the candidate — 4 px wider than the baseline — read 2.2% worse on it while
covering 0.4 m² *more* floor. It is out of the diff table;
`explored_area_m2` carries the same signal in metres and does not depend on
the canvas. It is still in `build.json`, like everything `map_quality`
measures.

The general rule both cases are instances of: a diff row is a claim that the
metric ranks one candidate against another. `angular_support_deg` was already
excluded on the same grounds (it is confounded by coverage).

## 4. What the acceptance asked for and could not be checked

The task's acceptance included "walls ≤ 0.5° off axis". That assertion cannot
be made: `2026-09-01-alignment-residual.md` established that the map has no
single wall grid to half a degree — thirds of this building disagree by 8°, and
there is a second family 18° off — and that the estimator in the tree reports
maps 3.5–5.6° out as square. The design was corrected accordingly (#111) and
the build measures wall structure and prints it rather than asserting a
residual. An orientation estimator the alignment step can be gated on is task
615.

## 5. What the run also confirmed, and is not this tool's

The nine room proposals are visibly tilted about 2° off the walls they enclose
(`rooms.png`) — task 349, `segment-map`'s wall-alignment estimator on a
multi-family building, reproduced here on the building it was reported against.

## Reproducing this

```bash
pixi run map-build -- \
--bag ~/.mote/bags/mapping/20260802_142539 \
--site home --floor ground --frame 0 0 -3.0 \
--baseline <a copy of home/ground/20260802T203339>
```

The bag is on the robot under `~/.mote/bags/mapping/`; the baseline revision is
that floor's current map. `build.json` records both digests.
96 changes: 96 additions & 0 deletions docs/tuning/2026-09-01-map-build/build-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Map build 20260901T180343

**candidate emitted** — `/home/michael/.claude/jobs/c1592bca/tmp/acceptance3/20260901T170343Z/revision/20260901T180343` — validated, packed as `20260901T180343.tar.gz`

## Inputs

| input | value |
|---|---|
| bag | /home/michael/.mote/bags/mapping/20260802_142539 |
| bag sha256 | 099f9d0608ec818c75a3c5792c13b7e5a4d58a9c5fcd8f005a43a40088750f40 |
| bag bytes | 191910513 |
| slam params | /home/michael/Projects/mote/.claude/worktrees/map-build-orchestrator/mote_bringup/config/slam_toolbox_build_params.yaml |
| params sha256 | 072929cf88c0147758b2a471546c7fac9a236b1ffef8d14c25981d54a3b4bb37 |
| frame injection (x, y, yaw°) | [0.0, 0.0, -3.0] |
| feed | lockstep |
| harness commit | 1c75eac |
| built (UTC) | 20260901T170343Z |

## Stages

| stage | outcome | detail |
|---|---|---|
| solve | ok | 340 pose-graph nodes from 542 scans in 21 s |
| assemble | ok | 240x182 @ 0.050 m/px, origin (-5.316, -5.211, 0.000) |
| declutter | ok | -1244 cells, +277, wall directions [11.2, 88.2, 110.2, 178.2] |
| segment | ok | 9 room zone(s) proposed |
| carry forward | stub | task 345 — names are reported, not rebound |
| validate | ok | valid |
| score | ok | 1 metric(s) worse than the baseline: map.speckle_frac |
| package | ok | 20260901T180343.tar.gz, 7101101 bytes, sha256:a1d6a4393b634f43… |

## Validation

`bundle.validate` — **valid**

- no errors, no warnings

## Metrics

Baseline: `/home/michael/.claude/jobs/c1592bca/tmp/baseline/20260802T203339`

These are **truth-free proxies**: the bag carries no ground truth, so a confidently wrong map can score well. Read them beside the map.

The `map.*` rows are the map this revision **serves** — after the declutter pass — on both sides, because that is what a promotion publishes. The raw solve's are in `build.json` under `map_raw`.

| metric | candidate | baseline | delta | vs baseline | reading |
|---|---|---|---|---|---|
| loop.start_end_dist_m | 0.09919 | — | — | — | lower is better — start↔end distance, if the run closed |
| loop.drift_ratio | 0.0007105 | — | — | — | lower is better — that distance over path length |
| map.mean_wall_thickness_m | 0.06015 | 0.0607 | -0.0005498 | same | lower is better — wall crispness; blur reads thicker |
| map.speckle_frac | 0.003637 | 0.002768 | 0.0008691 | worse | lower is better — isolated occupied cells |
| map.explored_area_m2 | 63.26 | 62.83 | 0.435 | same | higher is better — decided cells × cell area |

A change under 2% reads as `same`: the solver is not bit-identical run to run. **Nothing here blocks** — a regression is evidence for the reviewer, not a gate.

## Wall structure

| frame | angle (deg) | directions | energy share | off dominant |
|---|---|---|---|---|
| 0 | 2.25 | 2 | 0.5785 | 0 |
| 1 | 73.75 | 2 | 0.219 | 18.5 |

`angular_support_deg` 50.7, 4 wall direction(s), dominant frame share 0.5785. Support is **not** a quality ranking — a map that explored less has fewer long walls and reads as tighter.

A rectilinear building puts every wall in one frame. A second frame carrying real energy with **two** directions in it means a section of the map is drawn on its own axes — a tear. A second frame with one direction is an angled hallway, which is architecture.

The build does **not** align the map frame. Measuring a map's wall rotation well enough to gate a re-solve on it is task 615 (`docs/tuning/2026-09-01-alignment-residual.md`): the estimator in the tree called four maps square that were 3.5–5.6° out. Until it lands, birth-alignment is an operator's judgment, passed as `--frame X Y YAW`, and recorded above.

## Zones

Segmentation proposed 9 room(s): `room_01`, `room_02`, `room_03`, `room_04`, `room_05`, `room_06`, `room_07`, `room_08`, `room_09`

**Not carried forward**: the baseline floor names 7 place(s) — `room_01`, `room_02`, `room_03`, `room_04`, `room_05`, `room_06`, `room_07`. Re-binding them onto this map's rooms is task 345; until it lands the reviewer renames the placeholders above in the dashboard's zone editor, which is where a name is edited on a candidate anyway.

## Renders

### Built map (served)

![Built map (served)](map.png)

### Raw solve

![Raw solve](map_raw.png)

### Declutter diagnostics

![Declutter diagnostics](diagnostics.png)

### Proposed rooms

![Proposed rooms](rooms.png)


## Next

Review the map above, then upload `20260901T180343.tar.gz` to the registry as a candidate for `home/ground`. The upload route accepts enrolled robots only, so a builder needs a credential of its own — that is task 344; until it lands, a robot at the site can side-load the revision directory into its floor and `pixi run publish-map --revision 20260901T180343`. Promotion is unchanged: an operator's audited call, in the dashboard or `fleetctl promote`.
Loading