Skip to content

Adopt spec v0: capability set, typed failures, zone vocabulary/binding split - #104

Open
MJohnson459 wants to merge 5 commits into
mainfrom
spec-v0-adoption
Open

Adopt spec v0: capability set, typed failures, zone vocabulary/binding split#104
MJohnson459 wants to merge 5 commits into
mainfrom
spec-v0-adoption

Conversation

@MJohnson459

Copy link
Copy Markdown
Contributor

Adopts the three Augere v0 specifications across Mote's control plane, in three
independently reviewable commits.

1 — the contracts as shared modules (mote_bringup/spec/). mission.py,
capability.py and zone.py implement the specs' payloads, ROS-free and
stdlib-only, in mote_bringup for the reason bundle.py is there: the task
layer executes missions, the agent bridges them and the off-board server
dispatches them, and none of the three may depend on the others. Three rules
are enforced here rather than left to callers because each fails silently in a
caller — terminal is computed from state rather than supplied, failure is
refused anywhere but rejected/failed, and recoverable must be stated
for the spec's three "depends" classes, where a class-level default would be a
guess wearing a contract's clothes. The input validator is a bounded subset of
JSON Schema whose acceptable property is that it raises on a keyword it does
not implement
: an ignored maxItems is a promise nothing keeps, quietly,
forever.

2 — a capability set and typed failures on the wire (mote/v2). A robot is
told a capability key and a typed input, not fetch red_box dropoff; a refusal
carries a class and a recoverability, not a sentence. That is a change of
meaning in an existing payload, so by protocol.py's own rule the topic root
moves. The capability set is published by the task server and forwarded by the
agent — never authored — so a robot whose task server is down advertises
nothing, which is true; the dashboard's dispatch form is generated from it, with
a zone picker exactly where a property's schema $refs zone/v0's zone
reference, so the page holds no list of capabilities and no list of which inputs
are places. The lane moved to the executor, which is the thing that actually
holds it; dispatch.py kept dedup, an hour of retention, the no-verdict
timeout and source, and lost its prose parser. Preconditions are now
evaluated rather than documented: a goto sent to an unlocalised robot is
refused with a reason instead of flailing in Nav2.

3 — the zone vocabulary/binding split. A floor is two documents:
vocabulary.yaml (what the places are called — no coordinates, safe to
broadcast) and binding.yaml (this robot's poses, stamped with platform_id,
frame_id and map_revision). The vocabulary is built from the fields it may
carry, never stripped of the ones it may not, because stripping holds only until
someone adds a geometry key and forgets. What it buys is a distinction the robot
could not draw: a name in the vocabulary this robot has never been taught now
resolves unbound — "I know that place, nobody has driven me there" — where
before it was unknown_name, which sent an operator hunting for a typo that was
not there. Migration is a side effect of writing, never a step. A revision
carries the binding; a candidate from the zone editor carries both halves and
promotion lifts its vocabulary to the floor, so "uploading is not publishing"
now covers names as well as coordinates.

Verification

710 passed, 10 skipped across mote_bringup, mote_tasks and mote_fleet in
the dev environment — the tier with a real mosquitto, so the M1 acceptance
(test_e2e_fleet.py) and the map registry's publish/promote/pull ran against
the new wire and the new layout end to end. test_e2e_fleet.py now also covers
the capability set reaching the broker and unresolved_zone, invalid_input
and busy coming back classified with the mission holding the lane named.

test_spec_conformance.py validates payloads built by these modules against
the specification's own JSON Schemas — all 8 mission states, all 11 failure
classes, command, cancel, the capability set, and all four zone documents. 10
passed against augereai-spec 3895f02 with jsonschema 4.26. Mote vendors no
copy of those schemas; the test skips where the checkout or the library is
absent.

One conformance finding is recorded rather than papered over: zone/v0 requires
a pose on every binding, and Mote's polygon-only zones (what segment-map
emits) had none. Fixed on Mote's side — the spec is right, since a binding is
where a mission navigates to and a footprint alone cannot say. The pose is now
derived once on write rather than by every reader.

