Skip to content

Field import: camp (2026-08-27) - #223

Closed
rolker wants to merge 3 commits into
jazzyfrom
feature/issue-222
Closed

Field import: camp (2026-08-27)#223
rolker wants to merge 3 commits into
jazzyfrom
feature/issue-222

Conversation

@rolker

@rolker rolker commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Imports three field commits made on pandy during the BizzyBoat Isles of Shoals deployment.

Closes #222.

Three field commits made on pandy (operator station) during the BizzyBoat
Isles of Shoals deployment and pushed to the gitcloud field remote. camp is
not divergedgitcloud/jazzy is a strict superset of origin/jazzy, so
these arrive with their original SHAs: no cherry-pick, no rewrite.

Commits

SHA What
532b6df fix: subscribe to coverage_catalog VOLATILE so it matches udp_bridge
af5e378 fix: propagate catalog prune-on-absence into the overview pyramid
0fc6408 fix: draw coarse coverage only as a clipped placeholder for a missing tile

2085 insertions / 102 deletions across 10 files.

Pre-review against the Quality Standard

This batch is unusually complete for field work. Every one of the three
commits ships with a new test and registers it in CMakeLists.txt, and two of
them update docs/decisions/0010-bounded-eviction-overview-pyramid.md alongside
the code:

Commit Test added Registered ADR updated
532b6df test/test_sonar_live_catalog_qos.cpp (44) yes
af5e378 test/test_sonar_live_overview_prune.cpp (487) yes +82 lines
0fc6408 test/test_sonar_live_placeholder_draw.cpp (471), test/test_raster_gl_renderer.cpp (68) yes +99 lines

The QoS fix is measured rather than reasoned: the commit records the live
publisher/subscriber QoS pair observed on pandy, and confirms it both ways with
ros2 topic echo --once --qos-durability.

Finding 1 — the VOLATILE downgrade is framed as permanent, but the defect it works around is tracked for repair

532b6df switches the catalog subscription to VOLATILE and states the tradeoff
as accepted: no latched sample, so camp waits for the next publication
instead of getting the current catalog on join.

That reasoning is sound today — a never-matching subscription delivers
nothing, so waiting strictly dominates. But the root cause is a relay defect
that is already tracked
:
udp_bridge#78 — "Relay downgrades publisher durability: TRANSIENT_LOCAL
republished VOLATILE, silently unmatching every correct consumer"
.

The same mismatch was hit by the web-view coverage renderer on the same
deployment, and that commit
(unh_marine_autonomy 1ffc459) marks itself
FIELD WORKAROUND ... Revert to TRANSIENT_LOCAL once the relay is fixed.
camp's carries no such marker.

Consequence: when udp_bridge#78 lands and the relay preserves producer
durability, a TRANSIENT_LOCAL publisher still matches camp's VOLATILE
subscriber — so nothing breaks, and nobody is prompted. camp simply keeps the
late-join loss permanently, for a bug that no longer exists. Suggest recording
the revert condition in the code or the ADR so the two consumers stay in step.

Finding 2 — ADR edits arrive without separate review

af5e378 and 0fc6408 amend docs/decisions/0010-*.md by 181 lines combined.
That is the right instinct (a change carrying its consequences), but ADR text is
governance and normally gets its own read. Flagging for human attention in this
PR rather than treating it as ordinary code.

Clean

  • No hardcoded paths or credentials.
  • Topic name verified against the deployment: /bizzy/sensors/m3/cube_bathymetry/coverage_catalog.
  • Error/absence handling is the actual subject of two of the three commits
    (prune-on-absence, missing-tile placeholder).

Merge state

Not diverged — origin/jazzy fast-forwards to gitcloud/jazzy. No merge needed.


Authored-By: Claude Code Agent
Model: Claude Opus 5 (1M context)

Test plan

  • Each commit ships a test registered in CMakeLists.txt (verified per-commit at import).
  • Owed: build and run the three new test binaries on a dev host — they were written and run in the field, not re-verified here.
  • Owed: human read of the 181 lines of ADR-0010 changes (Finding 2).

🤖 Generated with Claude Code

https://claude.ai/code/session_014eGztbXhDcsyFFx8Lp1BAr

