Skip to content

feat(scene): correctable, disappearing objects on upstream ConceptGraphs - #234

Merged
enkerewpo merged 14 commits into
devfrom
scene-object-corrections
Sep 1, 2026
Merged

feat(scene): correctable, disappearing objects on upstream ConceptGraphs#234
enkerewpo merged 14 commits into
devfrom
scene-object-corrections

Conversation

@enkerewpo

Copy link
Copy Markdown
Member

Rebuild of #199 on top of dev, per its review. Upstream ConceptGraphs stays the perception core — every feature here hooks in from outside the MapObjectList; the twelve upstream symbols Scene uses are untouched.

Closes #177. Supersedes #199 (draft), which reached the right contracts and the right absence-detection algorithm but replaced the ConceptGraphs SLAM kernels with an in-repo reimplementation, undisclosed, inside a 13.5k-line commit. This PR is +1,843 −7 over 20 files against #199's +15,792 −2,089 over 69.

What it does

  • Correction contracts (list/relabel/geometry/delete/flush, ported unchanged from feat(scene): make derived object state correctable and recoverable #199's review): every call asserts the map epoch it was read under (ListObjects now echoes map_id + generation); an edit aimed at a stale map is rejected, not applied.
  • Registry-level operator state: sticky verbatim labels with explicit undo, provenance-marked non-navigation-grade geometry overrides, robot self-record untouchable. Operator-corrected objects are immune to soft mark-missing and TTL deletion — a relocated object's stale cloud must not erase the human's edit.
  • Epoch-checked mutation coordinator: single writer for derived-object mutations, snapshot persistence with rollback; deletion drops the bound CG entries under the inference lock so the next tick cannot resurrect the record.
  • Visible-absence eviction ([BUG] Scene object world model retains inaccurate, stale, and off-map ghost objects #177's actual ask): unmatched objects' stored clouds are projected into the current depth image; a miss counts only where the measured surface is materially behind the stored object (closer = occlusion, similar = detector miss, invalid = unknown — none delete state). Removal needs visibility_miss_ticks consecutive clear verdicts, then soft-evict → TTL, reversible by re-observation until the prune.

Review findings from #199, resolved here

Finding Resolution
ConceptGraphs silently replaced by 760-line in-repo kernels Not ported. Upstream imports (slam_classes, mapping, slam.utils, model_utils) unchanged; the aarch64 concern was moot — open3d>=0.17,<0.19 ships aarch64 wheels, which is why that pin exists
Operator corrections deletable via visible-miss → TTL Immune at the registry level (both mark-missing and prune guard on operator attributes)
One label edit wiped the whole caption/relation cache invalidate_object drops only that object's entries; the full clear is reserved for flush
Operator labels silently canonicalized (desk → table) Stored verbatim
Vocabulary config could silently disable perception N/A — the baked-checkpoint/TensorRT machinery this applied to was not ported; model loading is unchanged dev

Deliberately not ported

Web correction UI (+542), TensorRT cache (+422), model storage/baking (+210), lifecycle runtime (+240), perception profiles, and #199's 34-file test suite — the ten tests here cover the decisions (epoch rejection, guards, verbatim labels, narrow invalidation, absence verdicts, streak gating), not the plumbing. Each can be its own reviewable PR if wanted.

Validation

  • system/scene pytest: 146 passed, 30 skipped locally; the 10 failures are identical on dev (missing codegen env on this machine) — full suite + Webots runs on the GPU runner next.
  • Scene benchmark harness (scene-benchmark-harness branch) scores via Scene's HTTP surface, so the five-world numbers can be re-measured on this tree as-is.

List/relabel/geometry/delete/flush for derived Scene objects. Every
correction carries the map epoch it was read under (map_id +
generation echoed by ListObjects), so an edit aimed at a stale map is
rejected instead of applied to whichever map is live now. Runtime-only
by default; persist_to_snapshot makes a correction survive reload.

Contracts ported unchanged from the #199 review; the implementation
follows separately on top of upstream ConceptGraphs.

Assisted-by: Claude Code:claude-opus-5
Six correction entry points on ObjectRegistry: sticky operator labels
with explicit undo (the pre-edit model state is saved for restore),
provenance-marked non-navigation-grade geometry overrides, and derived
object delete/flush that never touch the robot self-record.

Operator-corrected objects are exempt from both soft mark-missing and
TTL hard deletion: a relocated object's stale perception cloud reads
as clear absence at its old footprint, and that evidence must not
erase or hide a human's edit.

Assisted-by: Claude Code:claude-opus-5
ObjectMutationCoordinator keeps registry, perception, graph cache, and
saved snapshot coherent for the correction contracts. Every mutation
revalidates the map epoch under the ops lock and rolls the runtime
back if the snapshot write fails.

The perception layer stays upstream ConceptGraphs; corrections hook in
from outside the map: the projection tick refreshes liveness but never
overwrites an operator-corrected pose, eviction releases a culled
uuid without hiding the human's edit, and deletion drops the bound CG
entries under the inference lock so the next tick cannot resurrect a
deleted record.

Differences from the reviewed #199 draft, per review findings:
- operator labels are stored verbatim instead of canonicalized
  ("desk" no longer silently becomes "table")
- a single edit invalidates only its own caption/relation cache
  entries; the full clear_derived_state is reserved for flush

Assisted-by: Claude Code:claude-opus-5
Ten focused tests: stale-epoch rejection, robot-record guard, verbatim
operator labels, provenance-marked geometry, delete/flush coherence
with the detector, semantic-hold blocking, eviction immunity for
operator corrections, and per-object graph-cache invalidation.

Assisted-by: Claude Code:claude-opus-5
Four thin handlers over the coordinator, declared on Atlas next to the
existing eight tools. ListObjects now echoes the map epoch (map_id +
generation) that every correction call must assert.

Assisted-by: Claude Code:claude-opus-5
The upstream MapObjectList only ever grows, so an object carried out
of the room stayed in the map forever (#177). This adds the missing
negative evidence: each tick, unmatched objects' stored clouds are
projected into the current depth image, and a miss counts only where
the measured surface is materially *behind* the stored object — a
closer surface is occlusion, similar depth is a detector miss, and
invalid depth is unknown; none of those delete state. Only after
visibility_miss_ticks consecutive clear-absence verdicts do the
object's entries leave the map; the projection sweep then soft-evicts
the registry record and the TTL prune finishes the job unless the
object is re-observed first.

The projection/decision function is ported unchanged from the
reviewed #199 draft — it was the part of that PR worth keeping — and
runs against the untouched upstream ConceptGraphs structures.

Assisted-by: Claude Code:claude-opus-5
CI's clippy flags chunks_exact(2) with a constant chunk size and asks for
as_chunks, which also drops the per-chunk indexing.

Assisted-by: Claude Code:claude-fable-5
(cherry picked from commit cec06ee)
@github-actions github-actions Bot added the comp:liaison system/liaison label Aug 22, 2026
Codegen derives the MCP type module from this list, so without the four
new entries the generated semantic_map_mcp lacks the correction types
and the service fails to import at boot.

Assisted-by: Claude Code:claude-opus-5
@enkerewpo
enkerewpo marked this pull request as ready for review August 31, 2026 07:51
@enkerewpo

Copy link
Copy Markdown
Member Author

Reviewed at d51ec610. The design holds up: epoch assertions on every mutation, rollback of runtime state when the snapshot write fails, verbatim operator labels, robot-self and operator-correction immunity carried consistently through soft_evict and prune_expired, and a visible-absence rule that puts the burden of proof on deletion (occlusion, detector miss, and invalid depth all decline to delete). The three new test files cover the paths that matter. All checks are green.

Two notes, neither blocking.

1. A zero-detection tick skips absence evaluation entirely. In _tick_locked, the len(det_list) == 0 branch returns after _maybe_periodic_cleanup(), so neither _update_visible_absence nor _project_to_registry runs. That is precisely the case the feature targets: the robot faces a spot where a mapped object used to be, the frame yields no detections at all, and the map keeps the object. With a 55-term closed vocabulary, empty ticks are not rare. Moving the absence update (and the registry projection) above that early return would close the gap; the streak counter already protects against a single noisy frame.

2. system/liaison/src/voice.rs does not belong in this PR. The change rewrites chunks_exact(2) as as_chunks::<2>() in pcm_rms_s16le. It is behaviourally identical, unrelated to Scene, and raises the effective minimum Rust version for a repository that pins no toolchain. Suggest dropping it here and landing it separately if it is wanted at all.

Neither needs to hold the merge. Filing (1) as a follow-up is fine.

@enkerewpo
enkerewpo force-pushed the scene-object-corrections branch from fc4e1f1 to ea067de Compare August 31, 2026 08:17
@enkerewpo

Copy link
Copy Markdown
Member Author

Field-design audit of the contract surface, at 54f465ff.

The five .toml contracts match the shape of list_regions and get_scene_graph exactly: id / version / kind = "service" / idl / description, plus [mode] type = "rpc". Nothing to change there.

On the IDL, three things needed correcting and are now fixed:

observation_count carried two types. It was added to Object as uint64, but SceneGraphNode has published the same quantity under the same name as int32 since before this branch. One name and one meaning must not carry two types across a single IDL library, and SceneGraphNode is already on the wire, so Object follows it. Unsigned is arguably the better model for a count, but changing the published field is a breaking change and does not belong in this PR.

note truncated silently. The coordinator strips whitespace and caps the stored text at 512 characters; the contract said nothing, so a longer note was quietly cut. All four services that accept a note now state the behaviour.

Object.frame_id's comment was wrong for one producer. It said an empty value means the entry has no metric frame. GetObjectContext.nearby_objects is built from scene-graph nodes, whose coordinates are map-frame but which carry no frame string. The comment now says what empty actually means and what follows from it: UpdateObjectGeometry rejects an edit built from such an entry rather than applying it blind.

One inconsistency is noted but deliberately left alone: counts across this library are mixed — SceneGraphNode.observation_count and now Object.observation_count are int32, while FlushObjects.deleted_count is uint64. Unifying them is a separate change against the published surface, not something to fold in here.

Scene suite: 202 passed. The one failure, test_runtime_protobuf_contract, needs Docker and fails identically on the unmodified base.

… side

UpdateObjectGeometry requires size_x/size_y/size_z and frame_id, and rejects
a frame that differs from the object's own. ListObjects published neither, and
no message under capabilities/lib/semantic_map carried extents or a frame at
all, so an operator had no contract-level way to obtain what the write call
demands. The response type had the same hole: it claimed to return the object
"with the geometry applied" while being unable to represent that geometry.

Object gains size_x/size_y/size_z, frame_id, and observation_count. The
extents and frame close the round trip. observation_count gives a caller that
read an object and then edits it a way to tell whether perception moved
underneath it: the map epoch orders edits against map reloads, not against
ordinary perception updates, so it cannot answer that on its own.
SceneGraphNode already published observation_count, so this is not new state
on the wire.

ListObjects gains generation_supported. A provider without a generation
counter reports -1, which silently degrades the epoch check to comparing
map_id alone and stops ordering two edits racing inside one map; callers that
care can now read that instead of inferring it from the sentinel.

The four mutating services gain an optional note. Operator corrections outrank
perception and are exempt from TTL deletion, so they are permanent and were
unattributable. Label and geometry corrections store it with the object's
provenance and drop it when the override is cleared; delete and flush have no
surviving record and log it instead.

UpdateObjectLabel now bounds `label` in both modes. It is ignored when
clear_override is set, but accepting an unbounded string there invites a
caller to believe it was used.
…tions

`observation_count` was uint64 on Object while SceneGraphNode has published the
same quantity under the same name as int32. One name and one meaning must not
carry two types across one IDL library, so Object follows the type that is
already on the wire.

`note` is stripped and capped at 512 characters by the coordinator. The IDL did
not say so, which made a longer note truncate silently. The contract now states
it, in all four services that accept one.

Object.frame_id said an empty value means the entry has no metric frame. That is
wrong for scene-graph derived entries in GetObjectContext.nearby_objects, whose
coordinates are in the map frame but whose frame string is not recorded. The
comment now says what empty actually means and what follows from it: a geometry
edit built from such an entry is rejected rather than applied blind.
The branch was cut on 2026-08-19 and had fallen 174 commits behind. Webots CI
on the old base would have run the pre-2026-08-22 workflow, which predates the
SLAM-map summary embed, the cmd_vel-driven mapping loop and the Filatov map
quality metrics — so a green run there would not have said anything about what
lands on dev.

Merged clean, no conflicts. Scene suite on the merged tree: 242 passed. The two
failures it does show, test_goal_room_rejects_stale_geometry_before_navigation
and test_runtime_protobuf_contract, both fail identically on unmodified dev.
…'s own checks

start.sh expands four optional argument arrays as "${ARR[@]}". Under set -u,
bash 3.2 — still the default on macOS — treats that as an unbound variable when
the array is empty and aborts. The script died right before `docker run`, so the
service container never launched and the runtime-protobuf contract test only
ever saw the codegen container. Expanding as ${ARR[@]+"${ARR[@]}"} keeps bash
4.4 behaviour and works on 3.2.

goal_room checked the Soma footprint twice: once at the top and once where the
footprint is actually used. The early copy made robot readiness preempt every
check on the request itself, so an unknown or stale room came back as "robot
geometry is not ready" and the caller could not tell which of the two was
wrong. The check at the point of use is kept; the duplicate is removed.

Scene suite: 244 passed, 0 failed. Both of these failed on unmodified dev.
…ummary

The map has been copied next to index.html and linked from summary.md since the
feature landed, but the HTML report never referenced it: the published page had
zero <img> tags, so the only way to see the map was to guess the slam-map.png
URL. The report page now carries a SLAM Map section whenever the file is there.

Two things had to change for that to work. write_html gained the flag, and the
copy that sets it moved above the call — it ran after, so the flag was always
false at render time and the section could never have appeared.

The image is scaled with nearest-neighbour. An occupancy grid is a couple of
hundred pixels wide at its own resolution, and smoothing it on the way up
invents wall edges that are not in the map.
The previous commit changed ${#GPU_ARGS[@]} to ${#GPU_ARGS[@]:-0} while
guarding the array expansions. An array length is never unset, so the default
was pointless, and `${#arr[@]:-0}` is not valid syntax: bash rejects it as a
bad substitution. start.sh exited 1 and Scene never reached Atlas registration,
which took the whole Webots suite down with it.

The expansion guards themselves were fine and stay. Only the length test is
reverted. Checked on both bash 3.2 and the runner's bash 5.2 this time, with
empty and populated arrays and an argument containing an `=`, rather than
trusting `bash -n` — a bad substitution is a runtime error and parses clean.
@enkerewpo
enkerewpo merged commit 750cc58 into dev Sep 1, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp:capabilities capabilities/ contracts comp:liaison system/liaison comp:scene system/scene type:feature New feature (feat:)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Scene object world model retains inaccurate, stale, and off-map ghost objects

1 participant