Three defects the new tests found, all fixed here:

  • the agent's tracker looked a mission id up before expiring the retention
    window, so a mission the retention rule says was forgotten still answered as
    a duplicate;
  • the wire fixture rejected an unknown capability before publishing
    dispatched, which the spec makes the only state rejected is reachable
    from;
  • and, uncovered rather than introduced: a module-level pytest.importorskip
    raises Skipped, which derives from BaseException, and the launch_testing
    plugin this workspace loads imports test modules outside pytest's skip
    handling — so one such skip aborted collection of the entire
    mote_bringup/test directory and reported "no tests collected" with exit
    code 0. 412 tests were silently not running. Filed as a follow-up (#504)
    because nothing stops the next one.

Not done, deliberately

Broadcasting the vocabulary over the retained registry subtree — what makes a
second robot at a site cheap — is filed as #503. The split is its prerequisite
and is what this task was asked for; the broadcast is M6's own work and is
independently landable, which is how the three changes here were scoped.

Also left: wrong_floor and stale_revision remain unanswerable and are stated
as such — the robot holds one floor at a time, and the map bundle declares no
frame continuity, which zone/v0 puts out of its own scope too. running,
blocked and cancelled are optional in mission/v0 and Mote emits none of
them, which is conforming; mission/cancel is a reserved topic with no
publisher, because the task layer has no cancel and cancellable: true would
promise a message nothing handles.

A v1 robot and a v2 server do not interoperate. Nothing translates between
them, deliberately: a shim would be a third definition of the wire. Upgrade the
agent and the server together; identity and maps are unaffected, since neither
is on this tree.

Adds `mote_bringup/spec/`, Mote's implementation of the Augere v0
specifications' payload layer: `capability.py` (capability/v0) and
`mission.py` (mission/v0). Nothing is wired to a wire yet — this is the
contract the next commit moves the fleet onto.

It lives in `mote_bringup` for the reason `bundle.py` does: three ends need
it and none may depend on the others. The task layer executes missions, the
fleet agent bridges them, and the off-board server dispatches them with
neither ROS nor a checkout. Stdlib-only as well as ROS-free, because the
server's container installs no framework.

Three of the spec's rules are enforced here rather than left to callers,
because each fails silently in a caller. `terminal` is computed from `state`
rather than supplied, so a producer cannot get the two out of step. `failure`
is refused anywhere but `rejected`/`failed` and required on both. And
`recoverable` must be *stated* for `precondition`, `unresolved_zone` and
`timeout` — the spec's "depends" rows, where the answer is a fact about the
instance and a class-level default would be a guess wearing a contract's
clothes.

The input validator is a bounded subset of JSON Schema, and the property
that makes that acceptable is that it raises on a keyword it does not
implement rather than ignoring it: an ignored `maxItems` is a promise
nothing keeps, forever, quietly. `check_schema` runs at declaration time, so
that failure lands on the platform that wrote the capability.

Verified: 27 unit tests, plus `test_spec_conformance.py`, which validates
payloads built by these modules against the spec repository's own schemas
(all 8 states, all 11 failure classes, command, cancel, capability set) —
6 passed against augereai-spec 3895f02 with jsonschema 4.26. Mote vendors no
copy of those schemas; the test skips where the checkout or the library is
absent.
Adopts mission/v0 and capability/v0 across the control plane. What a robot is
told to do is now a capability key plus a typed input; what comes back carries
a failure class and a recoverability instead of a sentence. That is a change of
meaning in an existing payload, so by protocol.py's own rule the topic root
moves: `mote/v2/<robot_id>/{presence,health,pose,capabilities,mission/*}`.
Telemetry moved with it unchanged — a tree has one version, not one per leaf.
A v1 robot and a v2 server do not interoperate, deliberately: a translating
shim would be a third definition of the wire.

**The capability set replaces the grammar.** `mote_tasks/capabilities.py`
declares `goto` and `fetch` as standard-registry keys with the registry's own
property names (`fetch` delivers to `destination`). The task server publishes it
latched; the agent forwards it retained, never authors it, so a robot whose task
server is down advertises nothing — which is true. A location input `$ref`s
zone/v0's zone reference, and the dashboard's dispatch form is *generated* from
that: a select of the keys, one field per property, a zone picker exactly where
the `$ref` says the property is a place. The page now holds no list of
capabilities and no list of which inputs are zones.

**Failures are typed.** `rejected: busy with '…'` became class `busy`,
`recoverable: true`, with the in-flight mission's id in the detail. Which class
a tree failure gets is decided by the behaviour that failed, because only it
knows: Nav2 refusing a goal is `unreachable`, Nav2 aborting after its own
recoveries is `obstructed` and retryable, no action server at all is `internal`.

**The lane moved to the executor**, which is the thing that actually holds it
and which also sees missions issued locally on the robot. It was the agent's
only because `task/command` had no correlation id. dispatch.py keeps what only
it can answer — dedup, an hour of retention so a restarted dispatcher learns
outcomes, the no-verdict timeout, and `source` — and its prose parser is gone.

**Preconditions are evaluated, not documented.** `localized` wants a
map->base_link transform newer than 5 s; `zone_known` carries zone/v0's own
reason. A `goto` sent to an unlocalised robot is refused with a reason instead
of flailing in Nav2. `max_duration_s` is enforced too. An unmet *non-blocking*
precondition lands in `warnings` on the accepted status rather than refusing.

The ROS seam stays `std_msgs/String`; the string is JSON, so the agent still
forwards bytes and cannot reinterpret them. That cost the bench flow, so
`ros2 run mote_tasks mission goto target=kitchen` replaces typing a sentence
into `ros2 topic pub`; `--list` prints what the robot will accept.

Also removes `mote_fleet/schema/{command,status}.schema.json`: those payloads
are the specification's now, and a stale local mirror would be read as
authoritative by whoever found it next.

Verified: 296 passed / 7 skipped in the robot environment, and 255 passed in the
dev environment where mosquitto exists — which is what runs the end-to-end
acceptance. `test_e2e_fleet.py` now covers the capability set reaching the
broker, a typed dispatch through the real behaviour tree, and `unresolved_zone`,
`invalid_input` and `busy` coming back classified with the mission holding the
lane named in the detail. Two defects the new tests found and fixed: the
tracker looked an id up before expiring the retention window, so a forgotten
mission still answered as a duplicate; and the fake robot rejected an unknown
capability before publishing `dispatched`, which the spec makes the only state
`rejected` is reachable from.
`zones.yaml` held names and coordinates in one file. zone/v0 pulls them apart,
and the split is what the whole spec is for: names are shared, coordinates are
not, maps are never shared. A floor is now two documents —
`vocabulary.yaml` (site, floor, and what the places are *called*) and
`binding.yaml` (this robot's poses and footprints, stamped with `platform_id`,
`frame_id` and `map_revision`). Both are built by the new
`mote_bringup/spec/zone.py`, which also takes over the vocabulary rules and the
containment geometry from `bundle.py` and `mote_tasks.zones`, so the robot and
the fleet server give the same answer on a boundary case.

**The split is structural, not a rule to remember.** The vocabulary is built
from the fields a vocabulary may carry, never stripped of the ones it may not:
stripping holds only until someone adds a geometry key and forgets, and the
leak would be a plausible-looking coordinate rather than a crash. Three tests
assert it by walking whole documents for geometry-shaped keys.

**What it buys is a distinction the robot could not draw.** A name in the
floor's vocabulary that this robot has never been taught now resolves
`unbound` — "I know that place, nobody has driven me there" — where before it
was `unknown_name`, which sent an operator hunting for a typo that was not
there. The refusal says to drive there and run `save-zone`. `task_server` loads
with `load_floor` (vocabulary ∪ binding) rather than `load_zones` (bound only)
for exactly that, and logs the untaught names at startup. A binding whose name
the vocabulary does not carry is a local extension: usable here, never
advertised, because one robot must not invent shared vocabulary for its
neighbours.

**Migration is a side effect of writing, never a step.** `bundle.read_floor`
takes a floor directory or a legacy combined file, and reads the latter through
`zone.split` so both paths produce the same structure by construction. The first
`save-zone` or `segment-map --write` on an old floor writes the pair and keeps
the original as `zones.yaml.premigration`. The sim worlds' committed
`<world>.zones.yaml` files stay combined on purpose — one file is the right
shape for a fixture with one robot in it.

**Which half travels where.** A revision carries the binding, because a
coordinate means nothing without the frame beside it; installing a pulled
revision replaces the floor's binding and leaves the vocabulary alone, so
re-mapping no longer costs an operator the aliases they typed. A candidate from
the zone editor carries both halves and promotion lifts its vocabulary to the
floor: uploading is not publishing, applied to names as well as coordinates.

Two things the specification decided rather than taste. A polygon-only zone's
binding pose is now **derived once, on write** rather than by every reader —
zone/v0 requires a binding to carry a pose, and it is right to, since a
footprint alone cannot say where a mission navigates to. And a floor is created
with **no zone documents at all** rather than empty ones: seeding empties made
a hand-written `zones.yaml` dropped in beside them ambiguous, and the empty
pair won silently.

Still unanswerable here and stated as such: `wrong_floor` (the robot holds one
floor at a time) and `stale_revision` (the bundle declares no frame continuity,
which zone/v0 puts out of its own scope too). Broadcasting the vocabulary over
the retained registry subtree is M6's, and is what this split was the
prerequisite for.

Verified: 708 passed / 10 skipped across mote_bringup, mote_tasks and mote_fleet
in the dev environment, which is the tier with a real mosquitto — so the map
registry's publish/promote/pull end-to-end ran against the new layout.
`test_spec_conformance.py` validates all four zone/v0 documents against the
specification's own schemas (10 passed with jsonschema 4.26 against
augereai-spec 3895f02). One conformance finding is recorded rather than papered
over: the spec requires a pose on every binding and Mote's polygon-only zones
had none — fixed on Mote's side, since the spec is right.