Claude Code Agent added 3 commits August 27, 2026 07:44
The live-coverage catalog subscription was created transient-local to match
the boat-side producer. But camp runs on the operator side and never sees
that publisher: it receives the catalog as republished by udp_bridge, which
emits VOLATILE. A transient-local subscriber is QoS-incompatible with a
volatile publisher, so the subscription never matched, handleCatalog() never
fired, and the ADR-0006 D4 anti-entropy reconcile never pruned in-memory
tiles, their GPU textures, or the on-disk copies. Stale coverage persisted
on screen indefinitely after a boat-side store reset.

Measured live on pandy during the BizzyBoat deployment, on
/bizzy/sensors/m3/cube_bathymetry/coverage_catalog:
  publisher  /operator/udp_bridge  RELIABLE/VOLATILE
  subscriber /operator/camp        RELIABLE/TRANSIENT_LOCAL  (never matches)
  subscriber coverage_renderer     RELIABLE/VOLATILE         (matched fine)
Confirmed both directions with `ros2 topic echo --once --qos-durability`:
volatile returns a catalog immediately, transient_local returns nothing.

Switch the durability to VOLATILE, keeping RELIABLE and depth 1. The
tradeoff is explicit and accepted: there is no latched sample, so camp no
longer gets the current catalog on join and must wait for the next
publication. A never-matching subscription delivers nothing at all, so
waiting strictly dominates.

The camp#169 buffering in handleCatalog() is unchanged and stays correct —
it is now more load-bearing, not less. Its premise was the once-only
delivery of a latched sample; under VOLATILE there is no latched sample
either, and the catalog is published only on change while the boat's
catalog is stable, so a sample dropped while disabled may not recur for the
rest of the session. disableLiveCoverage() tears down only the tile stream
(unsubscribeTiles); catalog_sub_ is reset only in the destructor, so
samples keep arriving and landing in last_catalog_ throughout a disabled
window for enableLiveCoverage() to replay. Comments on the buffer, the
member, and the class doc corrected to state the real reason.

The QoS moves into catalogSubscriptionQos() so a regression test can pin
it. Nothing pinned the durability before, which is exactly why the mismatch
was invisible; test_sonar_live_catalog_qos now asserts VOLATILE/RELIABLE/
depth 1.

Verified: camp builds; 312 tests, 0 failures (1 pre-existing GL skip in
test_gggs_render). A camp restart is required for this to take effect.
The ADR-0006 D4 anti-entropy reconcile pruned only the fine tiles. The overview
pyramid built by foldIntoParent() was entirely exempt: handleCatalog() erased
from `tiles_` and deleted `<cache_dir>/<stem>.tif`, and never touched
`overview_tiles_` or `overviews/`. The comment on foldIntoParent() asserted that
exemption as intended design — "a local derived product; prune-on-absence must
not touch them" — which conflated ADR-0010 D4 (overviews are never entered into
the reconciler, true and unchanged) with an exemption from the prune itself.

So a retracted region kept its coarse coverage forever, and warm-load reinstated
it on every restart. Measured on pandy during the BizzyBoat deployment after a
boat-side store reset: with the 532b6df QoS fix in place the fine cache pruned
correctly (107M -> 44M, every survivor newer than the restart) while 41M of
`overviews/` survived and the operator still saw the pre-reset coverage. No code
path could ever reflect a boat-side reset in the pyramid.

Compounding it, the fold is purely accumulative — foldChild() only ever folds
data IN and has no inverse — so a withdrawn contribution cannot be subtracted
from a parent in place.

reconcilePyramid() runs at the end of every reconcile, after the fine prune. The
catalog is authoritative over the fine tiles and the pyramid is a function of
them, so an overview should exist exactly when it is an ancestor of a live fine
index. Two failure modes, two repairs:

  * stale by absence — no live descendant: remove it from `overview_tiles_`,
    free its GL texture under the renderer's context (the camp#134 discipline
    the fine prune already follows), and delete its `overviews/` file. The
    DIRECTORY is swept, not just the resident map: eviction phase 2 frees an
    overview Entry while deliberately keeping its disk copy (ADR-0010 D2), so a
    stale overview can be absent from memory and still be warm-loaded back.
    That is exactly how the pandy pyramid kept returning.
  * dirty by partial withdrawal — some descendants live, one was withdrawn:
    rebuild the tile from its surviving children. This reproduces the original
    construction (D3 folds the WHOLE parent into the grandparent, so every level
    above the fine tiles is "the fold of my children"), drawing inputs from a
    child repaired earlier in the same pass, else the resident copy, else the
    disk copy. A tile with no surviving child is removed instead — a zoomed-out
    gap is honest, stale coverage is not.

