SaarMesh-Bot flagged on #1865 (PR #1867 commit 631686a) that
UpdateNodeConfiguredScope stored the report timestamp raw and compared it
lexicographically. Real firmware emits microsecond+offset timestamps and
different observers may use "Z" or non-UTC offsets, so string comparison
can diverge from chronological order and let a stale report win.
normalizeReportTS parses RFC3339Nano/RFC3339 and stores canonical UTC
RFC3339, used for both storage and the comparison.
cwichura flagged on PR #1867 that nodes.configured_scope (from the
/neighbors OTA report) was stored as the observer's raw scope string
("dk") while default_scope and every other scope display use the
#-prefixed hashRegion form ("#dk"). Apply the same regions.Normalize
already used for default_scope, with "*" passed through unprefixed
since it's a protocol wildcard, not a region name.
Requested by dborup after seeing the highscore board start empty on
deploy. A new async migration (ping_triggers_backfill_v1, following the
existing tx_last_seen_backfill_v1 pattern) scans payload_type=5
transmissions for the ping trigger once, catching CHAN messages sent
before this feature existed -- they never went through
InsertTransmission's isNew detection hook, since that only fires for
brand-new transmissions going forward.
Pulled the migration body into a named backfillPingTriggers function
(unlike its inline siblings) so tests can call it directly instead of
faking the async marker/goroutine machinery.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New feature: every "ping" channel message gets recorded and scored,
producing records (farthest reach, most hops, widest simultaneous
spread, fastest full spread, most airtime-efficient) plus leaderboards
(top relay nodes, top first-hearer observers). Global, not scoped by
region/area, per dborup's request.
Architecture:
- Ingestor writes a lightweight ping_triggers row (tx_id, hash,
channel_hash, sender, first_seen) at ingest time when a new CHAN
transmission matches the same isPingTrigger logic the pong-reply
feature already uses (mirrored, kept in sync by hand -- now a 3rd
copy alongside public/channels.js and cmd/server/db.go).
- Server periodically (every 2 min) joins ping_triggers with the same
GetPacketPath + LoRa-airtime-estimate logic behind View Path,
computing the full snapshot in memory (no write access needed from
the read-only server) and caching it, matching the steady-state
recomputer pattern used throughout cmd/server.
- New GET /api/ping-scores endpoint + Ping Scores page (linked from
Tools and from every pong reply bubble in Channels).
Schema change follows the dbschema.go INVARIANT from #1321 exactly:
migration in Apply() (ingestor-only), assertion in AssertReady()
(server fatal-exits if ping_triggers is missing rather than silently
degrading) -- the same pattern #1865/#1867's configured_scope should
have used but didn't fully wire up (see the ping_scores.go doc
comment and today's earlier startup-race fix for the story).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous fix only stopped the script from writing the wrong field
going forward. SE12 -- the one area actually created by today's buggy
run -- falls through both remaining code paths: it's not in CROSSWALK
(so the enrich step never touches it) and it already exists (so the
"add new area" step skips it as already present). Without an explicit
migration pass it would stay silently unlinked forever.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Caught live on stg: after running the script for real, the one genuinely
new area it added (SE12) never showed up as scoped via /api/config/areas,
while all 14 "enriched" existing areas looked fine. Turned out those 14
already had a correct regionScopes array from earlier manual config work --
the script's regionScope (singular) write was dead data riding along
next to it. SE12 had no prior regionScopes, so the script's only write
landed on a field cmd/server/routes.go's handleConfigAreas never reads,
leaving it silently unlinked.
Now appends to regionScopes (preserving any other scopes already on the
area) and drops a stray regionScope key if a prior buggy run left one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Routine "sync branch with master before deploying" (git merge origin/master
into areas-meshguide-sync) fast-forwarded cleanly and, in doing so, silently
carried master's exclusion of this deployment-specific script back into
areas-meshguide-sync too -- fast-forward doesn't distinguish "master never
had this" from "master deliberately deletes this", so the branch meant to be
this script's home lost it entirely instead of just staying ahead of master.
Restored verbatim from the last commit that had it (e81f89d), including the
matching AREAS.md section describing it. Master must never carry this file;
areas-meshguide-sync must never lose it to a master sync again.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found while live-testing #1867 on stg: the server and ingestor are
separate processes started ~simultaneously by supervisor, sharing one
SQLite file. The server's PRAGMA-based column detection ran exactly
once at OpenDB(), and could fire before the ingestor's additive ALTER
TABLE migrations landed -- reproduced on a fresh stg deploy, where
configured_scope silently never appeared in the API until the
container was manually restarted.
detectSchemaWithRetry now re-scans a few more times on a short fixed
schedule after the first pass. detectSchema's booleans are monotonic
(never reset to false once found), so repeated calls merge safely.
Deliberately does not stop early on two agreeing scans -- a migration
landing between two poll points would make an early scan look stable
right before the real change arrives.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The ESP32 observer firmware now emits a periodic /neighbors report carrying
the observer's own configured region scopes (`self`) plus, for each zero-hop
neighbor, the scopes fetched via an OTA scope query. This records that
CONFIRMED configuration into a new nodes.configured_scope column, kept
strictly separate from the existing inferred `default_scope` (observed advert
transport scope) and transported_scopes (transmissions.scope_name).
Provenance is modelled explicitly rather than overloading default_scope:
default_scope is overwritten on every advert observation, so writing neighbor
scopes there would let the next inferred observation clobber a confirmed
value. A dedicated configured_scope (+ configured_scope_at) column preserves
the distinction and structurally satisfies the report contract.
Report semantics honored:
- Only neighbors with status=="responded" update configured_scope. A timeout
is NOT evidence the scopes were cleared, so it never writes.
- Absence of a neighbor is never a signal: the report is size-capped and
truncates by ordering, so missing != gone — no deletes ever happen.
- A responded neighbor with empty scopes is a valid "no scopes configured"
statement and IS stored (the handler gates on status, not emptiness).
- self scopes are keyed by origin_id (the observer node pubkey) and need no
OTA query. Report pubkeys are uppercase; nodes.public_key is lowercase hex,
so keys are lowercased before the UPDATE. Unknown neighbors are a no-op
until a later advert creates the node.
- Out-of-order reports can't clobber newer data (last-write-wins on
configured_scope_at).
Changes:
- dbschema: additive ensureConfiguredScopeColumns migration on nodes +
inactive_nodes (marker nodes_configured_scope_v1), asserted via mustCol.
- ingestor: handleNeighborsReport dispatch on topic
meshcore/<region>/<observer_id>/neighbors (analogous to /status);
Store.UpdateNodeConfiguredScope writer.
- server: PRAGMA-detect configured_scope (hasConfiguredScope) and expose it +
configured_scope_at on the node read path.
- UI: node-detail (nodes.js + live.js) shows a "Configured scope" row marked
confirmed, with last-confirmed timestamp, distinct from the observed scope.
- tests: handleNeighborsReport (responded writes, timeout/absence never
clears, empty-responded stored, unknown no-op) + last-write-wins.
Topic format meshcore/<region>/<observer_id>/neighbors is assumed by analogy
to the /status topic; noted for reviewer confirmation against the firmware.
Co-Authored-By: Claude <noreply@anthropic.com>
Caught on real stg data: a directly-received packet has relays=0, and
while the plain int AirtimeRelayCount's omitempty correctly dropped it
from JSON, the *float64 EstimatedAirtimeMs still encoded as a bare
"estimatedAirtimeMs":0 (a non-nil pointer isn't "empty" to omitempty
even when it points at zero). The frontend's typeof-number check then
rendered "~0ms estimated airtime (undefined relays)". Now both fields
are omitted together whenever there's nothing to relay.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reuses the Relay Airtime Share formula (LoRa Time-on-Air x distinct
relay count, issue #1768) against the single transmission a View Path
packet resolves to, sourced from the in-memory PacketStore via the
transmission ID GetPacketPath now captures. Rendered in the footer as
"~340ms estimated airtime (3 relays)", omitted cleanly when the store
doesn't have the transmission.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three additions, all using secondsAfterFirst/distanceFromFirstKm data
the response already carried -- no backend changes needed:
- "deepest reached N hops (Xs)" -- the deepest branch's own elapsed
time, appended to the existing stat.
- "farthest reached Xkm (Ys)" -- new stat: the actual distance the
farthest branch reached, plus its elapsed time. Previously only
implied via the "touched" area list or a marker's hover tooltip,
never stated as its own number.
- "fully spread in Zs" -- the largest secondsAfterFirst across ALL
branches, not just farthest/deepest (a middling branch stuck behind
a slow relay can still be the last one reached).
All three degrade cleanly (omitted, not "NaNs") when a branch's
timing is unknown.
Following up on the previous fix (highlight by real distance, not hop
count): dborup asked why not show both when they diverge, rather than
picking one and letting the other blend into the secondary stations.
Now tracks two independent roles per branch -- farthest (by
distanceFromFirstKm) and deepest (by hop count) -- and highlights each
with its own color (accent for farthest, purple for deepest) plus its
own legend entry, when they're genuinely different branches. When
they're the same branch (the common case) it's still shown once,
combined, exactly as before. When no branch has distance data at all,
falls back to the single "deepest (most hops)" highlight, also
unchanged.
Also fixes a gap this surfaced: an observer-only branch (no relay
hops, e.g. a 0-hop direct reception) has no polyline and always FILLS
yellow regardless of role, so farthest vs. deepest were previously
indistinguishable on such a marker. The ring (stroke) color now
carries the role instead, so a highlighted observer reads as an
accent- or purple-ringed yellow dot rather than a plain one.
The highlighted "primary" route was always branches[0] (most hops),
labeled "farthest-traveled route" -- but more hops doesn't mean more
geographic distance. A dense area can take many short hops; a couple
of long-range links can cover more real distance in fewer. dborup
caught the mislabeling; we now pick the branch with the largest
distanceFromFirstKm when any branch has that data (ties break toward
more hops, since branches[] stays deepest-first).
Falls back to the old hops-based pick only when NO branch has usable
distance data (sparse GPS coverage) -- and the legend/checkbox
wording switches to an honest "deepest (most hops)" in that case
instead of still claiming "farthest-traveled" for a branch nobody
actually measured.
The "deepest reached N hops" footer stat is unrelated and unchanged
-- it was already correctly hop-based.
The Transported Scopes row shows regions a repeater has relayed for
OTHERS, but not what the node's own hashRegions scope actually is --
dborup asked for that distinction to be visible too. Added an "Own
scope" row right above it, for any node (not just repeater/room --
any node can configure its own scope). Mirrors live.js's existing
null (no schema support) / empty ("unknown scope") / value
distinction for default_scope.
Region Utilization and the other Scope Statistics sections all have a
one-line description under the summary explaining what the numbers
mean; this one passed null. Added one matching the section's own
doc comment: of the real nodes in each configured area, how many
actually support its linked region.
The "Region Utilization (39 of 1,098 used)" stat already listed the
*unused* regions in a collapsed details block, but not which specific
ones counted toward the "used" side -- dborup asked to see them too.
Added UsedRegions (ScopeStatsResponse) alongside the existing
UnusedRegions, computed in the same matched-regions pass so it's a
free byproduct, not a second query. Frontend adds a matching "Show N
used regions" details block next to the existing unused one.
Two more filter checkboxes alongside the existing declutter toggle:
- "Show area boundaries" -- starts checked (shapes visible by
default, matching current behavior); unchecking removes them.
- "Show only approximate positions" -- appears only when at least
one marker is approximate; filters down to just the
neighbor-estimated stations, useful for sanity-checking how many
(and which) positions on a packet are actually estimates rather
than real fixes.
Both only appear when there's something to toggle (no area boundaries
= no checkbox, no approx markers = no checkbox), matching the
existing declutter toggle's convention.
Refactored the layer-visibility bookkeeping to support this: the
declutter and approx-only filters are independent and combinable, so
naively having each checkbox add/remove only its own layers breaks as
soon as both are active and one gets unchecked -- a layer that's
still excluded by the OTHER filter would incorrectly reappear.
Replaced with markerEntries/polylineEntries (tagged primary/approx)
and a single applyMarkerFilters() that recomputes every layer's
visibility from both checkboxes' current state together. Area
boundary visibility stays a separate, independent toggle since it's
an unrelated layer type.
Three follow-up improvements to the View Path map, all requested
together:
- A compact color/symbol legend (route color, approx marker, first-
heard ring) replaces the old prose paragraph explaining the same
things -- shorter intro text, faster to scan.
- A "show only farthest-traveled route" checkbox appears whenever a
packet was heard by more than one station, to declutter busy
20+-branch packets. Only added/removed as a Leaflet layer toggle;
the primary branch's own markers/polyline are never touched.
- Every touched area is now shaded directly on the map (a real
polygon when the area was drawn as one, a rectangle fallback for
bbox-only areas) instead of only listed as text -- ties the
"touched: X, Y" footer line to actual geography. Non-interactive so
shapes never steal a click meant for a branch marker.
The shading needed PacketPathResponse.TouchedAreas to carry geometry,
not just labels -- changed from []string to []TouchedAreaShape
(label + polygon or bbox). annotatePacketPathTouchedAreas now looks
up the matched area's full config entry via AreaKeyForPoint instead
of just its label via AreaForPoint.
A shared entry-point repeater near the sender commonly appears in
many branches' chains (one relay a dozen stations all heard the
packet through). The status count summed every chain appearance
instead of deduping by node identity, so a single approximate-
position node showing up in 11 branches reported "11 approximate"
-- dborup spotted this on a real packet and asked whether it could
be right; it wasn't, it was the same node counted 11 times.
Dedupes by publicKey (falling back to name when a point has none).
The ping-bot reply's "touched" list caps at 3 to keep a chat bubble
readable, but View Path's map has room to show the full set: every
configured area any point or observer on the path resolved to,
deduped and alphabetized.
No extra DB round-trip needed here (unlike the pong reply's version)
-- GetPacketPath already resolved every position, including the
neighbor-centroid approximation fallback, so annotatePacketPathTouchedAreas
just reads the lat/lon already on the response.
Adds a "touched Area A, Area B, +N more" part to the ping-bot's
reply: the distinct configured areas any hearing station (with its
own GPS fix) was in, deduped and alphabetized, capped at 3 shown to
keep a broadly-flooded packet's reply from growing unboundedly. Sits
alongside the existing "spread up to Nkm" as a named-place view of
the same "how wide did this go" question.
Area resolution needs config.Areas, not available at the SQL-only DB
layer where the reply text is otherwise built -- GetChannelMessages
exposes the raw observer pubkey set via botReply.touchedObserverPubkeys
(never reaching the client), and a new Server-level
annotateBotReplyTouchedAreas resolves and appends the area list, same
layering annotateMessageAreas already uses for the per-message "area"
field.
A 0-hop (direct) message has no relay path at all, so the existing
entry-point-repeater area resolution (path[0]) has nothing to work
with -- even though the hearing station's own position is a
reasonable stand-in for "where this happened" at 0 hops. Falls back
to the station's own GPS fix in that case only; a multi-hop message
whose path just failed to resolve still gets no guess.
Bypasses the path-hop prefix machinery (buildPrefixMap/
resolveEntryPointArea) on purpose: that only indexes repeater/room-
server roles as path-hop candidates, but a listening station's own
position shouldn't depend on whether it could ever appear as a relay
hop in someone else's path. New gpsByPubkeysExact does a plain exact
public_key match instead.
Applied to both the REST channel message list (annotateMessageAreas)
and the live WebSocket broadcast path, which already mirrored each
other for the path[0] case and would otherwise drift.
Adds a "spread up to Nkm" part to the ping-bot's reply, computed as the
farthest any hearing station's own GPS fix was from whoever heard it
first -- the same signal View Path's map shows, surfaced directly in
the chat bubble so it's visible without opening the map. Deliberately
skips View Path's neighbor-centroid position fallback (too expensive
to run per-ping across a page of messages); a station without its own
GPS fix just doesn't contribute, so the part is silently omitted when
fewer than two stations have one.
"heard by N observers" already reported total station breadth per
transmission, so no separate count was added.
The tooltip's containing pane has no intrinsic width, so a plain
width:auto block collapsed to min-content (one word per line) and
never grew toward max-width at all. width:max-content makes it size
to its content up to max-width, producing a normal wrapped box.
Verified live on stg by patching the CSS in-page and re-triggering a
tooltip before shipping.
max-width was a fixed 220px; at bigger font/zoom settings that left
room for barely one word per line. Use 28ch instead so the box scales
with the actual font size.
The "relays for 2+ distinct region scopes" definition doesn't hold up
in practice: most nodes end up carrying both a broad national/regional
scope (e.g. #dk, #eu) and several local ones, so the vast majority of
repeaters would eventually qualify as "bridge" -- the flag stops
meaning anything useful.
Removes IsBridge from PacketPathPoint/PacketPathObserver, the
markBridgeRepeaters handler step, the purple-outline styling, and the
dedicated test file -- keeps PublicKey on PacketPathObserver, since
the click-to-node-detail feature depends on it independently.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tooltip text grew long once role, approx/confidence, bridge, and
distance/timing info were all combined into one line. Leaflet's
default tooltip is white-space:nowrap, so a long one stretched into a
single unreadable line spanning the map instead of wrapping.
Adds a packet-path-tooltip CSS class (white-space:normal, max-width)
passed to every bindTooltip call in this component.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Hop points already carried publicKey; PacketPathObserver got it in the
bridge-highlighting commit. Clicking any marker with one now closes
the modal and navigates to #/nodes/{pubkey} -- the same hash route the
rest of the app already links to (see e.g. public/channels.js). A
marker with no publicKey (e.g. a bridge-type observer keyed by device
name, not pubkey) gets no click handler and stays inert. Tooltip gets
a "click for node detail" hint when applicable.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds DistanceFromFirstKm to PacketPathBranch: the great-circle
distance (haversine) between a branch's own Observer and First's
Observer. Zero for First itself. Deliberately omitted when either
side is positioned via Approx (a neighbor-centroid estimate) -- a
distance computed against a guess isn't a real measurement worth
surfacing.
Rendered in the observer tooltip as "42.3 km away" alongside the
elapsed-time label, giving a concrete sense of how far the flood
physically reached, not just how many hops or how long it took.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds IsBridge to PacketPathPoint/PacketPathObserver and PublicKey to
PacketPathObserver (needed to look bridges up, and useful on its own
for future features). Set by a new markBridgeRepeaters handler-level
step in routes.go, not GetPacketPath itself, since the underlying data
(TransportedScopes per repeater) lives in the in-memory store, not
SQL -- same "relays for 2+ distinct regions" definition and data
source as the Foreign Traffic tab's existing Bridge badge
(ScopeStatsResponse.bridgeRepeaters), just applied to whichever
handful of pubkeys one packet's path touches.
The map gives a bridge repeater a bold purple outline (on top of
whatever primary/approx styling already applies) plus a tooltip note,
so the mesh's backbone nodes stand out regardless of which branch
they're in.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PacketPathPoint already carried Role; PacketPathObserver now does too
(from the observer's own nodes row, when it's known as a mesh node
itself and not just an MQTT/API listener). Rendered as a small icon
prefix in the tooltip (📡 repeater, 🏠 room, 📱 client, 🌡️ sensor) --
markers stay plain circleMarker dots throughout, since a role-specific
shape would clash with the color/dash coding already carrying primary,
approx, and observer meaning.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
nearestPositionedNeighbor now also returns how many positioned
neighbors fed the weighted centroid and the widest distance between
any two of them (0 with a single contributor). Exposed via new
ApproxNeighborCount/ApproxSpreadKm fields on PacketPathPoint and
PacketPathObserver.
The map scales the approximate marker's size/opacity accordingly: one
neighbor (or several that disagree widely) renders as a bigger,
fainter ring; several agreeing neighbors render tighter and more
solid. The tooltip also states the contributor count.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds SecondsAfterFirst to PacketPathBranch: the gap between the
earliest-arriving observation (First) and this branch's own deepest
observation. Zero for First itself. Rendered in the observer's tooltip
as "+4.7s" (or "Nm Ss" for longer gaps, "first to arrive" for zero) --
gives a sense of the propagation order across the flood, not just
depth/breadth.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Approximate (neighbor-borrowed) markers on the View Path map are now
actually visible -- larger radius, thicker dashed stroke, faint fill
instead of a near-invisible hollow outline.
- The approximate position itself is now a count-weighted centroid of
EVERY positioned neighbor, not just the single strongest one, so it
no longer sits exactly on top of one specific neighbor's dot when
more than one positioned neighbor is known.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
nearestPositionedNeighbor picked exactly one neighbor (the strongest by
edge count) and used its exact coordinates -- so the approximate marker
always landed precisely on top of that neighbor's own dot. Since each
neighbor's own position is a real, precise fix, and only the
unpositioned node's position relative to them is unknown, more
positioned neighbors should narrow the estimate rather than being
ignored in favor of just the top one.
Now sums every positioned neighbor's coordinates weighted by
neighbor_edges.count and returns the centroid. With exactly one
positioned neighbor this is unchanged (reduces to that neighbor's exact
position); with several, the marker settles somewhere among them
instead of overlapping a single specific node.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A plain hollow (fillOpacity:0) dashed ring at normal marker size blended
into the map tiles and was easy to miss entirely. Bumped it to a larger
radius (+4px), thicker dashed stroke, and a faint fill so it reads as a
soft, deliberately-imprecise blob instead of vanishing against the
background.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A hop or observer with no position of its own (no self-advertised GPS,
no name match, no IATA) previously stayed unplotted entirely. Adds a
last-resort fallback: look up its strongest neighbor_edges neighbor
(ranked by observation count) and, if that neighbor has a real
position, borrow it as an approximate stand-in -- flagged via a new
`approx` field so callers never mistake it for a real fix.
The View Path map renders approximate markers hollow and dashed
instead of solid, with a status-line callout ("N approximate (via
nearest neighbor)"), and the "N hops without a known position" count
drops accordingly for whichever ones now have a stand-in.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GetPacketPath's node-position lookups (relay-hop points and observer
positioning) read raw lat/lon straight off the nodes row, so a node
with (0,0) stored literally instead of NULL -- MeshCore's "never
actually reported a GPS position" case in practice -- rendered as a
real point off the coast of Ghana instead of no position at all.
Excludes (0,0) the same way GetNodesForScopeAdoption and
geofilter.PassesFilter already do; the node's name is kept, just not
the bogus position.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- GetPacketPath now also returns `first`: the single earliest-arriving
observation across every station (usually 0 hops, close to the
sender), drawn as a green landmark ring on the View Path map --
Branches alone had no natural "where did this start" anchor.
- The ingestor's interior neighbor-edge creation (added earlier today)
now excludes 1-byte hop prefixes, per upstream review on PR #1852
from a 1000+ node deployment: a "currently unique" 1-byte prefix can
silently become wrong once an unknown node sharing that byte joins,
with no secondary signal to catch it. 2+ byte prefixes are unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review on upstream PR #1852 (SaarMesh, 1071 nodes, 18.9M hop
occurrences) flagged a large-mesh false-edge risk in 7fe3dd9's
interior-hop edges: resolvePrefix only requires a hop to be unique
among nodes known TODAY. At 1 byte that "uniqueness" is fragile --
SaarMesh measured ~1.2% of their nodes as currently-unique on 1 byte,
each one a silent miscall waiting for an unknown/new node sharing that
byte to appear. Unlike the two endpoint edges, an interior edge has no
ADVERT/ANON_REQ to double-check it against, so there's no way to catch
the mistake once made. 2+ byte prefixes don't have this problem
(SaarMesh: 99.4% of nodes uniquely resolvable there).
Adds minInteriorEdgeHashBytes=2, matching the conservative default
already chosen for the upstream #1824 pathTrust.minHashBytesForMapping
config -- inlined here rather than depending on that config landing
first, since it isn't wired into any call site yet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GetPacketPath's branches are sorted deepest-first, so the map had no
natural "where did this start" anchor. Adds a `first` field: the
single earliest-arriving observation across every station regardless
of observer or hop depth (the same "first observation wins" pick
already used for a ping message's own meta line), positioned the same
way as any other observer (own GPS, then name match, then IATA).
The map draws it as a green ring on top of everything else, since it
usually coincides with one of the already-plotted branch dots, plus a
status-line callout -- an approximate landmark for where the message
entered the visible mesh.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- GetPacketPath now returns every station that observed a packet
(one branch per observer, deepest-first) instead of only the single
farthest-traveled route, so the "View path" map shows the full
flood spread.
- Observer positions now prefer the station's own self-advertised GPS
(falling back to a node-name match, then the IATA table) instead of
relying on the IATA table alone.
- The ingestor's neighbor_edges builder now also records adjacency
between consecutive hops within a path, not just the two endpoints
-- this is what resolvePathWithContext needs to resolve past hop 0,
and previously that data was never written at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>