Skip to content
Merged
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
235 changes: 107 additions & 128 deletions CLAUDE.md

Large diffs are not rendered by default.

16 changes: 7 additions & 9 deletions docs/design/mapping-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ at the end is sized so each item becomes one dispatchable task.
> its validator (`mote_bringup/mote_bringup/sites.py`,
> `mote_bringup/mote_bringup/bundle.py`), the map registry and its promotion
> flow (`docs/design/fleet.md` M4, `mote_fleet/server/bundle_store.py`), the
> zone vocabulary split (names travel, coordinates do not — fleet-api.md), the
> zone vocabulary (`GET /v1/zones` serves the names — fleet-api.md), the
> declutter/segmentation passes (`mote_bringup/mote_bringup/map_cleanup/`), and
> the lockstep replay harness (task 295, `mote_simulation/tools/bag_replay/`).
> None of those move. What this design changes is **what happens between them,
Expand Down Expand Up @@ -155,13 +155,11 @@ done by hand on 2026-08-02:
3. **Declutter** — the FFT structure pass with prominence-based peak picking
(task 337); no hand thresholds.
4. **Segment** — room polygons from the cleaned map, as today.
5. **Carry forward the vocabulary** — the previous revision's zone names,
aliases, kinds and taught poses re-bind onto the new geometry: same-frame
rebuilds by containment (a named zone whose pose lands inside a proposed
room claims it); new frames get proposed matches for the operator to
confirm in review. Placeholder names are minted only for genuinely new
rooms. (This is the vocabulary/binding split doing work: names are the
stable half, coordinates are rebuilt.)
5. **Carry forward the names** — the previous revision's zone names, notes
and poses re-bind onto the new geometry: same-frame rebuilds by containment
(a named zone whose pose lands inside a proposed room claims it); a new
frame gets proposed matches for the operator to confirm in review.
Placeholder names are minted only for genuinely new rooms.
6. **Validate + score** — `bundle.validate`, then the truth-free metrics
(loop drift when the trajectory closes, explored area, speckle, wall
thickness) diffed against the current canonical revision. Regressions
Expand Down Expand Up @@ -194,7 +192,7 @@ Requirements, in priority order:
a polygon, and place a pose by clicking the map (`⌖`), which is what replaced
driving to a goto target — `save-zone` remains for poses that need a real
approach heading. A save derives a new candidate from the one under review
rather than writing into it (bumping `vocabulary_revision`); the source's
rather than writing into it (bumping the floor's `revision`); the source's
bytes never change and the result stays inert until promoted. Still to do:
accepting or rejecting a proposed carry-forward match.
- **See the build report.** The scoring diff from stage 2, on the same page
Expand Down
20 changes: 11 additions & 9 deletions docs/fleet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1076,10 +1076,12 @@ holds, ignores the rest of the fleet's, downloads the revision, checks its
digest, stages it in a temporary directory, renames it into `maps/<rev>/` and
flips its local `map` symlink. A half-transferred revision is never visible.

**Zones travel with the map.** A revision from a different mapping session is a
different map frame, so the zones bound in the old one are wrong the moment the
new map is published — the bundle's `binding.yaml` therefore replaces the floor's,
and the one it replaces is kept beside it as `zones.<old-rev>.yaml`.
**A revision carries a copy of the floor's zones.** The floor owns them — a zone
is a coordinate in the floor's frame and a revision is an estimate registered
into it — but the revision is how a floor's places reach a robot that has never
driven there, and the copy inside the revision an operator promoted is the
fleet's current answer. Installing one therefore replaces the floor's
`zones.yaml`, keeping the one it replaces beside it as `zones.<old-rev>.yaml`.

**The running navigation stack keeps the map it loaded.** Nav2's `map_server`
reads the map at startup, so the flip takes effect on the next `pixi run robot`
Expand Down Expand Up @@ -1143,10 +1145,10 @@ curl -s http://fleet-box:8080/v1/zones/home/ground | python -m json.tool
```

This is what to point a dispatcher at — anything turning "take it to the
kitchen" into `goto kitchen`. It is safe to hand out precisely because it
carries no coordinates: a vocabulary is portable, a binding is not. The route
that *does* carry coordinates is `/v1/maps/<site>/<floor>/zones.json`, and it
is for the thing drawing zones on the basemap, which already has the basemap.
kitchen" into `goto kitchen`. It carries no coordinates because a caller of this
route has nothing to draw one on, not because a coordinate would be wrong. The
route that *does* carry them is `/v1/maps/<site>/<floor>/zones.json`, and it is
for the thing drawing zones on the basemap, which already has the basemap.

### Teaching the vocabulary

Expand All @@ -1171,7 +1173,7 @@ where a hand-maintained list of spellings was one more thing to keep in step.

`pixi run segment-map` gives every candidate it proposes a footprint and nothing
else; the names it invents (`zone_01`…) are placeholders for you to replace, in
the dashboard's zone editor or by hand in `vocabulary.yaml`:
the dashboard's zone editor or by hand in the floor's `zones.yaml`:

```yaml
zones:
Expand Down
2 changes: 1 addition & 1 deletion docs/fleet/control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ broker that lost its retained state with its volume, repairs itself.
|---|---|---|
| `schema` | int | `1` |
| `site` | string | |
| `floor` | string | one floor is one SLAM session, i.e. one map frame |
| `floor` | string | one floor is one frame; each map revision is one SLAM session registered into it |
| `revision` | string | the immutable revision id; also its directory name at both ends |
| `url` | string | path on the fleet server to fetch the packed revision from |
| `sha256` | string | `sha256:<hex>` of the packed bundle |
Expand Down
80 changes: 39 additions & 41 deletions docs/fleet/fleet-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,29 +275,28 @@ with no map frame to be in is not an answer.

## The zone vocabulary

**Names are shared; coordinates are not.** This is the half of a zone that is
portable between robots, served so that the question a dispatcher most needs to
ask — *what places can I name?* — has an answer in the API rather than out of
band. The shape is [zone/v0](https://spec.augereai.com/zone/v0/).

A zone's pose is a coordinate in one robot's map frame, and that frame's origin
is an accident of where its SLAM session happened to start. `(2.0, 3.5)` on
`mote-01` is a different physical point from `(2.0, 3.5)` on `mote-02`, and
there is no fleet-level transform that fixes it — the two are independent
estimates of the same building, drifting apart. The name, by contrast, is true
for both. So the vocabulary travels and the binding does not, and the split is
in the route: everything under `/v1/maps` is bound to a basemap, everything
under `/v1/zones` is bound to nothing.
**The names, and nothing else**, served so that the question a dispatcher most
needs to ask — *what places can I name?* — has an answer in the API rather than
out of band. The shape is [zone/v0](https://spec.augereai.com/zone/v0/).

Not because a coordinate would be wrong. A zone is a coordinate in the floor's
frame — a fact about the building — and every robot on the floor holds the same
one. It is that a caller of this route has no basemap to draw a coordinate on,
and being handed a number it cannot place is worse than not being handed it. So
the division is in the prefix: everything under `/v1/maps` is served beside a
basemap and gated on there being one, everything under `/v1/zones` is gated on
nothing.

A caller that must never be handed a map can be given `/v1/zones` and only
`/v1/zones`.

**The split is now also in the files.** A floor is two documents —
`vocabulary.yaml` and `binding.yaml` — rather than one `zones.yaml` filtered two
ways, so this route serves a document rather than a projection of one, and the
kind of leak a filter permits (a geometry key added later that nobody remembers
to strip) is not representable. A map revision carries the *binding*, because
coordinates travel with the frame they mean something in; the vocabulary sits at
**The payload is built, never stripped.** A floor's zones are one file, and this
route is a *view* over it assembled from the fields a vocabulary may carry —
never that file with the geometry keys filtered out. The difference is the leak
a filter permits: a geometry key added later that nobody remembers to strip,
arriving as a plausible-looking coordinate rather than as a crash. A map
revision carries a copy of the whole file, because that is how a floor's places
reach a robot that has never driven there; the names sit at
floor level, which is why this route answers for a floor with no published map
at all. A candidate produced by the zone editor carries **both** halves, and
promotion is what lifts its vocabulary to the floor: uploading is not
Expand All @@ -323,7 +322,7 @@ where the stationery lives.
| `name` | What the place is called, which is also what a dispatcher types. Printable text with no leading or trailing space; unique within a **floor**, not within a site — two floors may each have a `reception`. Matched exactly, then case-insensitively and whitespace-normalised. |
| `note` | Free text for where reality diverges from what the name implies. The other names a place answers to belong here: a resolver reads the sentence, and there is no alias list to keep in step by hand. |
| `navigable` | Whether it is a legal destination. Not vocabulary — it is the planner's contract — but it travels with the names because it is not a coordinate. |
| `revision` | Bumped every time a zone's vocabulary is written, so a binding can record which vocabulary it was built against. |
| `revision` | Bumped every time a floor's zones are written, so a reader can tell which of two copies is the later one. |
| `problems` | Empty when the vocabulary is well-formed; see below. |

`kind`, `display_name`, `aliases`, `parent` and `tags` were part of this
Expand All @@ -336,11 +335,11 @@ served.
There are **no coordinates, no `frame_id` and no map reference**, by
construction: the payload is built from the fields a vocabulary may carry
rather than filtered of the ones it may not, so a geometry key added to
`zones.yaml` later cannot leak into it. `test_zone_vocabulary.py` asserts this
by walking the whole payload for geometry-shaped keys rather than checking the
ones it happens to know about.
`zones.yaml` later cannot leak into it. The tests assert this by walking the
whole payload for geometry-shaped keys rather than checking the ones they
happen to know about.

Unlike the binding, this is **not** gated on a published map. A floor someone
Unlike the routes under `/v1/maps`, this is **not** gated on a published map. A floor someone
has named but no robot has mapped still answers here — names are a fact about
the building and do not wait on a SLAM session. `404` only when the floor has
no `zones.yaml` at all.
Expand Down Expand Up @@ -528,17 +527,15 @@ which looks entirely convincing and is the exact failure this route removes.
```

`source` is `revision` when the revision carries its own `zones.yaml` and
`floor` when it inherits the floor's. The difference matters and the coordinates
cannot express it: inherited zones were bound in a *previous* SLAM session's
frame, so they draw perfectly over this map and are wrong by however far the two
origins differ.
`floor` when it inherits the floor's. An operator reviewing a candidate is
entitled to know that what is drawn came from beside it rather than from inside
it, and the coordinates cannot say.

Unlike `read_zones` on the canonical route, this is **not gated on there being a
published map** — the review that matters most is the first candidate on a floor
with nothing published at all. That does not loosen the vocabulary/binding
split: these are still coordinates, still served under a path bound to a
basemap, and still never over `/v1/zones`. Naming a revision is naming a map
frame.
with nothing published at all. It stays under a `/v1/maps`-shaped path and never
over `/v1/zones`, because it is served beside a basemap and that is what the two
prefixes divide.

All three are reads, so like every other read route they take no operator token;
M7 changes that for all of them at once.
Expand Down Expand Up @@ -591,15 +588,16 @@ frame they were drawn in: the operator is looking at that revision's own map.
Omitted, the canonical revision is edited, which is the same thing for a floor
whose published map is what is on screen.

**An entry's `anchor` is carried, not re-invented.** zone/v0's
`anchor.method` says how a coordinate came to be — `taught` for a pose a robot
was driven to, `derived` for one an algorithm read off a map — and a submitted
entry keeps whatever it names, so a zone this edit did not touch keeps its
provenance. The dashboard's editor sends `{"method": "external", "by":
"zone-editor"}` on geometry it placed or moved, because neither of the other
two is true of a click; the server fills in `at` from its own clock and
rewrites `by` to name the operator holding the token, which is the half a
browser cannot be trusted for. A method outside zone/v0's four is a `422`.
**An entry's `source` is carried, not re-invented.** It says what made the
zone — `save-zone` for a pose a robot was driven to, `segment-map` for a room an
algorithm read off a map, `editor` for a click — and a submitted entry keeps
whatever it names, so a zone this edit did not touch keeps what it arrived with.
The dashboard's editor sends `editor` on geometry it placed or moved. Nothing
decides anything from the field: a zone is a coordinate in the floor's frame
however it got there, and what the field buys is an operator being able to see
which zones somebody drew. A value outside the three is dropped rather than
refused, for the same reason — it costs nothing to ignore and a `422` would cost
the whole save.

**The bar is the source's, not the upload's.** A revision with no posegraph is
one mapping cannot be continued from — an error for a robot's upload, where the
Expand Down
58 changes: 29 additions & 29 deletions docs/robot/sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

Everything that is only meaningful relative to one mapped place — the Nav2 map
pair, the `slam_toolbox` posegraph, and named zones — lives together as a
**site bundle**. A zone's pose is a coordinate in a map frame whose origin is
an accident of where SLAM happened to start, so those three artefacts must live
and travel as one unit or they quietly stop describing the same building.
**site bundle**. A zone is a coordinate in the floor's frame — a fact about the
building — and a map revision is one SLAM session registered into that frame, so
those three artefacts live and travel as one unit or they quietly stop
describing the same building.

- A **floor** is one SLAM session, i.e. one map frame.
- A **floor** is one frame — the building's, which its zones are coordinates
in. Each map revision is one SLAM session registered into it.
- A **site** groups floors that share a location.
- A **revision** is one immutable set of map artefacts for a floor.

Expand Down Expand Up @@ -123,39 +125,37 @@ pixi run segment-map --write # merge the proposal into zones.yaml to rename
```

`segment-map` carves a saved map's free space into rooms on one physical
assumption — a doorway is narrow. It is additive over zones already bound (a
candidate covering an already-footprinted zone is dropped), so re-running is a
no-op, and it writes beside `zones.yaml`, never into the immutable map
revision. A proposed room is anchored `derived`, not `taught`: an algorithm read
it off a map, which is what tells an operator later that a re-map invalidates
it. Two consequences worth knowing: a corridor network is not proposed at
all, and the geometry is Manhattan after rotation. See
assumption — a doorway is narrow. It is additive over the zones already there
(a candidate covering an already-footprinted zone is dropped), so re-running is
a no-op, and it writes into the floor's `zones.yaml`, never into the immutable
map revision. A proposed room records `source: segment-map` — what made it, and
nothing about what the coordinate is worth. Two consequences worth knowing: a
corridor network is not proposed at all, and the geometry is Manhattan after
rotation. See
[map cleaning & room segmentation](map-cleanup.md) and the
[validation run](../tuning/2026-07-27-room-segmentation.md).

The shape of the file, circles versus polygons, and how membership is answered
are covered in [Missions](missions.md#zones-and-go-to-the-kitchen).

### Names travel, coordinates do not
### What a zone says besides where it is

**A zone is a place-name**: a human name bound to geometry. Beside its
coordinates it carries a **vocabulary** — the `name` it is called, a free-text
`note` for what the name cannot say ("stationery lives here, not in the
office"), and `navigable`. `save-zone` and the dashboard's zone editor both
write the first two; a zone that says nothing but its name is a place a robot
may drive to.

That split is the whole reason the vocabulary exists separately. `(2.0, 3.5)`
is a different physical point for the robot standing beside this one, and no
fleet-level transform fixes that; the *name* is true for both. So the fleet
publishes the vocabulary and not the binding, over
[`GET /v1/zones`](../fleet/fleet-api.md) — expressed by the route rather than
by a rule someone has to remember: everything under `/v1/maps` is bound to a
basemap, everything under `/v1/zones` is bound to nothing.

Locally, `load_zones` **refuses** a vocabulary in which two zones answer one
query, because loading it would resolve `goto` by dictionary order — silently,
once per boot, and differently after an edit.
coordinates it carries the `name` it is called, a free-text `note` for what the
name cannot say ("stationery lives here, not in the office"), and `navigable`.
`save-zone` and the dashboard's zone editor both write the first two; a zone
that says nothing but its name is a place a robot may drive to.

The names are what [`GET /v1/zones`](../fleet/fleet-api.md) publishes, and it
publishes them and nothing else — not because a coordinate would be wrong (a
zone is a coordinate in the floor's frame, and every robot on the floor holds
the same one) but because a caller of that route has no basemap to draw one on.
That is what the two prefixes say: everything under `/v1/maps` is served beside
a basemap, everything under `/v1/zones` needs nothing.

Locally, `load_zones` **refuses** a floor in which two zones answer one query,
because loading it would resolve `goto` by dictionary order — silently, once per
boot, and differently after an edit.

## Publishing a map to the fleet

Expand Down
Loading