Two properties are load-bearing. Proportionality: every fine tile's ancestor
chain reaches level 0, so invalidating the chain of each pruned tile would
destroy the whole pyramid on any ordinary retraction, and tiles are withdrawn
and re-added in normal operation. Nothing is removed while a live descendant
remains, and only ancestors of something really withdrawn are rebuilt. Prune-gate
parity: a generation_time of 0 disables prune-on-absence in the reconciler
(ADR-0008 D4b), so it disables the pyramid sweep too, and the live set is unioned
with the ancestors of the fine tiles still held locally so a held tile too NEW to
prune keeps its ancestors alive.

Budget accounting is preserved: removals shrink overviewResidentBytes(), a
rebuild that pulls a non-resident overview back into memory is followed by
evictIfOverBudget(), and overviews still never enter the reconciler.

Two supporting changes. cancelPendingWrite() drops a deleted tile's queued
write-through (a coalesced relaunch would otherwise re-create the .tif the prune
just removed); an in-flight write is left to finish with its WriteState intact,
because erasing that state would let a later fold launch a second worker racing
it on the shared <stem>.tif.tmp path. And the `<level>_<row>_<col>` cache stem,
open-coded at three sites, moves into tileStem()/fineTilePath()/overviewTilePath()
with indexFromStem() as the inverse the disk sweep needs — a divergence between
the writer's name and a deleter's would orphan files silently rather than fail.

test_sonar_live_overview_prune pins all of it (GL-free, ROS-free, same offscreen
harness as test_sonar_live_eviction): stale overviews leave memory and disk;
ancestors with a surviving catalogued descendant are kept (the over-deletion
guard); a shared ancestor is rebuilt without the withdrawn descendant's
contribution, in memory and on disk; a catalog that retracts nothing changes
nothing; an unstamped catalog sweeps nothing. Verified the first two fail against
a stubbed reconcilePyramid, and that the over-deletion guard fails against the
rejected naive chain-invalidation design.

ADR-0010 gains D7 and retires the "overview lifecycle-on-retraction /
catalog-prune propagation" deferred follow-up it had carried since 2026-08-20.
Per-overview provenance was rejected as an alternative: it cannot survive
warm-load, which is precisely the path that resurrects a stale pyramid.

Verified: camp builds clean; 317 tests, 0 failures (baseline 312, +4 new gtest
cases +1 ctest aggregate entry), 1 pre-existing GL skip in test_gggs_render. A
camp restart is required for this to take effect.
… tile

