Skip to content

feat(analytics): split advert relay airtime by route - #86

Merged
dborup merged 7 commits into
masterfrom
codex/split-relay-airtime-adverts
Sep 24, 2026
Merged

dborup merged 7 commits into
masterfrom
codex/split-relay-airtime-adverts

Conversation

@adminopenclaw8-sketch

@adminopenclaw8-sketch adminopenclaw8-sketch commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Split ADVERT rows in Relay Airtime Share into ADVERT (flood) (route 0/1) and ADVERT (zero-hop) (route 2/3).
  • NULL or unknown route values keep the historical unsuffixed ADVERT bucket instead of being guessed into either class.
  • Every non-ADVERT payload stays grouped exactly as before, whatever its route.
  • Payload Type Mix is untouched and still shows one combined ADVERT entry.
  • Every row carries a machine-readable route_class, so clients no longer have to parse the display label.

Upstream context: Kpa-clawbot/CoreScope#2041 (reference only).

API contract

GET /api/analytics/relay-airtime-share rows now have this shape. The only new field is route_class, and every existing field and label is unchanged.

{"payload_type": "ADVERT (flood)",    "type": 4, "route_class": "flood",    "count": 84,  "count_pct": 16.568, "score": 370284543856, "airtime_pct": 30.414}
{"payload_type": "ADVERT (zero-hop)", "type": 4, "route_class": "zero_hop", "count": 22,  "count_pct": 4.339,  "score": 1550336000,   "airtime_pct": 0.127}
{"payload_type": "ADVERT",            "type": 4, "route_class": "legacy",   "count": 2,   "count_pct": 0.394,  "score": 2325504000,   "airtime_pct": 0.191}
{"payload_type": "GRP_TXT",           "type": 5, "route_class": null,       "count": 166, "count_pct": 32.742, "score": 572542975819, "airtime_pct": 47.027}
route_class Rows Label
"flood" ADVERT, route_type 0 or 1 ADVERT (flood)
"zero_hop" ADVERT, route_type 2 or 3 ADVERT (zero-hop)
"legacy" ADVERT, route_type NULL, negative or > 3 ADVERT
null every non-ADVERT row (always present, never omitted) unchanged
  • Row key. (type, route_class) is unique for every row, including several unnamed payload types that all show the UNK label. type alone is not unique, since the three ADVERT rows share type: 4.
  • Stable values. The route_class strings are constants that don't depend on the display labels. payload_type stays a display label and may change.
  • Why route_class rather than a bucket_key. It describes what the split means. Combined with the existing type, it gives a unique key without a second, redundant identifier.
  • Compatibility. The change is additive. Clients that ignore unknown fields are unaffected. Clients that keyed rows by type alone should key by (type, route_class).
  • Documentation. The endpoint is now documented in cmd/server/openapi.go: fields, route_class values, row key, first-ingested semantics, and window / from / to. It is removed from openapi_known_gaps.json, and the completeness gate passes.
  • Frontend. The dumbbell still renders and colours rows by position. Each row now carries data-payload-type and, on ADVERT rows, data-route-class, giving a stable identity that doesn't depend on the label or on type being unique. Responses without route_class (older servers) render unchanged.

Firmware evidence

MeshCore firmware at 0679dbeffc504d562d2f09eb072fdc223f8ffc2a. src/Packet.h, src/Mesh.cpp and docs/packet_format.md are identical at current firmware HEAD e94125987ed87497e706a0b54d1e80c709343980.

Claim Evidence
Route type is the two low header bits src/Packet.h:8 PH_ROUTE_MASK 0x03; :62 getRouteType()
0 transport flood, 1 flood, 2 direct, 3 transport direct src/Packet.h:14-17; docs/packet_format.md:21-24
Payload 4 is ADVERT src/Packet.h:23
0/1 are flood, 2/3 are direct src/Packet.h:64-65
Direct adverts are zero-hop Mesh::sendZeroHop sets route 2/3 with path_len = 0 (src/Mesh.cpp:717-739). Every firmware advert send site uses sendZeroHop or sendFlood*, never sendDirect.
Direct adverts are not relayed Direct forwarding requires getPathHashCount() > 0 (src/Mesh.cpp:78); routeRecvPacket only re-broadcasts flood packets (src/Mesh.cpp:346)
Older firmware generations The initial commit 6c7efdd0 had 0 and 3 reserved, and flood/direct were already 1/2. Transport variants took over 0/3 in 3c7ff8da. No generation maps a value to the opposite class.