Also fixes a collection bug this work uncovered: a module-level
`pytest.importorskip` raises `Skipped`, which derives from `BaseException`, and
the launch_testing plugin this workspace loads imports test modules itself
outside pytest's skip handling — so one such skip aborted collection of the
whole `mote_bringup/test` directory, reporting "no tests collected" rather than
an error. 412 tests were silently not running.
The resolution that put it above `statusText` left the original copy inline at
the call site. Two identical paragraphs about one rule is one to keep in step
with the code and one to forget.
**`fleet-image`** could not find `mote_bringup/mote_bringup/spec/`. The
`.dockerignore` denies everything and allows the files each image copies, and
its own comment says so — "adding a COPY means adding a line here". I added the
COPY and not the line. Both lines, in fact: `*` excludes the directory itself,
so docker never descends into it and a bare `!dir/` allows nothing.

That rule lived only in a comment, which is why it was possible to forget, so
`test_deploy_config.py` now enforces it: every COPY source in both Dockerfiles
must be covered by an allowlist entry, and any allowed directory must carry a
`/**` beside it. It checks *this file's own convention* rather than emulating
docker's matcher — a second implementation of `.dockerignore` semantics would
be a worse thing to own than the rule it was checking. Reverting either fix
reproduces its failure.

**`build`** collected `test_mapsync.py`, which imported `mote_tasks.zones` for
the new split-floor test — and that imports `geometry_msgs`. `mote_fleet/test`
runs in the `fleet` environment, which has no ROS *by design*: it is the
deployable control-plane role, and keeping it ROS-free is the property the
package split exists to hold. The test now writes its floor with
`bundle.write_floor`, the ROS-free writer the fleet server itself uses, which
is the correct side of that seam anyway.

**And the e2e test was racy**, which running it again is what surfaced. The
`busy` assertion waited for *any* status for the second mission and then read
the last one — but the agent publishes `dispatched` as it forwards and the
executor's refusal follows milliseconds later, so it caught `dispatched` on a
loaded machine and `rejected` on an idle one. It waits for a terminal status
now, as every other assertion in the file already did; that one was the sole
exception. The robot's behaviour was never in question — the failing run's log
carries the correct refusal, naming the mission holding the lane.

Verified: the fleet image builds locally for the first time on this branch, and
the container answers `/healthz` with `{"contract": "mote/v2"}` — the spec
modules import inside it, which a file being present does not prove. 226
passed / 4 skipped under the ROS-free `fleet` environment, the job that failed.
The e2e test five times consecutively, then 712 passed / 10 skipped in the dev
environment.
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