The live-coverage draw list appended EVERY resident overview tile, coarse->fine,
then the fine tiles on top. Its stated premise (camp#160) was "where a fine tile
is present it fully covers its parent" — false: a child covers a QUARTER of its
parent. Wherever fine coverage is sparse, which is most of a survey in progress,
every coarse ancestor painted through, and the apex is resident by design
(kApexProtectLevel, ADR-0010 D1) so the coarsest level of all drew at every zoom.

Measured on pandy during the BizzyBoat deployment: every cached tile is 960x960
cells at every level, so ground cell size doubles per level up and a level-0 cell
is ~667 x 926 m carrying the MEAN of everything folded beneath it (4 valid cells
at level 0; 1264 at level 11). The operator saw a solid ~650 m block over a good
portion of the survey area, covering the chart — including water the survey has
never touched, since an overview's own extent is 4^n fine tiles wide.

Coarse data may now be drawn ONLY as a placeholder for a fine tile that is known
to exist and has not loaded, and only over that tile's own footprint.
planDraw() resolves exactly two contributions: for each pending index, the finest
resident ancestor overview painted over the FINE index's extent through the
sub-rect of that ancestor it occupies; and every resident fine tile, whole, on
top. Nothing else. "Known to exist" is derived, not guessed: the catalog is
authoritative for what exists (catalogued_fine_, assigned from every catalog
since a complete catalog is a snapshot), unioned with evicted_fine_indices_ for
the warm start with no link, minus what is resident.

Scale never enters it. A placeholder exists exactly while a real tile is missing,
so zoom-out needs no threshold: zoomed out, nearly every catalogued tile is
non-resident and the union of the footprints IS the coverage. The alternative
considered first — cell size vs. screen pixel — was rejected: it would have
stopped level 0 at survey zoom, but at any zoom where a coarse level did qualify
it would still smear that level across its full extent, unsurveyed water included.

Clipping needed a renderer change. RasterFieldItem carries one texture and a
geographic extent, and the renderer stretches the whole texture across whatever
bounds it is given, so narrowing the bounds would SQUASH the overview into the
small box rather than clip it. The item gains a normalized texture window
[u0,v0]-[u1,v1] (u west->east, v north->south, texture row 0 = north) honoured by
RasterGlRenderer's texcoord generation. It defaults to the whole texture, so
RasterLayer, GggsTileLayer and this layer's own resident tiles emit exactly the
coordinates they did before — verified by reading all three call sites (each
default-constructs the item and assigns members, none aggregate-initialises).
The window comes from GGGS index arithmetic (rows and columns both double per
level, and the +-72/+-80 latitudeScaleFactor bands are bounded by whole grid rows
at every level, so an ancestor and its descendants share a band), making it an
exact power-of-two fraction; a descendant that does not resolve inside its
ancestor draws nothing rather than guessing. The CPU-side alternative — crop each
sub-window into its own small texture, no renderer change — was rejected: at
zoom-out the pending set is the whole survey, so it pays an upload per
placeholder per frame, or keeps a second resident texture per pending index,
which is the memory eviction exists to reclaim.

camp#103 offscreen culling is intact (the clip test is still before textureFor,
now in planDraw), as is the camp#134 GL-context discipline. camp#172 reload is
unchanged and consistent by construction: every index it wants to reload is
evicted, hence pending, hence currently shown as a placeholder, and a completed
reload replaces that placeholder with the real thing. The converse deliberately
does not hold — a catalogued tile never received has no disk copy, so it is a
request candidate, not a reload one.

test_sonar_live_placeholder_draw pins the rule (GL-free, ROS-free, same offscreen
harness as test_sonar_live_overview_prune): nothing coarse while the fine tiles
are resident (the reported defect); a known-but-unloaded tile IS covered, over
its own footprint and through a strict sub-rect of the ancestor, asserted against
an independent geographic derivation of the same window; whole-survey coverage
survives evicting every fine tile, with no catalog at all; and unsurveyed water
inside the apex's extent draws nothing at either zoom. Verified the first, third
and fourth fail against the pre-fix draw list and the second and third against an
unclipped placeholder. test_raster_gl_renderer gains the renderer half: the
default window still samples the whole texture, and each quadrant window samples
exactly that quadrant (the north/south pair catches a flipped v). Verified it
fails against the pre-change texcoords.

ADR-0010 D5 is replaced (draw-order fallback -> clipped placeholder) and D1's
guarantee is restated in the shape it now has: the protected apex is not drawn at
zoom-out, it is the guaranteed resident SOURCE a zoomed-out view samples through
the footprints of the missing tiles. Two consequences recorded: a pyramid whose
fine tiles are neither catalogued nor on disk now draws nothing, and zoom-out
issues one small quad per missing tile (sharing the same few textures) rather
than one per overview.

Verified: camp builds; 323 tests, 0 failures (baseline 317, +5 new gtest cases +1
new ctest aggregate entry), 1 pre-existing GL skip in test_gggs_render. A camp
restart is required for this to take effect.
@rolker

rolker commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

Duplicate of #220, which already carries these same three commits (532b6df, af5e378, 0fc6408) from the same field push. Opened in error during a second /import-field-changes pass whose dedup did not check for an existing import PR.

Pre-review findings from that pass have been moved to #220 so the analysis is not lost. Closing this and deleting feature/issue-222.

@rolker rolker closed this Sep 3, 2026
@rolker
rolker deleted the feature/issue-222 branch September 3, 2026 13:16
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.

Field import: camp (2026-08-27)

1 participant