First-seen route: verified behaviour

Where mixed routes come from. The same advert payload can arrive on both route classes. BaseChatMesh::shareContactZeroHop (src/helpers/BaseChatMesh.cpp:539-551@0679dbef, the companion app's "share contact") re-sends a stored raw advert unchanged as TRANSPORT_DIRECT zero-hop. ComputeContentHash hashes only the payload type and payload, not the route bits, transport codes or path, so both variants get the same hash. An originator's own flood and local zero-hop adverts are not a source of mixing: they are separate createSelfAdvert() calls on separate timers, so they have different timestamps, signatures and hashes.

Ingest flow. In cmd/ingestor InsertTransmission, a new hash inserts the transmission with that observation's route_type and raw_hex. An existing hash only moves first_seen back to an earlier receive time. route_type and raw_hex are never rewritten. Every observation is stored with its own raw_hex.

Regression tests. cmd/ingestor/advert_route_first_ingested_test.go drives the real DecodePacket, BuildPacketData and InsertTransmission:

Order Stored route_type / raw_hex first_seen Relay Airtime Share
Flood inserted first, zero-hop later 1 / flood frame flood rx ADVERT (flood) / flood
Zero-hop inserted first, flood later 3 / zero-hop frame zero-hop rx ADVERT (zero-hop) / zero_hop
Zero-hop inserted first, flood received earlier 3 / zero-hop frame flood rx (moved back) ADVERT (zero-hop) / zero_hop

In every case there is one transmission with two observations. The server side is pinned by TestRelayAirtimeShare_MixedRouteHashFollowsStoredRoute.

What this means. The route class is the class of the first observation inserted for the content hash, not of the earliest received one. When a zero-hop re-share is inserted first, the relays of later flood observations are scored on the zero_hop row, even though a pure zero-hop advert is never relayed. The browser dataset below reproduces this: 1.55 s of relay airtime on ADVERT (zero-hop), all from that one hash. The code and OpenAPI docs now state this contract explicitly.

Why it is not changed here:

  • Reclassifying a relayed zero-hop advert as flood is not provably correct. Firmware direct forwarding (Mesh.cpp:78) applies to every payload type, so a non-firmware client sending a direct advert with a path would legitimately be relayed.
  • Rewriting route_type on later observations is unsafe. It would break consistency with the stored raw_hex header, make the class depend on arrival order in a new way, and add a write to the ingest hot path.
  • A correct fix is feasible but larger. Per-observation raw_hex already exists (#881), but the server drops obs.RawHex from memory in all four observation load paths, relying on the assumption "same content hash ⇒ same frame", which this case contradicts. A correct fix changes those load paths and adds a mixed-class definition to the API. Rows stored before per-observation raw_hex existed have no per-observation route.

Proposed follow-up issue (not created)

  • Title: Relay Airtime Share: ADVERT route class follows the first inserted observation
  • Repro: go test ./cmd/ingestor -run AdvertRouteIsFirstIngested, then inspect a hash first inserted as a zero-hop re-share.
  • Options:
    1. Keep first-ingested semantics (current, documented).
    2. During the existing observation loads, keep a one-byte per-transmission bitmask of the route classes seen, parsed from each observation's raw_hex header byte. Then either add a "mixed" route class or report flood whenever any flood observation exists. Fall back to the transmission's route for rows without per-observation raw_hex.
    3. Also fix the enrichObs assumption that every observation of a hash has the same frame.
  • Constraints: memory (1 byte per transmission), cold-load cost (one header byte per observation), and API versioning of any new class.

Test hardening across the PR

  • Classification. Table tests cover every route class; route values NULL, 4, 7, 42, 99, -1 and -2; all 15 non-ADVERT payload types staying unsplit and route_class: null on every route; and route_class values independent of the labels.
  • Accounting. Exact counts and scores (independent lora.TimeOnAir), totals and percentage denominators, hash dedup, deterministic ordering including tied UNK rows, and zero-score buckets. Single-class and empty datasets are covered.
  • Served API. Through the router: exact field set; route_class is a JSON value (never absent) on every row; (type, route_class) is unique even with two unnamed payload types that both show UNK; the three ADVERT rows share type: 4.
  • Payload Type Mix keeps one ADVERT.
  • Frontend (test-analytics-relay-airtime-dumbbell.js, 10 cases): one row per API row, per-row tooltip, dot positions and distinct colours, a (type, route_class) identity that survives relabelling, rendering of responses without route_class, no ids, single-class and empty cases.
  • First-ingested contract, in the ingestor and the server (above).

Mutation testing

Each mutation was applied to a scratch copy, never to this branch. All 24 were killed.

# Mutation Killed by
1 Swap flood and zero-hop RouteClassification, AdvertSplitSingleClassAndEmpty, AdvertSplitTotalsAndDedup
2 Route 3 → flood RouteClassification/advert_transport_direct, SplitsAdvertRouteClasses
3 Unknown route → flood RouteClassification/advert_route_{4,99,-1,-2}, …/legacy_only
4 NULL → zero-hop RouteClassification/advert_NULL_route, …/legacy_only
5 ACK split by route NonAdvertPayloadsIgnoreRoute, AdvertRowsShareNumericType
6 Legacy bucket dropped AdvertSplitTotalsAndDedup, …/legacy_only
7 Frontend keys rows by numeric type dumbbell: rows in API order, per-row tooltip
8 A bucket missing from totalCount AdvertSplitTotalsAndDedup, …/zero-hop_only
9 A bucket missing from totalScore AdvertSplitTotalsAndDedup, …/flood_only
10 Same hash counted twice AdvertSplitTotalsAndDedup, AdvertRowsShareNumericType
11 Payload Type Mix split by route TestPayloadTypeMix_AdvertStaysCombined
12 Flood and zero-hop share a label RouteClassification, AdvertRowsShareNumericType
13 type tiebreak removed UnknownPayloadTieOrderIsStable
14 Frontend colours rows by numeric type dumbbell: distinct ADVERT colours
15 route_class missing from rows AdvertRowsShareNumericType, MixedRouteHashFollowsStoredRoute
16 Zero-hop rows report flood RouteClassification, AdvertRowsShareNumericType
17 Legacy rows report zero_hop RouteClassification/advert_NULL_route, …
18 Non-ADVERT rows get an ADVERT class NonAdvertPayloadsIgnoreRoute, RouteClassification/unknown_payload_12
19 route_class derived from a reworded label RouteClassification, AdvertRowsShareNumericType, MixedRouteHashFollowsStoredRoute
20 Frontend drops data-route-class dumbbell: identity, identity-not-from-label
21 Frontend derives identity from the label dumbbell: identity-not-from-label, older-server rows
22 Ingestor updates route_type on later observations AdvertRouteIsFirstIngested (all 3 orders)
23 Ingestor switches to the earliest-received route AdvertRouteIsFirstIngested/zero-hop_inserted_first_but_received_later
24 Server reclassifies a relayed zero-hop advert as flood MixedRouteHashFollowsStoredRoute, …/zero-hop_only

Performance

Split vs master. Measured on the split. computeRelayAirtimeShare, master 6334c427 vs branch, identical synthetic stores, 9 interleaved runs per size, -benchmem, Apple M2 Pro, go1.26.0:

n master median branch median Δ runtime Δ B/op Δ allocs/op
1,000 0.103 ms 0.106 ms +3.1% +968 B +16
30,000 3.264 ms 3.431 ms +5.1% +984 B +18
300,000 57.49 ms 59.23 ms +3.0% +984 B +18

route_class vs a2e726ca. This round only adds an output field per row (at most 16 rows); a *string for each ADVERT row accounts for up to 3 small allocations. 6 interleaved runs:

n a2e726ca median (min-max) f5daed38 median (min-max) Δ runtime Δ B/op Δ allocs/op
30,000 3.385 ms (3.352-3.406) 3.377 ms (3.336-3.478) -0.3% +48 B +3
300,000 58.93 ms (58.57-59.39) 59.05 ms (58.50-59.33) +0.2% +48 B +3
  • It is still a single O(n) pass under the existing RLock, with no new scans.
  • Label, type, count, score and totals are identical to a2e726ca at both sizes. Non-ADVERT rows and totals are identical to master.

Verification

Run on f5daed38:

  • cd cmd/server && go test -count=1 ./... and cd cmd/ingestor && go test -count=1 ./... passed.
  • -race: the relay airtime, airtime, wardriving and OpenAPI tests in cmd/server, and the advert-route and InsertTransmission tests in cmd/ingestor, passed.
  • go vet passed for both packages. gofmt -l is clean on the touched Go files. git diff --check is clean, and openapi_known_gaps.json is valid JSON.
  • node --check public/analytics.js passed. scripts/check-xss-sinks.sh --diff origin/master passed.
  • Master moved to 4f59b993 (the #79 merge, which touches only cmd/server/distance_lock_contention_test.go). There is no file overlap and no conflict, and the full cmd/server suite passes on the computed merge tree. Master was not merged into this branch.
  • test-analytics-relay-airtime-dumbbell.js 10/10, test-analytics-table-ids-unique.js 4/4, test-packet-filter.js 92/92, test-aging.js 19/19, and the other analytics unit tests all passed.
  • test-frontend-helpers.js (2 favStar cases) and test-analytics-channels-integration.js (1 sidebar-link case) fail locally, identically on unmodified master. They are unrelated to this change.

Browser validation

Chromium was run against a local server using a scratch copy of the e2e fixture. Nothing touched staging, demo or production.

Seeded data:

  • ADVERT routes 0, 3, NULL and 99, and ACKs on routes 0 and 2.
  • Two mixed-route content hashes produced by the real ingestor: one flood-first, one zero-hop-first.
  • The relays' resolved_path on the flood observations was set by hand, because the resolver needs known nodes.

Full dataset, desktop, after a full reload:

  • The DOM matches the API for every row: label, data-payload-type, data-route-class, count %, airtime % and tooltip.
  • (type, route_class) is unique, and the three ADVERT colours are distinct.
  • There are no duplicate ids, no console errors, and no error or unhandledrejection events.
  • Payload Type Mix shows a single ADVERT (108).

Other checks:

  • 375 px mobile: no page-level horizontal scroll and no label overflow. The card's 371 px vs 349 px overflow is identical with the old single label, so it is the existing baseline.
  • Navigation: navigating away and back, and a full reload, give identical rows.
  • Legacy-only: one ADVERT / legacy row.
  • Zero-hop-only: one ADVERT (zero-hop) / zero_hop row, whose 0.18% airtime comes entirely from the zero-hop-first mixed hash.
  • Flood-only: one ADVERT (flood) / flood row.
  • Empty: the existing empty-state message, with no errors.

Independent review

  • Round 1 (at 0260c4c8): no blockers. Its nits were fixed in a2e726ca, and its suggested machine-readable field is now route_class.

  • Round 2 (at 9fefb142), a separate reviewer: no blockers. It confirmed:

    • the API is additive, JSON null is never absent, and the OpenAPI text matches ParseTimeWindow and the cache TTL;
    • (type, route_class) is unique by construction;
    • the first-seen flow, in both code and firmware, and that no small safe fix was missed;
    • the mutations and browser evidence;
    • performance and scope (exactly 7 files), and that the map[string]interface{} count is unchanged at 705.

    f5daed38 addresses its findings:

    • The served-API test no longer requires unique labels. That requirement contradicted the row key, and the test now includes two UNK types.
    • relayAirtimeRouteClass returns *string instead of interface{}.
    • The mixed-route test comment now describes what the test models.
    • The OpenAPI from/to text covers unparseable values.
    • Left as information only: in a mixed hash, the Time-on-Air byte count also comes from the first-inserted frame. An old pre-#786 hash migration in hash_migrate.go may pick an arbitrary surviving row; that code predates this PR and is out of scope.

Commits

  • 822c9660 Split advert relay airtime by route class
  • 0260c4c8 test(analytics): harden relay airtime ADVERT split contract
  • a2e726ca fix(analytics): stable relay airtime order for tied UNK rows
  • daeec639 feat(analytics): add machine-readable route_class to relay airtime rows
  • 4e11bd39 feat(analytics): expose relay airtime row identity in the dumbbell
  • 9fefb142 test(ingestor): pin first-ingested route for adverts seen on both routes
  • f5daed38 fix(analytics): type route_class and stop keying relay airtime rows by label

Limitations

  • First-inserted route class. A hash seen on both route classes is classified by its first inserted observation, and its Time-on-Air uses that observation's frame length. This is documented and tested, and a follow-up is proposed above.
  • Mobile overflow (existing baseline). The dumbbell's grid columns are fixed width, so long labels wrap to two lines and the card overflows slightly on narrow phones. This is identical on master.
  • Region and area are ignored. The endpoint accepts region and area from the frontend but ignores them, as it did before this change.
  • Local data only. Browser validation used a fixture-derived local dataset, not live mesh data.

🤖 Generated with Claude Code

Openclaw and others added 7 commits September 23, 2026 16:28
Pin the route-class contract for the Relay Airtime Share ADVERT split:

- table tests for routes 0/1 (flood), 2/3 (zero-hop), NULL and
  out-of-range values (legacy ADVERT), and all non-ADVERT payload
  types staying unsplit on every route
- totals, percentage denominators, hash dedup and deterministic
  ordering with all three ADVERT buckets present
- single-class and empty datasets
- the served API: ADVERT rows share numeric type 4, stay unique by
  payload_type, and the row shape is unchanged
- Payload Type Mix keeps one combined ADVERT entry
- frontend: renderRelayAirtimeDumbbell renders one row per API row
  when rows repeat numeric type (test-only export), wired into
  test-all.sh and the CI frontend step

Document on computeRelayAirtimeShare that type can repeat across the
ADVERT rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Independent review follow-ups for the relay airtime ADVERT split:

- break sort ties on the numeric payload type after the label, since
  unnamed payload types all share the "UNK" label and previously kept
  Go map iteration order when airtime and count tied
- note that relayAirtimeKey expects a non-nil payload type
- assert that ADVERT rows sharing numeric type 4 get distinct airtime
  colours in the dumbbell renderer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Relay Airtime Share rows gain an additive route_class field so clients
no longer have to parse the payload_type display label to tell the
three ADVERT rows (all type 4) apart:

- "flood" for ADVERT route_type 0/1
- "zero_hop" for ADVERT route_type 2/3
- "legacy" for ADVERT with NULL, negative or out-of-range route_type
- null on every non-ADVERT row

(type, route_class) identifies each row. Existing fields and labels are
unchanged. The values are constants independent of the display labels.

Document the endpoint in openapi.go (fields, route_class values, row
key, window/from/to) and drop it from openapi_known_gaps.json. The
route class is the route_type stored on the transmission, i.e. that of
the first observation the ingestor inserted for the content hash; a
test pins how a hash seen on both route classes is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each dumbbell row carries data-payload-type and, for ADVERT rows,
data-route-class from the API's route_class. Rows stay rendered and
coloured by position; the attributes give tooling and tests a stable
(type, route_class) identity that does not depend on the display label
or on type being unique. Responses without route_class (older servers)
render unchanged, just without data-route-class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A contact re-shared with shareContactZeroHop is the same advert payload
sent as TRANSPORT_DIRECT, so ComputeContentHash gives it the same hash
as the original flood advert. Drive the real DecodePacket,
BuildPacketData and InsertTransmission with both variants in both
orders and pin the current contract: one transmission, two
observations, route_type and raw_hex from the first inserted
observation, first_seen moved back to the earliest receive time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y label

Independent review follow-ups for route_class:

- relayAirtimeRouteClass returns *string instead of interface{}; nil
  still serialises as JSON null
- the served-API test no longer requires unique payload_type labels,
  which contradicts the (type, route_class) row key; it now includes
  two unnamed payload types (both "UNK") and asserts uniqueness of the
  key instead
- reword the mixed-route test comment to say what it models: the
  stored first-inserted route with relays from flood observations
- OpenAPI: from/to replace window whenever either is given, and an
  unparseable value leaves that bound open

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dborup
dborup merged commit e51272d into master Sep 24, 2026
6 checks passed
adminopenclaw8-sketch pushed a commit that referenced this pull request Sep 24, 2026
Brings in #86 (Relay Airtime Share), #87 (blacklist QA hardening) and #90
(Reach Rank test stabilisation). None of them touch public/live.js or
test-live-multibyte-only-e2e.js. The merge was conflict-free, and its tree
equals the verified synthetic merge tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants