Repository navigation
feat(analytics): split advert relay airtime by route - #86
Merged
Merged
Conversation
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>
This was referenced Sep 24, 2026
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>
This was referenced Sep 24, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ADVERT (flood)(route 0/1) andADVERT (zero-hop)(route 2/3).ADVERTbucket instead of being guessed into either class.ADVERTentry.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-sharerows now have this shape. The only new field isroute_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"flood"route_type0 or 1ADVERT (flood)"zero_hop"route_type2 or 3ADVERT (zero-hop)"legacy"route_typeNULL, negative or > 3ADVERTnull(type, route_class)is unique for every row, including several unnamed payload types that all show theUNKlabel.typealone is not unique, since the three ADVERT rows sharetype: 4.route_classstrings are constants that don't depend on the display labels.payload_typestays a display label and may change.route_classrather than abucket_key. It describes what the split means. Combined with the existingtype, it gives a unique key without a second, redundant identifier.typealone should key by(type, route_class).cmd/server/openapi.go: fields,route_classvalues, row key, first-ingested semantics, andwindow/from/to. It is removed fromopenapi_known_gaps.json, and the completeness gate passes.data-payload-typeand, on ADVERT rows,data-route-class, giving a stable identity that doesn't depend on the label or ontypebeing unique. Responses withoutroute_class(older servers) render unchanged.Firmware evidence
MeshCore firmware at
0679dbeffc504d562d2f09eb072fdc223f8ffc2a.src/Packet.h,src/Mesh.cppanddocs/packet_format.mdare identical at current firmware HEADe94125987ed87497e706a0b54d1e80c709343980.src/Packet.h:8PH_ROUTE_MASK 0x03;:62getRouteType()src/Packet.h:14-17;docs/packet_format.md:21-24src/Packet.h:23src/Packet.h:64-65Mesh::sendZeroHopsets route 2/3 withpath_len = 0(src/Mesh.cpp:717-739). Every firmware advert send site usessendZeroHoporsendFlood*, neversendDirect.getPathHashCount() > 0(src/Mesh.cpp:78);routeRecvPacketonly re-broadcasts flood packets (src/Mesh.cpp:346)6c7efdd0had 0 and 3 reserved, and flood/direct were already 1/2. Transport variants took over 0/3 in3c7ff8da. 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.ComputeContentHashhashes 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 separatecreateSelfAdvert()calls on separate timers, so they have different timestamps, signatures and hashes.Ingest flow. In
cmd/ingestorInsertTransmission, a new hash inserts the transmission with that observation'sroute_typeandraw_hex. An existing hash only movesfirst_seenback to an earlier receive time.route_typeandraw_hexare never rewritten. Every observation is stored with its ownraw_hex.Regression tests.
cmd/ingestor/advert_route_first_ingested_test.godrives the realDecodePacket,BuildPacketDataandInsertTransmission:route_type/raw_hexfirst_seenADVERT (flood)/floodADVERT (zero-hop)/zero_hopADVERT (zero-hop)/zero_hopIn 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_hoprow, even though a pure zero-hop advert is never relayed. The browser dataset below reproduces this: 1.55 s of relay airtime onADVERT (zero-hop), all from that one hash. The code and OpenAPI docs now state this contract explicitly.Why it is not changed here:
Mesh.cpp:78) applies to every payload type, so a non-firmware client sending a direct advert with a path would legitimately be relayed.route_typeon later observations is unsafe. It would break consistency with the storedraw_hexheader, make the class depend on arrival order in a new way, and add a write to the ingest hot path.raw_hexalready exists (#881), but the server dropsobs.RawHexfrom 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-observationraw_hexexisted have no per-observation route.Proposed follow-up issue (not created)
go test ./cmd/ingestor -run AdvertRouteIsFirstIngested, then inspect a hash first inserted as a zero-hop re-share.raw_hexheader 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-observationraw_hex.Test hardening across the PR
route_class: nullon every route; androute_classvalues independent of the labels.lora.TimeOnAir), totals and percentage denominators, hash dedup, deterministic ordering including tiedUNKrows, and zero-score buckets. Single-class and empty datasets are covered.route_classis a JSON value (never absent) on every row;(type, route_class)is unique even with two unnamed payload types that both showUNK; the three ADVERT rows sharetype: 4.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 withoutroute_class, no ids, single-class and empty cases.Mutation testing
Each mutation was applied to a scratch copy, never to this branch. All 24 were killed.
RouteClassification,AdvertSplitSingleClassAndEmpty,AdvertSplitTotalsAndDedupRouteClassification/advert_transport_direct,SplitsAdvertRouteClassesRouteClassification/advert_route_{4,99,-1,-2},…/legacy_onlyRouteClassification/advert_NULL_route,…/legacy_onlyNonAdvertPayloadsIgnoreRoute,AdvertRowsShareNumericTypeAdvertSplitTotalsAndDedup,…/legacy_onlytypetotalCountAdvertSplitTotalsAndDedup,…/zero-hop_onlytotalScoreAdvertSplitTotalsAndDedup,…/flood_onlyAdvertSplitTotalsAndDedup,AdvertRowsShareNumericTypeTestPayloadTypeMix_AdvertStaysCombinedRouteClassification,AdvertRowsShareNumericTypetypetiebreak removedUnknownPayloadTieOrderIsStabletyperoute_classmissing from rowsAdvertRowsShareNumericType,MixedRouteHashFollowsStoredRoutefloodRouteClassification,AdvertRowsShareNumericTypezero_hopRouteClassification/advert_NULL_route, …NonAdvertPayloadsIgnoreRoute,RouteClassification/unknown_payload_12route_classderived from a reworded labelRouteClassification,AdvertRowsShareNumericType,MixedRouteHashFollowsStoredRoutedata-route-classroute_typeon later observationsAdvertRouteIsFirstIngested(all 3 orders)AdvertRouteIsFirstIngested/zero-hop_inserted_first_but_received_laterMixedRouteHashFollowsStoredRoute,…/zero-hop_onlyPerformance
Split vs master. Measured on the split.
computeRelayAirtimeShare, master6334c427vs branch, identical synthetic stores, 9 interleaved runs per size,-benchmem, Apple M2 Pro, go1.26.0:route_classvsa2e726ca. This round only adds an output field per row (at most 16 rows); a*stringfor each ADVERT row accounts for up to 3 small allocations. 6 interleaved runs:a2e726camedian (min-max)f5daed38median (min-max)RLock, with no new scans.a2e726caat both sizes. Non-ADVERT rows and totals are identical to master.Verification
Run on
f5daed38:cd cmd/server && go test -count=1 ./...andcd cmd/ingestor && go test -count=1 ./...passed.-race: the relay airtime, airtime, wardriving and OpenAPI tests incmd/server, and the advert-route andInsertTransmissiontests incmd/ingestor, passed.go vetpassed for both packages.gofmt -lis clean on the touched Go files.git diff --checkis clean, andopenapi_known_gaps.jsonis valid JSON.node --check public/analytics.jspassed.scripts/check-xss-sinks.sh --diff origin/masterpassed.4f59b993(the#79merge, which touches onlycmd/server/distance_lock_contention_test.go). There is no file overlap and no conflict, and the fullcmd/serversuite passes on the computed merge tree. Master was not merged into this branch.test-analytics-relay-airtime-dumbbell.js10/10,test-analytics-table-ids-unique.js4/4,test-packet-filter.js92/92,test-aging.js19/19, and the other analytics unit tests all passed.test-frontend-helpers.js(2favStarcases) andtest-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:
resolved_pathon the flood observations was set by hand, because the resolver needs known nodes.Full dataset, desktop, after a full reload:
data-payload-type,data-route-class, count %, airtime % and tooltip.(type, route_class)is unique, and the three ADVERT colours are distinct.errororunhandledrejectionevents.Other checks:
ADVERT/legacyrow.ADVERT (zero-hop)/zero_hoprow, whose 0.18% airtime comes entirely from the zero-hop-first mixed hash.ADVERT (flood)/floodrow.Independent review
Round 1 (at
0260c4c8): no blockers. Its nits were fixed ina2e726ca, and its suggested machine-readable field is nowroute_class.Round 2 (at
9fefb142), a separate reviewer: no blockers. It confirmed:nullis never absent, and the OpenAPI text matchesParseTimeWindowand the cache TTL;(type, route_class)is unique by construction;map[string]interface{}count is unchanged at 705.f5daed38addresses its findings:UNKtypes.relayAirtimeRouteClassreturns*stringinstead ofinterface{}.from/totext covers unparseable values.#786hash migration inhash_migrate.gomay pick an arbitrary surviving row; that code predates this PR and is out of scope.Commits
822c9660Split advert relay airtime by route class0260c4c8test(analytics): harden relay airtime ADVERT split contracta2e726cafix(analytics): stable relay airtime order for tied UNK rowsdaeec639feat(analytics): add machine-readable route_class to relay airtime rows4e11bd39feat(analytics): expose relay airtime row identity in the dumbbell9fefb142test(ingestor): pin first-ingested route for adverts seen on both routesf5daed38fix(analytics): type route_class and stop keying relay airtime rows by labelLimitations
regionandareafrom the frontend but ignores them, as it did before this change.🤖 Generated with Claude Code