chore: save pending state — pathing experiment reports, stats & mosquitto updates (pre-migration sweep)

This commit is contained in:
gadgethd
2026-08-08 16:46:49 +01:00
parent 4af343f60d
commit 40e843ff72
16 changed files with 1385 additions and 7 deletions
+74
View File
@@ -0,0 +1,74 @@
# Pathing accuracy baseline
Phase 0 baseline for `ukmesh`, captured 2026-08-03 from the local TimescaleDB
snapshot. The harness selects multibyte packets from the last 45 days, keeps
only packets whose full relay hashes uniquely identify every relay, degrades
the resolver-facing packet rows to one-byte prefixes, and scores the four
path-resolution jobs:
- legacy-greedy — `lazyResolvePathLegacy`
- lazy-viterbi — `lazyResolvePath`
- beta-single — `resolveBetaPathForPacketHash`
- beta-multi — `resolveMultiObserverBetaPath`
Each cell is `route accuracy / hop accuracy`. Route accuracy means the complete
relay chain is correct; hop accuracy is correct relay positions divided by all
gold relay positions. `n` is the number of gold packets in the stratum.
The primary run requested 1,500 packets and yielded 1,259 valid gold packets:
309 routes of length ≤3, 126 of length 4, 196 of length 56, 190 of length
78, 314 of length 912, and 124 of length 13+. Corridor novelty is source →
receiver: **seen** means the pair occurred earlier in the preceding 120-day
prior-building window; missing source IDs are classified as unseen.
## Primary run: target 1,500, valid gold n=1,259
| stratification | n | legacy-greedy | lazy-viterbi | beta-single | beta-multi |
|---|---:|---:|---:|---:|---:|
| overall | 1,259 | 20.7% / 44.6% | 11.5% / 15.3% | 17.0% / 20.0% | 2.1% / 1.9% |
| prior rare (≤1) | 931 | 16.0% / 39.7% | 6.4% / 10.2% | 17.4% / 17.8% | 1.9% / 1.8% |
| prior mid (27) | 17 | 11.8% / 44.6% | 11.8% / 26.1% | 58.8% / 52.2% | 0.0% / 0.0% |
| prior supported (≥8) | 311 | 35.4% / 62.0% | 26.7% / 33.2% | 13.5% / 26.2% | 2.9% / 2.3% |
| route ≤3 | 309 | 35.9% / 49.5% | 10.4% / 11.1% | 53.7% / 62.8% | 6.8% / 8.0% |
| route 4 | 126 | 37.3% / 54.8% | 11.9% / 14.9% | 23.0% / 43.1% | 1.6% / 4.8% |
| route 56 | 196 | 26.5% / 52.7% | 12.2% / 13.9% | 8.2% / 34.6% | 2.0% / 3.5% |
| route 78 | 190 | 17.4% / 53.7% | 13.7% / 17.1% | 1.1% / 17.5% | 0.0% / 1.7% |
| route 912 | 314 | 5.4% / 41.3% | 9.9% / 12.4% | 0.3% / 10.4% | 0.0% / 0.7% |
| route 13+ | 124 | 0.8% / 33.5% | 13.7% / 21.8% | 0.0% / 6.3% | 0.0% / 0.0% |
| corridor seen | 470 | 21.7% / 42.0% | 7.0% / 6.9% | 19.8% / 20.9% | 2.3% / 1.8% |
| corridor unseen | 789 | 20.2% / 45.9% | 14.2% / 19.7% | 15.3% / 19.5% | 2.0% / 2.0% |
The primary run had one lazy-viterbi `PATH_HISTORY_LIMIT` failure. It is
included in that job's denominator and scored as unresolved; legacy-greedy,
beta-single, and beta-multi had zero resolver failures.
## Smoke run: target 200, valid gold n=173
| stratification | n | legacy-greedy | lazy-viterbi | beta-single | beta-multi |
|---|---:|---:|---:|---:|---:|
| overall | 173 | 20.2% / 48.2% | 10.4% / 14.0% | 18.5% / 21.6% | 0.0% / 0.9% |
| prior rare (≤1) | 131 | 18.3% / 44.0% | 5.3% / 9.3% | 18.3% / 19.3% | 0.0% / 1.1% |
| prior mid (27) | 4 | 25.0% / 76.2% | 25.0% / 52.4% | 75.0% / 76.2% | 0.0% / 0.0% |
| prior supported (≥8) | 38 | 26.3% / 61.7% | 26.3% / 28.7% | 13.2% / 25.4% | 0.0% / 0.0% |
| route ≤3 | 45 | 28.9% / 48.2% | 4.4% / 5.5% | 51.1% / 63.6% | 0.0% / 3.6% |
| route 4 | 20 | 35.0% / 58.8% | 10.0% / 13.8% | 40.0% / 55.0% | 0.0% / 7.5% |
| route 56 | 24 | 33.3% / 52.7% | 16.7% / 17.6% | 4.2% / 16.0% | 0.0% / 0.0% |
| route 78 | 26 | 19.2% / 51.8% | 15.4% / 15.4% | 0.0% / 10.8% | 0.0% / 0.0% |
| route 912 | 41 | 2.4% / 43.0% | 9.8% / 12.5% | 0.0% / 16.5% | 0.0% / 0.0% |
| route 13+ | 17 | 5.9% / 48.2% | 11.8% / 17.6% | 0.0% / 11.8% | 0.0% / 0.0% |
| corridor seen | 60 | 20.0% / 50.5% | 1.7% / 1.6% | 23.3% / 25.7% | 0.0% / 1.3% |
| corridor unseen | 113 | 20.4% / 47.1% | 15.0% / 20.0% | 15.9% / 19.6% | 0.0% / 0.6% |
## Execution and safety notes
The compatible CLI was used:
```text
npx tsx src/path-lazy/evaluate.ts ukmesh 200
npx tsx src/path-lazy/evaluate.ts ukmesh 1500
```
The harness uses SELECTs only. Beta's `touchPredictedOnline` option is false,
and its direct database packet reads are degraded in memory for evaluation;
the database rows are not modified. Beta coordinate DTOs are mapped back to
the unique node coordinate index loaded for the gold set.
+60
View File
@@ -0,0 +1,60 @@
# Single-repeater RF coverage implementation report
Date: 2026-08-03
Branch: `feat/single-repeater-coverage`
Base HEAD: `8d218c1`
## What changed
### HopReach one-shot node mode
- Added `--node <64-hex-public-key>` to `cmd/hopreach`, with normalized key validation and a dispatch path separate from the global run.
- Added machine-readable one-shot results for `available`, `busy`, `not_found`, `invalid_coordinates`, `out_of_region`, `stale`, and `failed` outcomes.
- The node run fetches CoreScope repeaters, requires valid in-region coordinates, rejects silent/stale positions, and accepts active/degraded positions with their position status recorded.
- Computes exactly one `propagation.Site` at the configured Standard settings: image width 2000, DEM zoom 11, supersample 1. It uses `RasterProgressiveChunked`; it never passes the production nil whole-region DEM grid to `Raster` and never computes Precision.
- Added the isolated progressive namespace `tiles/nodes/<dataset-id>/<row>-<col>.png` and `checkpoints/nodes/<dataset-id>.json`. The bounded 25-character dataset ID is a deterministic SHA-256-derived value over the normalized full public key, node snapshot, and raster inputs. The full key is also folded into the checkpoint signature.
- Node checkpoint/run identities are separate from global `standard` and `precision`. Node metadata updates replace only the `node_coverage` member of `meta.json`; global `meta.run`, global completion, global progress, and global checkpoints are not modified.
- Added 6-hour skip-if-fresh behavior using `coverage.min_recompute_interval_hours`. Cached metadata is accepted only when the node coordinates/status still match and every advertised tile exists.
- Added a compact node metadata index with computing/available/failed state, position freshness, per-node run/dataset IDs, progress counts, Standard metadata, and failure text. The index is limited to 128 entries and seven days; unreferenced node tile/checkpoint datasets are cleaned after successful publication.
- Global runs preserve the existing node index while updating normal network metadata.
### Tile serving
- Added a narrow Nginx regex/alias for exactly `/rf-coverage/tiles/nodes/n<24-lowercase-hex>/<row>-<col>.png`.
- Kept the exact `meta.json` and `progress.json` aliases, existing Standard/Precision allowlist, and `/rf-coverage/` 404 fallback unchanged.
### Frontend
- Extended RF metadata types and tile validation for the bounded node dataset contract.
- Added derived `pending`, `available`, `stale`, and `error` states. Degraded node positions are displayed as stale even when a raster is present.
- Added **Show RF coverage** beside **Show LOS** for non-redacted repeaters with a full public key, including the current dataset state.
- Wired selected-node RF state through `MapLibreMap` and `App`; selecting a repeater swaps the existing generic raster protocol/source to that node's Standard tile set.
- Added clear **Back to network coverage** / **Network coverage** actions and node-specific status/legend text.
- Added tests for node state derivation and the node tile path contract.
### Backend
- Not changed. The optional single-node lookup was unnecessary because HopReach fetches and validates the authoritative CoreScope repeater record, while the browser already has the public key and coordinates needed to expose the map action.
## Quality gates
All required gates passed on the final worktree:
- `docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/work" -w /work/third_party/hopreach golang:1.25.7-bookworm go test ./...` — PASS
- `docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/work" -w /work/third_party/hopreach golang:1.25.7-bookworm go vet ./...` — PASS
- `frontend/: npx tsc --noEmit` — PASS
- `frontend/: npm test` — PASS (57 tests)
- `frontend/: npm run lint:css` — PASS
- `frontend/: npm run build` — PASS (Vite emitted only its existing large-chunk advisory)
- `git diff --check` — PASS
The backend gate was not required because no backend file was touched.
## Deviations and constraints
- No public or unauthenticated browser compute endpoint was added. The popup selects and polls node metadata; the on-demand execution primitive is `hopreach --node <pubkey>`, intended for the plan's internal/operator-side submitter. This follows the review's requirement not to turn a browser click into an unbounded unauthenticated compute trigger.
- The seven-day/128-entry retention limits are fixed implementation bounds because the current configuration schema only defines the six-hour recompute interval.
- No live node raster smoke test was run: the task explicitly prohibited running the production HopReach binary or touching the running container and `/data` output. Computation/publication behavior is covered by Go unit tests plus the full Go test/vet gates.
- No image, source-offer, digest pin, deployment, service restart, or benchmark artifact was changed. Those remain release/deployment work after the fork is published under the documented licensing and immutable-image process.
+131
View File
@@ -0,0 +1,131 @@
# UKMesh Pathing Algorithm — Accuracy Improvement Report
**Date:** 2026-08-02 · **Scope:** read-only analysis (no code changes) · **Author:** Hermes
**Codebase:** `~/meshcore-analytics` on 192.168.100.105 (backend is TypeScript)
---
## 1. Executive summary
The pathing stack is already sophisticated: a Viterbi HMM decoder over prefix-matched candidate trellises, gated by an ITM radio-viability model, boosted by four families of learned priors, an ML model (LightGBM, calibrated, evolutionary-tuned) for 1-byte hash disambiguation, and multi-observer stitching. The fundamental accuracy limit is intrinsic — hops are **truncated Ed25519 pubkey prefixes (13 bytes)**, so a 1-byte (2-hex-char) hash can match dozens of nodes.
The biggest accuracy wins are not in the decoder math but in:
1. **Measurement** — the calibration/evaluation loop measures a self-referential proxy on an easy subset, so the confidence numbers (and the UI colour bands) are not proven against true path accuracy.
2. **Silent hard-gates** — links flagged ITM-impossible are excluded even when the network *actually observed* them; real paths get dropped and nobody audits the contradiction.
3. **Multi-observer evidence** — each observer view is solved independently then stitched; a joint inference (position-consistency voting) would extract far more signal from ambiguous hashes.
4. **Calibration granularity** — a single global scale/bias is applied to all evidence classes; unique-3-byte matches and pure-geography guesses get the same calibration.
---
## 2. How the algorithm works today
### 2.1 Wire format (the hard constraint)
- `path_len` byte: upper 2 bits = hash size mode (1/2/3 bytes per hop), lower 6 bits = hop count.
- Path = `hop_count × hash_size` bytes, each hop being the first N bytes of the node's Ed25519 public key. No coordinates in the path. (`multipath.md`)
### 2.2 Two resolvers
| | Lazy (`backend/src/path-lazy/lazyResolver.ts`) | Beta (`backend/src/path-beta/resolver.ts`) |
|---|---|---|
| Role | older/lightweight path view, feed fallback | main live-paths resolver (3D arcs, multi-observer) |
| Candidate source | prefix `LIKE` lookup on nodes, bounded by observer bounding box | context trellis per hop, `MAX_TRELLIS_CANDIDATES_PER_HOP` cap |
| Core | per-position scoring + known-links + anchors | **Viterbi HMM** over trellis (`O(K²·N)`), global optimum, DP + backtrack |
| Hard gate | known-links set (node_links observed ≥2) | **ITM linkPairs only** (`linkPairs` = itm_viable OR force_viable) — anything else is `-Infinity` |
| Priors | prefix/transition/edge/motif + ML (≥0.80) | same families + ML + observer anchors + sticky anchors + affinity |
| Confidence | — | mean hop conf × resolved ratio → calibrated (scale/bias) |
### 2.3 Evidence tiers in the beta decoder (`resolver.ts`)
- **Confirmed tier**: link in `observedLinkPairs` (real traffic) → confidence floor from observation counts (`strongConfirmedFloor`, `confirmedLinkConfidence`).
- **ITM-viable tier**: valid link, no observations → `rawConf` capped at `nonLinkCap` (0.410.62) unless multibyte floor wins.
- **Unique multibyte floors**: 4-char single-match ≥ 0.93, 6-char ≥ 0.985 — near-definitive.
- **Ambiguity penalties**: `localPrefixAmbiguityPenalty` (distance/proximity-weighted peers, 2-hop graph check) + linear `(matches1)·0.01`.
- **Anchors**: direct observer anchors (hop_count → position), sticky anchors with age decay, terminal-collision guard, observer hop hints with typical-hop normalisation.
- **Affinity**: packet-derived neighbour affinity (14-day window, count/observer/SNR/recency, half-life 7 days) + mutual-neighbour Jaccard.
- **ML**: `mlPrefixScores` — LightGBM + `CalibratedClassifierCV` + evolutionary param search, trained only on gold (uniquely-resolved multibyte) data degraded to 1-byte prefixes, no feedback loop. (`ml-path-learner/worker.py`)
### 2.4 Priors + calibration pipeline (`path-learning/rebuild.ts`, hourly via `workers/path-learning.ts`)
- Prefix/transition priors (receiver-region keyed, `MODEL_LIMIT` capped), edge priors (6h hour-bucketed, reliability/directionality/recency/pathloss scored), motif-2/3 priors.
- Calibration: `top1Accuracy = successPackets / evaluatedPackets`, success = ≥60% of a packet's *verified* edges appear in `confirmedLinks`; `scale = clamp(top1/meanConf, 0.551.7)`, `bias = clamp(top1 meanConf·scale, ±0.2)`, `recommendedThreshold = 0.35 + (1top1)·0.2`.
### 2.5 Multi-observer (`resolveMultiObserverBetaPath`)
- Canonical observation per observer (richest path), each solved independently, shared-prefix stitching + trim, per-observer result list with explanation.
---
## 3. Accuracy gaps found
### A. Measurement & calibration (biggest lever)
- **A1 — Self-referential calibration metric.** `successPackets` counts packets whose verified edges agree with `confirmedLinks` (`rebuild.ts:597-603`) — a proxy, not true path accuracy, and only evaluated on hops that were *uniquely resolvable* (the easy subset). The global scale/bias is therefore optimistic and the UI bands (cyan ≥0.75 / amber 0.40.75 / red <0.4) are not validated against ground truth.
- **A2 — Single global calibration for all evidence classes.** A unique 6-char hash (floor 0.985) and a pure-geography fallback hop share the same scale/bias. Per-class calibration is needed: unique-multibyte / ML-dominant / prior-backed / observed-link / geometry-only.
- **A3 — No held-out gold set.** Training data (unique multibyte resolutions) is the same population used for evaluation — leakage risk; `ml-path-learner` correctly avoids feedback loops, but the calibration path does not hold out anything.
### B. Hard gates that silently drop real paths
- **B1 — ITM-impossible is absolute.** `hopScore` returns `-Infinity` when a link isn't in `linkPairs`, and `isImpossibleLink` hard-blocks `pathLoss ≥ impossibleLinkPathlossDb` (`resolver.ts:1126-1129`, `fallback.ts:15-17`) **even when the link has real observed traffic**. If ITM/SRTM is wrong, correct paths vanish with no audit trail. (This class of issue also explains the old UI's "matchedHops excludes ambiguous hops" behaviour.)
- **B2 — No contradiction feedback loop.** No query/log for "observed_count > 0 but ITM says impossible" — that data is the cheapest ground truth for retuning the physical model.
### C. Multi-observer evidence under-used
- **C1 — Per-observer independent solving + stitching** (`resolver.ts:2324+`). A position-consistency matrix (which hash→candidate appears at position P across *all* observers) is only partially used (`globalDirectAnchors` in the lazy resolver). Joint inference would let ambiguous hops be resolved by cross-observer agreement — the single biggest accuracy win available.
- **C2 — hop_count only used for anchors.** The majority hop count across observers is a strong prior on path length that the Viterbi never sees (no length penalty/constraint). It also feeds the UI "remaining hops" figure.
- **C3 — Hash-size mixing.** Observers with 1 vs 2 vs 3-byte paths for the same packet fragment groups; cross-size alignment (a 3-byte hash sharing its first 2 chars with a 1-byte hash) could unify evidence.
### D. Evidence signals not used in the decoder
- **D1 — SNR.** Packets carry SNR; it's used in affinity (`avg_snr`) but not per-hop in `hopScore`. An SNR gradient (candidates closer to the observer should hear it louder) would disambiguate local peers cheaply.
- **D2 — Node role.** `role` is used only for the observer self-prefix guard (`rx.role === 2`, `resolver.ts:1137`). Repeater-vs-end-node role could weight candidates.
- **D3 — Prefix priors lack recency decay.** Edges get `recencyScore`; prefix/transition counts (`rebuild.ts`) are window counts without decay — a node that moved or died keeps a stale prior. Also no day-of-week bucketing (edges/motifs have 6h buckets; prefixes don't).
### E. Prior saturation & weight hygiene
- **E1 — `MODEL_LIMIT` global cap.** Popular prefixes crowd out rare-but-informative priors. Per-prefix top-N would preserve signal.
- **E2 — Duplicated inline weights.** `scoring.ts` header admits `resolver.ts` predates it and still has inline weights — convergence was deferred. The ML learner runs an evolutionary search over its own params; the decoder weights (which are *more* consequential) are hand-tuned with no automated search or A/B.
- **E3 — `ABLATE_LEAKY_PRIORS` exists but no systematic ablation record.** No CI harness measures which evidence family actually moves accuracy.
---
## 4. Recommended improvements (prioritised)
### Phase 1 — Measure honestly (foundation, ~no decoder changes)
1. **Gold-standard evaluation set**: hold out uniquely-resolved 3-byte multibyte packets (say last 14 days, re-sampled weekly). Re-resolve their degraded 1-byte forms; compute **true top-1 path accuracy**, segment accuracy, ECE + Brier.
2. **Per-class reliability curves**: fit calibration (isotonic or platt per class) for: unique-multibyte / ML-dominant / prior-backed / observed-link / geometry-only. Publish reliability diagram + ECE on the stats page.
3. **Fix the calibration success metric** (`rebuild.ts:603`): replace the ≥0.6 proxy with continuous expected-calibration-error on the gold set.
### Phase 2 — Stop dropping real paths
4. **Observed-override tier**: links with `observed_count ≥ N` (e.g. 3) bypass the ITM-impossible hard block (keep a distinct low-confidence marker rather than `-Infinity`).
5. **Contradiction audit worker**: hourly query for observed-but-ITM-impossible pairs; log to a table + surface in health; use the corpus to retune `impossibleLinkPathlossDb` and ITM parameters.
### Phase 3 — Joint multi-observer inference (biggest accuracy win)
6. **Position-consistency voting**: build a `(position, hash) → candidate frequency` matrix across all observers of a packet; use cross-observer agreement as a new trellis-column prior (weight ~ unique-multibyte evidence). This directly attacks 1-byte ambiguity.
7. **Hop-count prior**: soft length constraint in the Viterbi from the majority observer hop count (penalty when decoded length deviates) + expose expected-vs-decoded in the explanation payload.
8. **Cross-size hash alignment** for mixed 1/2/3-byte observer groups.
### Phase 4 — Cheaper decoder-side wins
9. **SNR shaping**: add per-hop SNR-gradient term to `hopScore` (observer-relative).
10. **Recency decay for prefix/transition priors**; optional day-of-week buckets.
11. **Per-prefix top-N** instead of global `MODEL_LIMIT`.
12. **Role-weighted candidates** using the existing `role` field.
### Phase 5 — Keep it from regressing
13. **Converge weights into `scoring.ts`** and make them config-driven; run the ML learner's evolutionary search over decoder weights too (or a simpler random search), with the Phase-1 gold set as the objective.
14. **CI accuracy regression gate**: `path-lazy/evaluate.ts` already exists — wire a fixed gold corpus into CI; any resolver change must not regress top-1 accuracy/ECE beyond a threshold; record ablations (prefix/edge/ML/affinity on/off) to `docs/pathing-ablations.md`.
---
## 5. Verification plan
1. Build the gold set from the DB (unique 3-byte packets, held out).
2. Baseline: current resolver top-1 path accuracy + ECE per class on the gold set (numbers to be recorded in `path_model_calibration`-style table).
3. Apply changes one phase at a time; A/B on the same gold set; publish delta per phase.
4. Live check: monitor `top1`, `mean_pred_confidence`, `recommended_threshold` from the existing calibration row over a week; confirm the UI colour bands now match measured reliability.
5. Spot-check live paths against the map for a sample of packets (manual/hardware evidence step).
---
## 6. Files referenced
- `backend/src/path-beta/resolver.ts` (Viterbi decoder, multi-observer, context)
- `backend/src/path-beta/fallback.ts`, `affinity.ts`, `geometry.ts`, `constants.ts`
- `backend/src/path-shared/scoring.ts` (shared weights — partially converged)
- `backend/src/path-lazy/lazyResolver.ts`, `path-lazy/evaluate.ts`
- `backend/src/path-learning/rebuild.ts` (priors + calibration), `workers/path-learning.ts`
- `backend/src/pathing/pathingService.ts` (+ `pathingRepository.ts`, `pathingPublicDto.ts`)
- `backend/src/platform/config/pathing.ts` (tunables)
- `ml-path-learner/worker.py` (LightGBM 1-byte disambiguator)
- `multipath.md` (wire-format notes)
+174
View File
@@ -0,0 +1,174 @@
# PLAN: Single-Repeater RF Coverage on UKMesh
Status: **reviewed (REVIEW-single-repeater-coverage.md, commit 4ec5fd1) + benchmarked (2026-08-03) — standard tier only per Ben**
Date: 2026-08-02
Author: Hermes (assessment from live inspection of 192.168.100.105)
Canonical review output: `REVIEW-single-repeater-coverage.md` (same directory)
## 1. Goal
Expose an individual repeater's RF coverage footprint on the UKMesh app, reusing
the HopReach propagation calculations already deployed on 192.168.100.105 —
without degrading the existing whole-network map.
## 2. Current state (verified live 2026-08-02)
- **HopReach** (Go, vendored `third_party/hopreach`, public fork `gadgethd/hopreach`
@ tag `v0.1.32-ukmesh.3`, commit 0230702be70a2729c5acc5640401f56ab9d65fd4) runs
as compose service `hopreach` (4 CPU / 8 GiB, CPU mode), nightly cron `17 2 * * *`.
- Backend serves a private `/hopreach` compatibility router (internal-only, not
proxied by Nginx): paginated nodes (role=2 repeaters with coordinates, ukmesh
network) + observed `node_links` (calibration evidence).
- **The raster model is best-server**: `internal/propagation/propagation.go`
`marginsRowIndexed` computes margins for every candidate site per pixel but
keeps ONLY the max (`bestMargin`) — per-transmitter margins are discarded.
- Output published to volume `rf_coverage_data`:
`/data/output/meta.json`, `progress.json`, `tiles/standard/{r}-{c}.png`
(6 tiles, 2000px, DEM zoom 11), `tiles/precision/{r}-{c}.png` (54 tiles,
6000px, DEM zoom 13, 2x supersample). Precision was mid-compute at inspection.
Live meta: 7,071 repeaters fetched, 4,380 in region (416 active / 592 degraded /
3372 silent), max_search_range_km 77.53, freq 868 MHz.
- Frontend: `RfCoverageOverlay.tsx` is fully **data-driven** — it renders any
tile set described in `meta.json` (`coverage[<tier>].tiles` = {image, bounds})
via `/rf-coverage/<path>?revision=…`. Adding a new tile set = meta extension +
a selector; the raster protocol is generic.
## 3. Key finding
**The current output cannot yield per-repeater coverage** (per-transmitter data is
maxed away at raster time; PNG tiles are merged best-server).
**But the fork already contains the exact machinery needed**: `cmd/hopreach/run.go`
has a per-**scope** coverage path (~line 720): it filters repeaters to a subset,
calls `coverage.Raster(engine, grid, scopeSites, scopeBounds, …)` with the same
engine/DEM-cache/model, writes `coverage-scope-<slug>-*.png` tiles, and registers
them in `meta.json` (`ScopeCoverage`). **A single repeater is a subset of one.**
It is dormant in production: `rf-coverage/config.ukmesh.yaml` has
`scope_observation.enabled: false` (live meta.json contains no scope tier).
## 4. Recommended approach (A): per-repeater tiles from the HopReach fork
> **DECISION (Ben, 2026-08-03): per-repeater coverage uses the STANDARD tier ONLY.**
> Precision is rejected for per-node use — measured 4 min 52 s / 36 tiles vs 6 s / 4 tiles
> (see §4.5 benchmark). Standard-only keeps on-demand compute trivial and the UI snappy.
> The global whole-network map keeps both tiers exactly as today.
>
> **DECISION (Ben, 2026-08-03): ON-DEMAND ONLY.** A repeater's coverage is computed
> only when that specific repeater is requested — never precomputed for every repeater,
> never a nightly batch. Repeat requests for the same node within the freshness window
> are served from cache. meta.json grows only by requested nodes (bounded by LRU/TTL).
### 4.1 Calculator (third_party/hopreach, Go)
- Clone the per-scope loop pattern (run.go `computing_scope_coverage` block) into a
new **per-repeater** path:
- Input: a single repeater public key (64-hex) → find its node in the fetched
node set (`selectRepeaters` output) → `sites = [one Site]`.
- Bounds: `coverage.RasterBounds([]Point{node}, rangeKm)` (same as scope path).
- Raster: `coverage.Raster(engine, grid, oneSite, bounds, imageWidth, params, maxAlpha, progress)`.
- Tiles: `coverage.WriteTiles(outputDir, "coverage-node-<short-id>", raster, bounds)`.
- Meta: register `meta.node_coverage[pubkey] = buildCoverageMeta(tiles, rangeKm, cfg, note)`
(mirror `ScopeCoverage` pattern).
- **Trigger mode — DECIDED: on-demand only.** Chosen path (per REVIEW
recommendation): `--node <pubkey>` one-shot CLI primitive as the execution
engine, invoked by a small loopback-only internal admin/queue endpoint
(hopreach-shareapi `/admin` pattern) or an operator-side submitter. The UI
click → internal endpoint → queued job → status poll. Reuses lock.go +
`min_recompute_interval_hours: 6` skip-if-fresh: a second request for the same
node inside the window returns the cached tiles, not a recompute.
- **NO precomputation**: no nightly batch, no compute-all-repeaters path, no
warming. Only nodes actually requested are ever computed (bounded set; see
meta growth policy §7 Q5).
- **Tier scope: STANDARD only** (2000 px, DEM zoom 11) — the precision tier must
NOT be computed for node jobs (see decision banner). The node path therefore
needs far fewer DEM tiles (zoom 11 only), less memory, and no supersampling.
- Terrain: reuse the DEM cache (`/data/dem-cache`, zoom-11 tiles are fully warm
UK-wide — 67 x-columns; zoom 13 is NOT needed for node jobs).
### 4.2 Serving (no changes expected)
- Tiles land in the same `rf_coverage_data` volume; Nginx already serves
`/rf-coverage/` read-only (meta/progress/numeric tile paths).
- Verify the nginx app config allows arbitrary subpaths under `/rf-coverage/`
(it currently serves `tiles/standard|precision/*.png` — check the exact location
rule during review; numeric `{row}-{col}.png` pattern may need widening).
### 4.3 Frontend (frontend/src)
- `hooks/useRfCoverage.ts`: extend `RfCoverageMeta` with
`node_coverage?: Record<string, RfCoverageTier>`.
- Node popup (map page): add "Show RF coverage" action for repeaters.
- `components/Map/RfCoverageOverlay.tsx`: select the node tile set instead of the
global tier; existing layer ordering (below roads/labels/nodes) and tier
availability states are reusable. Tile URL helper is generic
(`rfCoverageTileUrl`).
- Optional: status/legend text mirroring the existing legend/model details.
### 4.4 Sizing (VALIDATED by benchmark, 2026-08-03)
- Single repeater, **standard tier only**: measured **~6 s** (4 tiles, zoom 11)
on 4 CPU / 8 GiB with warm DEM cache, running concurrently with the nightly
UK precision job. On-demand compute cost is negligible.
- Precision for one repeater (measured, for reference only): 4 min 52 s / 36
tiles — ~50x standard. Rejected for per-node use (§4 decision).
- Memory: standard tier needs zoom-11 DEM only; no supersampling; well under
the 8 GiB container limit.
### 4.5 Benchmark evidence (2026-08-03, Berwick 55.7708,-2.0058, active repeater)
- Method: production hopreach binary (deployed digest), mock node feed with
exactly 1 in-region repeater, prod-identical limits (4 CPU / 8 GiB /
GOMAXPROCS=4), warm DEM cache, run concurrently with prod nightly run.
- Results: standard 6 s (4 tiles, gen 00:15:38Z); precision 4 min 52 s
(36 tiles, gen 00:20:30Z); total wall 4 min 58 s. Engine mid-run ETA (~5.3
min) matched. Prod nightly run unaffected (59.5% → 60.3% during test).
- Artefacts on VM: `~/hopreach-bench-output/` (tiles+meta), `~/bench-cache/`
(warm tile copy). Cold areas add only S3 download time (~65 KB/tile).
- Implications: a "show coverage" click can serve standard almost instantly;
precision would be a poor trade for per-node views.
## 5. Alternatives
- **B — resurrect old per-node API**: `node_coverage` table still holds 4,578
per-node polygons (red/amber/green bands; last written 2026-08-02 15:53 UTC).
`/api/coverage/:nodeId` is tombstoned 410. Zero compute, but: old model that
HopReach replaced, frozen/stale data, and `docs/rf-coverage-rollout.md`
explicitly forbids the live app reading `node_coverage` (rollback material,
one-release window, removal planned). **Rejected as primary option.**
- **C — derive from merged tiles**: impossible (per-transmitter data destroyed).
## 6. Release constraints (from docs/rf-coverage-rollout.md — MUST hold)
- Any fork change requires a new public tag (e.g. `v0.1.32-ukmesh.4`), immutably
tagged on `gadgethd/hopreach`, referenced by SOURCE-OFFER.md; AGPL-3.0 +
Commons Clause publication rules apply.
- `scripts/benchmark-hopreach.sh` release gate + `rf-coverage/BENCHMARKS.md`
update; digest-pinned images (`.env` pins); do NOT enable calibration or reduce
fidelity; keep `node_coverage` + old images for the rollback window.
- Backend/app rollback safety: per-repeater additions must not change the global
tier contract.
## 7. Open questions for review
> **Status 2026-08-03:** code-level questions (12) are answered with file:line
> evidence in `REVIEW-single-repeater-coverage.md` (commit 4ec5fd1) — that review
> MUST be folded into the implementation (nil-grid chunked path, WriteTiles/nginx
> path contract, per-node checkpoint identity, freshness, meta size). The compute
> question is resolved by the §4.5 benchmark: standard tier only, ~6 s per node.
1. Exact touch points (file:line) for the per-repeater loop; does
`progressiveSignature`/input-signature invalidation interact safely with
per-node runs (site list changes)?
2. Nginx location rule for `/rf-coverage/` — does it need widening for
`coverage-node-*` tile names?
3. On-demand trigger: CLI flag vs HTTP endpoint vs queue — which fits the
container's lock/schedule model with least risk?
**DECIDED**: `--node` CLI primitive + loopback-only internal queue endpoint
(per REVIEW's hybrid recommendation); no public/browser compute trigger.
4. Should per-repeater runs be gated on node freshness (silent repeaters have
stale positions) or computed for any in-region repeater?
5. meta.json growth: thousands of `node_coverage` entries — acceptable? (416
active now; only compute on demand/by request.)
6. Frontend: where exactly is the node popup (which component) to host the
"Show RF coverage" action?
+197
View File
@@ -0,0 +1,197 @@
# UKMesh Pathing Integration Plan — One Overarching Resolver
**Date:** 2026-08-03 · **Author:** Hermes · **Status:** PLAN ONLY (no code changed)
**Basis:** production code read (backend/src), experiment results (~/multibyte-exp,
REPORT-pathing-experiment.md), prior report PLAN-pathing-accuracy.md.
---
## 1. What exists today (verified in code)
All path resolution flows through **one worker pool** (`path-beta/resolveWorker.ts`)
with three job types, but **two different decoders** behind them:
| job type | entry point | decoder | evidence/weights |
|---|---|---|---|
| `resolveLazy` (API `/pathing` packet path — post-settled full route) | `path-lazy/lazyResolver.ts` (818 ln) | **modern Viterbi** (trellis, marginals→ambiguity, NULL_BASELINE unresolved candidate, observer anchors, observer bounding box) | `path-shared/scoring.ts` (single source of truth) + rebuild priors + ML prefix scores + node_links |
| `resolve` (single-observer view) | `path-beta/resolver.ts` (2913 ln) | **legacy single-observer core** (ln 8161441): greedy-ish permutation scoring, confidence calibration, fallback modes | **inline weights** (0 refs to SCORE) — explicitly flagged in scoring.ts as "predates this module… converge it here when convenient" |
| `resolveMulti` (multi-observer overlay) | `path-beta/resolver.ts` (ln 23242420) | legacy combine: observers solved independently, overlays stitched | inline weights |
Supporting infra, already shared or parallel:
- `path-shared/scoring.ts` — MAX_HOP_KM, SCORE weights, ML_DOMINANT_THRESHOLD, NULL_BASELINE, AMBIG_DELTA, MAX_COL, prior key formats. **Used by lazy only.**
- `path-learning/rebuild.ts` (878 ln) — rebuilds prefix/transition/edge/motif priors + ML prefix scores from packet history.
- `path-lazy/evaluate.ts` (205 ln) — **gold-packet accuracy harness**: multibyte packets with uniquely-resolving hashes → degrade to 1-byte prefixes → compare legacy greedy vs Viterbi; stratifies by prior support to expose leakage. This is the report's Phase-1 eval gate, already in-repo.
- `path-lazy/lazyResolverLegacy.ts` — old greedy (kept for the harness comparison).
## 2. The goal
**One overarching algorithm**: a single Viterbi decode core + one evidence model +
one weights table, powering all three job types. Delete the legacy inline-weight
resolver. One accuracy harness gates everything.
## 3. What the experiment contributes (new signals, validated)
From REPORT-pathing-experiment.md (gold set 75,625 routes; holdout test 11,821):
| signal | gain (test, 1-byte task) | where it plugs in |
|---|---|---|
| **Corridor interpolation** — hop i anchored at i/(N-1) along src→rx line | +9 pp unseen corridors (90.4 vs 81.3) | new emission term |
| **Position-conditional transitions** — spine stability (pos, from)→to | small overall, big on long routes | new transition term |
| **ITM viability bonus** (node_links.itm_viable, 20,363 pairs) | +0.4 pp controlled A/B | transition bonus (bonus-only, never hard-gate) |
| **Tuned weights** (coord-descent: pos_freq 1.2, trans 2.0, obs_dist 80…) | 97.21 → 97.27% overall | scoring.ts values (re-tuned on prod harness) |
| Margin-based per-hop confidence | — | reuse lazy's marginals; feed the beta confidence/3D-arc colouring |
Negative results to NOT repeat: ML candidate scorer (loses to hand-built priors),
elevation transition penalty (regressed), bidirectional decode (neutral),
joint longest-route-fix decode (error propagation — margin-gated version only).
## 4. Target architecture
```
┌─────────────────────────────────────────────┐
│ path-core/decoder.ts (NEW) │
│ one Viterbi (max-product) trellis decode │
│ emissions + transitions + NULL_BASELINE │
│ + per-position margins (confidence) │
└──────────────┬──────────────────────────────┘
│ evidence in, path out
┌────────────────────────┼─────────────────────────┐
│ │ │
┌───────▼───────┐ ┌─────────▼─────────┐ ┌────────▼────────┐
│ lazyResolver │ │ beta resolve │ │ beta resolveMulti│
│ (thin wrapper │ │ (thin wrapper │ │ (wrapper: group │
│ → canonical │ │ → single-observer│ │ observers → │
│ group decode)│ │ decode + DTO) │ │ canonical │
│ │ │ │ │ decode + DTO) │
└───────┬───────┘ └─────────┬─────────┘ └────────┬────────┘
│ │ │
└──────────── resolveWorker (unchanged: 3 job types, same DTO contracts)
```
Key decisions:
1. **One decoder, three thin wrappers.** DTO contracts (`pathingPublicDto.ts`,
`api/contracts.ts`) and the worker pool stay exactly as-is; only the decode
internals unify. Zero API surface change.
2. **The lazy resolver is the base** — it is already the modern Viterbi with
shared weights. The legacy path-beta core is deleted after its accuracy is
matched or beaten by the shared core on the harness (per-job gate, §6).
3. **Multi-observer = canonical grouping.** The lazy resolver already groups
prefix-compatible observers and decodes once (its `groupByPathHashes`).
`resolveMulti` adopts this instead of the "solve-then-stitch" legacy combine —
same packet evidence, one coherent chain, no permutation explosion.
Preserve the beta DTO fields (permutationCount etc. can be derived or
deprecated with the frontend's blessing).
4. **All weights in scoring.ts.** Move the beta inline weights out; the SCORE
table becomes the only tuning surface, re-valued by the harness (§6).
5. **New priors from rebuild.ts** (extend, don't fork):
- `path_corridor_priors` — (src, rx, pos) → node counts (corridor emission)
- `path_pos_transition_priors` — (pos, from) → to counts (spine transitions)
- position-aware prefix counts (`pos_freq`) if rebuild doesn't already carry it
- ITM viability comes read-only from `node_links.itm_viable` (already computed
by the link worker; no new compute)
Lifecycle identical to existing priors (rebuild cadence, generation stamp,
cache invalidation via visibility generation).
6. **Confidence:** the shared decoder emits per-position margins; lazy's
AMBIG_DELTA semantics + beta's calibrated confidence/fallback thresholds both
derive from the same margins. The 3D-arc colouring keeps its current bands.
## 5. Implementation phases (each gated, deployable independently)
- **Phase 0 — Baseline (no code):** run `evaluate.ts` on prod data; capture
route/hop accuracy for legacy greedy, lazy Viterbi, and (add harness support
for) the beta single + multi jobs. These numbers are the merge gate.
- **Phase 1 — Extract `path-core/decoder.ts`:** move the lazy Viterbi body out
of lazyResolver into path-core with an evidence-interface (priors passed in).
lazyResolver becomes a thin wrapper. Harness must be bit-identical.
- **Phase 2 — Port single-observer `resolve`:** route the beta single-observer
job through the shared decoder (observer anchor emission gives it the same
view it has today). Add harness coverage for the beta DTO path. **Delete the
legacy single-observer core** (resolver.ts shrinks to the multi-observer
surface) once accuracy ≥ baseline.
- **Phase 3 — Port `resolveMulti`:** canonical grouping (lazy-style) + one
decode; project per-observer overlays. Compare against legacy combine on the
harness; keep DTO fields or migrate frontend in the same deploy.
- **Phase 4 — Enable new signals (behind env flags, one at a time):**
corridor priors → pos transitions → ITM bonus. Each lands with its harness
delta on the leakage-resistant stratifications (unseen corridors, 13+ hops,
prior-support buckets). No flag ships enabled without a measured gain.
- **Phase 5 — Re-tune weights:** run the coord-descent tuner against
`evaluate.ts`'s gold set (real hash sizes — do NOT copy experiment weights
blindly; they were tuned on 1-byte-degraded data). Commit final SCORE values.
- **Phase 6 — Confidence unification + cleanup:** margin→confidence mapping
for the 3D arcs; delete `lazyResolverLegacy.ts` if the harness no longer needs
it; docs in scoring.ts; update multipath.md.
## 6. Acceptance criteria (regression gate, CI-able)
**Accuracy reference: the experiment's vit_src numbers (Ben directive
2026-08-03: "trust your results over anything on the site — they are the most
accurate and the newest").** The harness degrades to the same 1-byte task as
the experiment, so the gates are directly comparable:
`npx tsx src/path-lazy/evaluate.ts` (extended to all three job types) must show:
- aggregate route accuracy ≥ **97.27%** / hop ≥ **99.24%** on the holdout-style
stratification (unseen-corridor and 13+ buckets tracked separately, matching
the experiment: ≥ 93% unseen, ≥ 89% 13+);
- shared decoder ≥ legacy decoder on **every** stratification (length buckets,
seen/unseen corridors, prior-support buckets), no single regression > 0.5 pp;
- API DTO parity tests (`pathing.test.ts`, `pathingPublicDto.test.ts`) pass;
- no new endpoints; worker pool untouched; rebuild runtime within budget.
## 7. Risks & mitigations
| risk | mitigation |
|---|---|
| Corridor/pos priors memorize repeated corridors (leakage) | evaluate.ts "supported" stratification + margin-gated usage; cap table size (top-N per key) |
| Prior table size blow-up (corridor_pos ~ src×rx×pos) | caps (MAX keys, top-N per key), same retention as existing priors |
| DTO drift breaking the map frontend | DTO contracts frozen in Phase 1-3; frontend verify on staging; own-eyes screenshot check per Ben's policy |
| Worker thread memory with bigger priors | MAX_COL already caps trellis; priors loaded per-generation with LRU |
| Harness regressions hidden by leakage | all gates use the leakage-resistant stratifications |
| Live-paths visual regression | deploy → verify live-paths + main map manually (screenshots) before merge |
| VM resource load during rebuild/tune | reuse run_capped discipline: 4 cores, nice, memory guard (from the experiment) |
## 8. Effort & sequencing (estimate)
- Phase 0: 0.5 day (harness extension + baseline capture)
- Phase 1: 0.51 day (extract, no behavior change)
- Phase 2: 1 day (port + delete legacy single-observer core)
- Phase 3: 11.5 days (canonical multi-observer + DTO migration decision)
- Phase 4: 0.5 day per signal (flag + harness delta)
- Phase 5: 0.51 day (retune on prod gold set)
- Phase 6: 0.5 day (confidence + cleanup + docs)
Total: ~46 focused days, each phase independently deployable. Can be run as a
MissionDeck wave (agents per phase, local commits, no deploys) with Hermes
verifying the harness gates between phases — or direct, per Ben's preference.
## 10. Ben's decisions (2026-08-03, binding)
1. **All implementation on the ukmesh VM (192.168.100.105)** — nothing local.
Agents run via MissionDeck `codex@ukmesh` backend in git worktrees under
`/home/ben/worktrees/pathing-*` on that host; canonical repo untouched
(worktrees branch from HEAD 8d218c1; the single-repeater-coverage WIP in the
main checkout is NOT touched by this wave).
2. **Fallback red paths: REMOVED.** One set of paths, all blue. The unified
decoder's NULL_BASELINE handles unresolved hops (they stay unrendered or
flagged) — no fallback rendering mode.
3. **resolveMulti: canonical-path projection** (Hermes decision, Ben delegated):
lazy-style prefix-compatible observer grouping + ONE coherent Viterbi decode;
per-observer overlays projected from the canonical path. Kills the legacy
solve-then-stitch + permutation explosion.
4. **Execution: one MissionDeck agent wave** (staged: 2 foundation agents →
3 parallel → 1 integration/tuning agent), as many agents as needed. Hermes
orchestrates, verifies gates between stages, merges; agents commit locally,
never push/deploy/restart services.
5. **Accuracy bar: the experiment's vit_src numbers** (97.27% route / 99.24%
hop test; ≥93% unseen corridors, ≥89% 13+ hops) via the extended harness.
## 11. Wave status (2026-08-03)
- Stage 1 (IN FLIGHT): pathing-core-extract (sol/xhigh) + pathing-eval-harness
(luna/max) on ukmesh worktrees.
- Stage 2 (QUEUED): beta single-observer port (sol/xhigh) | multi-observer
canonical + fallback removal backend (sol/xhigh) | frontend all-blue
(luna/max).
- Stage 3 (QUEUED): new signals (corridor/pos-trans/ITM) in rebuild.ts +
decoder (sol/xhigh), then weight tuning + confidence + cleanup (sol/xhigh).
+121
View File
@@ -0,0 +1,121 @@
# UKMesh Pathing Algorithm Experiment — Final Report
**Date:** 2026-08-03 · **Location:** ~/multibyte-exp/ on the Hermes VM
**Goal:** store all resolvable multibyte routes as ground truth, then build the
most accurate path-reconstruction algorithm possible from 1-byte hop hashes only.
---
## 1. Ground-truth route store (complete)
Source: 1,230,149 multibyte packet observations (30 days) from the ukmesh
TimescaleDB, via the 15432 SSH tunnel.
- **75,625 fully-resolved routes** stored in `pathing-experiment/gold.duckdb`
— every hop uniquely resolvable (4/6-char prefix → exactly one positioned
node), known src + observer, consecutive hops ≤150 km, observer terminal hop
trimmed. 9,175 unique packets · 818 unique nodes · 440 sources · 20 observers.
- Node inventory: 13,244 nodes (10.5k with coords) + 19,118 nodes ever heard
as rx/src across all 5 months of history + 28,613 distinct path prefixes.
- Split by packet (zero leakage): train 52,452 / val 11,352 / test 11,821 routes.
## 2. Task definition
Each hop's *true* node is known (gold). The algorithm sees only:
the **first byte (2 hex chars)** of each hop's hash, src node, observer node,
and hop count. Candidate pool = every positioned node matching that prefix
(full universe, avg **37.3 candidates/hop**, all 256 prefixes occur).
## 3. Algorithms compared (final holdout test, 11,821 routes)
| algorithm | route acc | hop acc |
|---|---|---|
| random (floor) | 0.2% | 2.8% |
| greedy nearest-chain | 22.6% | 43.7% |
| greedy best-of-both-directions | 25.7% | 49.4% |
| prefix frequency argmax | 67.4% | 88.9% |
| positional frequency argmax | 71.2% | 90.2% |
| Viterbi + freq priors | 96.9% | 99.2% |
| Viterbi + observer shaping | 97.0% | 99.2% |
| **vit_src — Viterbi + freq + corridor interpolation + ITM (WINNER)** | **97.27%** | **99.24%** |
| Viterbi bidirectional | 96.8% | 99.1% |
| Viterbi joint multi-observer | 93.6% | 96.8% |
## 4. Champion design — `vit_src`
Viterbi over the candidate trellis (per-hop candidates from the 1-byte prefix),
with emissions and transitions summed from seven calibrated signals:
**Emissions (per position):**
1. Positional prefix-frequency prior `log(1+count)` ×1.2 — which node usually
sits at this hop position for this prefix (train-derived)
2. Corridor-level position prior ×1.0 — (src, observer, position) frequency
3. Distance-to-observer shaping (÷80) — relay near the observer
4. **Corridor interpolation** (÷55) — hop i should lie near the point
`i/(N-1)` of the way from src to observer along the corridor; the single
most valuable addition for long unseen routes (+9 pp unseen corridors)
5. Multi-observer position anchors ×0.9 (when other observers of the same
packet heard this position directly)
**Transitions:**
6. Learned hop-transition log-probs ×2.0 (global) + ×1.2 (position-conditional)
7. Distance decay (÷40) with hard 150 km hop cap
8. **ITM radio-viability bonus ×0.8** — pairs the production link model deems
radio-viable get a boost (+0.44 pp in controlled A/B; +0.06 pp overall test)
Weights tuned by coordinate descent on a val subsample (baseline 97.50% →
97.93% subsample; 97.21% → 97.27% full test).
## 5. Honest breakdown (test, champion)
| ≤3 hops | =4 | 5-6 | 7-8 | 9-12 | **13+** | seen corridor | **unseen corridor** |
|---|---|---|---|---|---|---|---|
| 99.6% | 98.3% | 96.9% | 94.6% | 90.9% | **89.6%** | 97.3% | **93.3%** |
Per-hop accuracy 99.24%. Remaining errors are almost exclusively **single-hop
1-byte prefix collisions** (two real nodes sharing the first byte, e.g.
`9D1A41` vs `9D924A`) — fundamentally unresolvable without the second byte or
more observers. That is the hard floor of the 1-byte task.
## 6. What was tried and rejected (with evidence)
- **LightGBM candidate scorer** (9.5M samples, elevation features): 97.0% vs
97.4% — the hand-built priors already capture its signal; ML loses on unseen
corridors (86.4% vs 90.0%). Rejected.
- **Elevation transition penalty** |Δelev|: regressed every variant (raw
elevation deltas are the wrong model — hilltop→valley drops are normal; data
only 57% complete). Elevation kept as an ML feature only. Rejected.
- **Bidirectional Viterbi**: neutral-to-negative. Rejected.
- **Joint multi-observer decode** (longest route fixes shared positions):
93.6% — forced fixes propagate rare errors; observer density too low in this
dataset (margin-gated version: 91-94%, still below single-observer). Rejected.
## 7. Files
- `01_build_gold.py` — gold route construction (resumable)
- `02_prepare.py` — train/val/test split + 1-byte degradation + candidate maps
- `03_algorithms.py` — all algorithms, tunable `WEIGHTS`, `run_capped.sh` runner
- `04_analysis.py` — per-length / seen-vs-unseen breakdowns
- `06_tune.py` — coordinate-descent weight tuner
- `pathing-experiment/` — gold.duckdb, exp.duckdb, candidates.pkl, ml models
- `ukmesh-multibyte.duckdb` — raw multibyte packets + nodes + heard + prefixes
- `itm_links.csv` — 20,363 ITM-viable pairs (from production link model)
## 8. Production integration notes
1. Port `vit_src` scoring (emissions 1-5, transitions 6-8) into
`backend/src/path-beta/resolver.ts`; weights are the config surface.
2. The corridor-level prior needs the (src, observer) pair — available in
packet headers; falls back to prefix-position priors for unseen corridors.
3. ITM bonus: fetch from `node_links` (`itm_viable`), refresh with the link
worker; bonus-only (never hard-gate — ITM misses real links).
4. Confidence: use the Viterbi per-position margin (already computed) as the
per-segment confidence for the live-paths colouring.
## 9. Resources (VM crash follow-up)
The 2026-08-03 VM crash coincided with sustained all-8-core LightGBM runs.
Since then every run is capped: `taskset -c 0-3` (4 cores), `nice -n 15`,
single process at a time, and a memory guard that kills the job if free RAM
drops below 2.5 GB. Peak measured during the full test run: ~300 MB / 1 core
per process; VM load stayed < 3. No further crashes.
+193
View File
@@ -0,0 +1,193 @@
# Review: single-repeater RF coverage plan
Review date: 2026-08-02. Repository and running containers were inspected on the UKMesh VM. The review is read-only; no service, container, configuration, or output was changed.
## Verdict
**Revise before implementation.** The plan is directionally sound: a single-repeater raster can reuse HopReach's terrain-aware propagation path, and the existing frontend raster protocol can display an additional tile dataset. The per-scope block is a good mathematical template, but the proposed change is not a mechanical drop-in in the deployed configuration.
Four plan claims need correction before coding:
- In production, progressive mode leaves the whole-region DEM grid nil. The scope path gets a whole grid only because scope observation enables it; a copied `coverage.Raster(..., grid, ...)` call would therefore be unsafe in the current configuration. A node path should use the chunked DEM path or explicitly load a bounded grid.
- `coverage.WriteTiles` writes directly below its `outputDir`. `WriteTiles(cfg.outputDir, "coverage-node-...", ...)` would create `/data/output/coverage-node-...png`, not an Nginx-served `tiles/...` URL.
- Both the checked-in and running Nginx configuration allow only `tiles/standard|precision/<row>-<col>.png`; arbitrary `coverage-node-*` paths are not served.
- The standard-area and timing estimates are not supported by the geometry or live run. The current precision run is still only 27/54 tiles after several hours, so a benchmark is required before promising “tens of minutes.”
The recommendation is to add a bounded, host-only/on-demand node job backed by a `--node` CLI primitive, with a unique node checkpoint/tier identity, explicit freshness/signature rules, and a separate node manifest or tightly bounded metadata index. Do not make the browser a compute trigger and do not add a nightly batch of all repeaters in the first release.
## Validation findings (file:line)
### 1. Per-scope coverage block and the single-site question
The claimed template is present in `third_party/hopreach/cmd/hopreach/run.go:714-775`:
- `run.go:727-731` gates on `scopeObservationEnabled`, then iterates known scopes.
- `run.go:733-743` filters `selected` into `scopeSites` and `scopePoints` using `repeaterInScope`, and skips an empty subset.
- `run.go:744-746` applies the existing freshness check for that scope.
- `run.go:748` calls `coverage.RasterBounds(scopePoints, rangeKm)`.
- `run.go:752` calls `coverage.Raster(engine, grid, scopeSites, scopeBounds, cfg.coverageImageWidth, cfg.propagation, cfg.coverageMaxAlpha, nil)`.
- `run.go:753` calls `coverage.WriteTiles(cfg.outputDir, "coverage-scope-"+scopeSlug(scopeName), scopeRaster, scopeBounds)`.
- `run.go:759-763` builds coverage metadata and assigns it to `m.ScopeCoverage[scopeName]`; `run.go:764-768` writes `meta.json`.
The raster math is therefore mechanically reusable for one selected site: make `scopeSites` and `scopePoints` contain one validated repeater, then use the same bounds, raster, tile, and metadata operations. The production implementation is not mechanically identical, however:
- The scope block is conditional on scope observation, while the requested node mode must work with `scope_observation.enabled: false` (`rf-coverage/config.ukmesh.yaml:23-28`).
- The whole-grid setup is conditional at `run.go:421-437`. With progressive coverage enabled and scope observation/calibration disabled, `needWholeStandardGrid` is false and `grid` remains nil. The CPU/GPU `Raster` path eventually dereferences the grid (`third_party/hopreach/internal/compute/compute.go:156-162,204-205`). The node path must use `RasterProgressiveChunked`/`MarginsChunked` or explicitly load a suitable grid.
- The node path needs its own freshness, checkpoint, metadata, and publication identity; it must not reuse the global `standard` or `precision` checkpoint names.
### 2. Coverage and propagation signatures
The relevant interfaces are:
- `third_party/hopreach/internal/coverage/coverage.go:31-52`: `RasterBounds(points []Point, rangeKm float64) (propagation.Bounds, bool)` computes a padded geographic rectangle from the selected point set.
- `coverage.go:90-102`: `Raster(engine *compute.Engine, grid *demgrid.Grid, sites []propagation.Site, bounds propagation.Bounds, imageWidth int, p propagation.Params, maxAlpha uint8, progress func(done,total int)) *image.NRGBA`.
- `coverage.go:145-171`: `RasterSupersampledChunked` uses the chunked engine path, which is the relevant bounded-memory option for progressive node work.
- `coverage.go:190-197`: `WriteTiles(outputDir, baseName string, img *image.NRGBA, bounds propagation.Bounds) ([]Tile, error)`.
- `coverage.go:197-231`: tiles are named `<baseName>-<row>-<col>.png` and written directly under `outputDir`; this helper does not create `tiles/<tier>`.
The best-server behavior is confirmed in `third_party/hopreach/internal/propagation/propagation.go:161-185` and `187-218`. For each output pixel, the code starts with negative infinity, evaluates candidate sites, and retains only the maximum margin. The row-indexed implementation at `propagation.go:196-215` likewise retains one best margin per pixel. Per-transmitter margins are not preserved, so merged whole-network tiles cannot be inverted into a repeater footprint. A single-site raster is valid, but it means “coverage from this transmitter alone,” not the transmitter's contribution to the merged best-server map.
### 3. Configuration, skip logic, lock, and progressive signatures
The deployed configuration is `rf-coverage/config.ukmesh.yaml:23-28,30-33,37-63,75-81`:
- scope observation is disabled;
- the DEM cache is `/data/dem-cache` and the configured standard DEM zoom is 11;
- progressive output is enabled, with standard width 2000, precision width 6000, precision DEM zoom 13, supersample 2, and publication tile size 1024;
- `min_recompute_interval_hours` is 6 and the schedule is `17 2 * * *`.
The global skip happens before fetching repeaters at `third_party/hopreach/cmd/hopreach/run.go:358-365`: if a complete `meta.json` is younger than the configured interval, the run exits unless `forceRecompute` is set. The CLI exposes only `-force`, `-force-all-tiers`, and `-prepare` (`third_party/hopreach/cmd/hopreach/main.go:34-38`); `-force` bypasses the global interval but does not make a node request exist. Tier freshness is separately checked at `run.go:634-644` and `third_party/hopreach/cmd/hopreach/output.go:170-185`; it is a UTC-day check, not an input/signature check.
The singleton lock is acquired before the run at `main.go:52-60` and implemented as a nonblocking exclusive flock at `third_party/hopreach/cmd/hopreach/lock.go:9-33`. A node invocation must either queue behind a running job or return a visible busy result. Starting a second process and relying on a retry loop would create ambiguous user state and unnecessary pressure on the container.
Progressive identity is more restrictive than the plan implies:
- `third_party/hopreach/internal/coverage/progressive.go:24-24` permits tier names matching `^[a-z][a-z0-9_-]{0,31}$`, so a full 64-character pubkey cannot be used as a progressive tier name.
- `progressive.go:88-104` hashes model, bounds, zoom, image dimensions, supersampling, site list, and propagation parameters in `progressiveSignature`.
- `progressive.go:126-145` resets a checkpoint when the run ID, tier, signature, or completion state changes.
- `progressive.go:223-260` stores checkpoints as `checkpoints/<Tier>.json` and tiles below `tiles/<Tier>`.
The signature correctly notices a changed site list, but it is safe for node jobs only if each node has a unique bounded tier/checkpoint namespace and a stable node-specific run ID. A generic `standard`/`precision` node job would collide with global work. A safe short hash or collision-checked slug may be used for paths; retain the normalized full pubkey as the metadata key. The global run ID and global `run.tiers` must not be overwritten by an independent node job.
### 4. Nginx serving contract, including the running image
The checked-in contract in `nginx.app.conf:75-95` is:
- exact aliases for `/rf-coverage/meta.json` and `/rf-coverage/progress.json` (`nginx.app.conf:78-85`);
- one regex location at `nginx.app.conf:88-92` matching only `/rf-coverage/tiles/(standard|precision)/([0-9]+-[0-9]+\.png)` and aliasing to `/rf-coverage-data/output/tiles/$1/$2`;
- a 404 fallback for other `/rf-coverage/` paths at `nginx.app.conf:93-95`.
I also inspected the active app container with `docker exec meshcore-analytics-app-ukmesh-1 nginx -T`. Its effective configuration has the same standard/precision numeric regex and 404 fallback; there is no image-side exception for node tiles. This was a read-only inspection.
Consequently, the plan's “no changes expected” serving statement is wrong. `coverage-node-*` files are not served, and the current `WriteTiles` call would place them in the wrong directory even before Nginx matching is considered. The implementation needs a bounded path contract, for example `tiles/nodes/<safe-id>/<tier>/<row>-<col>.png`, plus a narrowly scoped Nginx regex and alias. The tile writer/metadata must emit exactly that path. Do not replace the allowlist with a broad wildcard: the current rule intentionally limits path shape and prevents arbitrary file exposure.
### 5. Frontend data flow and popup location
The frontend is data-driven for the existing global tiers:
- `frontend/src/hooks/useRfCoverage.ts:37-57` models `meta.coverage` with only `standard` and `precision`; `useRfCoverage.ts:78-100` validates tile paths and derives available tiers.
- `useRfCoverage.ts:114-161` polls `/rf-coverage/meta.json` and progress every three seconds and uses `no-store` fetches.
- `frontend/src/components/Map/RfCoverageOverlay.tsx:21-43` builds URLs from metadata tile paths and selects `meta.coverage[tier]`; `:45-89` registers one generic raster dataset and displays it.
- `frontend/src/components/Map/rfCoverageRasterProtocol.ts:189-223` accepts arbitrary validated tile lists/bounds, but its public tier typing/max-zoom logic is currently only standard/precision.
The natural popup action belongs in `frontend/src/components/Map/NodePopupContent.tsx:135-159`, alongside the existing repeater “Show LOS” action. The dock and popup props are assembled by `frontend/src/components/Map/MapLibreMap.tsx:1069-1100,1194-1279`, so that component must pass the node key and a coverage callback/state. Global overlay state currently lives in `frontend/src/App.tsx:133-136,192-203,623-628`; it will need a selected node dataset/state and a clear way to return to the global tier. `NodePopupContent` should show the action only for a valid repeater with coordinates, and should expose pending/available/stale/error states rather than implying that a click synchronously computes a raster.
### 6. Backend lookup feasibility
The actual file is `backend/src/api/hopreachCompatibility.ts` (not a top-level `api/hopreachCompatibility.ts`). It already has the required patterns:
- internal-only protection is at `hopreachCompatibility.ts:81-94`;
- 64-hex pubkey validation is at `:149-157`;
- the reusable repeater/coordinate predicate is `:159-173`;
- the existing `GET /api/nodes/:pubkey/reach` route is `:305-326` and validates a single key before querying links.
The router is mounted under `/hopreach` in `backend/src/index.ts:224-231`, and the reach route returns links, not a node record. A direct single-node lookup using the same predicate and a `public_key` equality condition would be straightforward if the calculator needs server-side freshness/coordinates. The browser already has the node feature's public key and coordinates (`frontend/src/hooks/useNodes.ts:21-41`, `frontend/src/components/Map/types.ts:17-35`), so a lookup is not necessary merely to draw a button. The internal `/hopreach` router should not be treated as a public browser compute endpoint.
### 7. Live state
The read-only command `docker exec meshcore-analytics-hopreach-1 cat /data/output/meta.json` showed only `coverage.standard` and `coverage.precision`; there is no `scope_coverage` or node-coverage collection. The live values were:
- standard: 6/6 tiles, state `available`;
- precision: 27/54 tiles, state `computing`;
- run total: 33/60 tiles;
- top-level `complete`: `false`;
- fetched repeaters: 7071; in-region: 4380; active: 416; degraded: 592; silent: 3372;
- max search range: 77.53187284381539 km; frequency: 868 MHz.
The precision timestamp in the metadata is not completion; the run state explicitly says `computing`. The live metadata confirms that node metadata and node tiles do not currently exist, and that precision cost cannot be assumed to be negligible.
## Touch points (exact files + what changes)
The following are the expected implementation touch points; this review does not make those changes.
- `third_party/hopreach/cmd/hopreach/main.go:34-66`: add a validated `--node <64-hex-pubkey>` dispatch path. Keep it distinct from the global run and return a machine-readable busy/not-found/stale result.
- `third_party/hopreach/cmd/hopreach/run.go:358-463,528-610,634-775`: add node selection, coordinate/status validation, bounded site setup, node-specific publication, and node job state. Do not call the global skip path or mutate global tier state for a node request. Use a chunked raster path when the configured run does not load a whole DEM grid.
- `third_party/hopreach/cmd/hopreach/output.go:152-185,252-285,343-419`: define node coverage metadata, normalized-key/short-path identity, per-node freshness, retention, and atomic publication. Avoid making every node job rewrite an unbounded historical map in the main global manifest.
- `third_party/hopreach/internal/coverage/coverage.go:190-231`: either add a safe node-aware tile destination helper or use the progressive tile writer with a node-specific namespace. The existing `WriteTiles` output must not be assumed to satisfy the Nginx path contract.
- `third_party/hopreach/internal/coverage/progressive.go:24-35,88-104,126-145,223-305`: namespace node checkpoints/tiers with a <=32-character safe identifier and include the normalized key, node snapshot, and all raster inputs in the signature. Ensure a node job cannot resume or overwrite global `standard`/`precision` state.
- `third_party/hopreach/cmd/hopreach-shareapi/admin.go:37-91` and `.../main.go:234-238`: if an HTTP trigger is retained, extend the existing loopback-only admin control plane into a queue/coalescer for node jobs. Do not expose it through the public API or frontend without authentication, authorization, rate limits, and durable job status.
- `nginx.app.conf:75-95`: add a narrow node tile location/alias matching the chosen canonical path. Update path validation and metadata URLs together; retain the exact meta/progress aliases and 404 fallback.
- `frontend/src/hooks/useRfCoverage.ts:3-161`: model node manifests/status, node-specific revision values, and stale/pending/error states. Do not make the three-second global poll download thousands of repeated tile arrays indefinitely.
- `frontend/src/components/Map/RfCoverageOverlay.tsx:21-122` and `frontend/src/components/Map/rfCoverageRasterProtocol.ts:189-223`: allow one selected node dataset to replace the global source, keep the fixed source/layer IDs coherent, validate node paths, and support node tier/zoom metadata.
- `frontend/src/components/Map/NodePopupContent.tsx:135-159`, `frontend/src/components/Map/MapLibreMap.tsx:1069-1100,1194-1279`, and `frontend/src/App.tsx:133-136,192-203,623-628`: add the repeater action, pass node identity and callbacks, manage selected-node overlay state, and restore the global coverage view.
- `backend/src/api/hopreachCompatibility.ts:149-173,305-326`: optionally add a single-node details route using the existing key validation and repeater/coordinate predicate. Keep the route internal and do not use it as an unauthenticated compute trigger.
- `third_party/hopreach/Dockerfile:7,29,45-51`, `docker-compose.yml:553-580`, `rf-coverage/SOURCE-OFFER.md:3-17`, `rf-coverage/BENCHMARKS.md:1-15`, and `docs/rf-coverage-rollout.md:7-30,96-117`: update the public fork/tag, source/revision/license labels, benchmark evidence, image digest, and deployment/rollback documentation if the fork is changed. The release process requires immutable digest-pinned artifacts, not a mutable local tag.
## Answers to the 6 open questions
### 1. Exact touch points and signature safety
The exact current template is `run.go:714-775`; the common interfaces are `coverage.go:31-52,90-102,190-231`. The signature machinery is conceptually suitable because it includes the site list and raster inputs, but it is not safe to reuse globally. Give each node job a unique safe namespace, include the normalized pubkey and a node-position/status snapshot in the input identity, and use a node-specific run ID/checkpoint. A changed position or site snapshot must invalidate the node output. Never use the global `standard`/`precision` checkpoint files for a node job.
Also correct the grid assumption: in the deployed progressive configuration, the node path should use chunked DEM loading rather than pass the nil whole-region grid into `Raster`.
### 2. Nginx location rule
Yes, Nginx needs a change. The exact rule at `nginx.app.conf:88-92` accepts only `tiles/standard` or `tiles/precision` and a direct numeric filename. It will reject both `coverage-node-*` in the output root and a node subdirectory.
Use a canonical, bounded path such as `tiles/nodes/<safe-id>/<tier>/<row>-<col>.png`, add an exact regex/alias for it, and make the tile writer and metadata emit that path. A full pubkey should remain a metadata key, not an unvalidated URL path. A short ID must be deterministic and collision-checked; do not use an arbitrary user-provided slug or a broad Nginx wildcard.
### 3. On-demand trigger: CLI flag, HTTP endpoint, or queue
Use a hybrid with clear ownership: `--node` is the execution primitive, and a loopback-only internal admin endpoint or operator command submits a job to a bounded queue. The existing `hopreach-shareapi` endpoint (`admin.go:37-91`) is already a local control-plane pattern, but its current `/admin/recompute` starts a full `-force` run, so it should not be copied as an unbounded node launcher.
The queue must coalesce duplicate requests, expose queued/running/available/failed/busy states, and respect the singleton lock. If the global nightly run owns the lock, a node request should queue or return an explicit retryable busy response. Do not trigger computation directly from an unauthenticated browser request. Do not batch all 416 active repeaters nightly in the first version; the live precision job makes the resource risk too high.
### 4. Freshness and silent repeaters
Gate the default public/current-coverage request to repeaters with valid coordinates that are active or degraded under an explicit age policy. Exclude silent repeaters by default: the live snapshot has 3372 silent repeaters, and their positions may be stale. Degraded nodes may be included with a visible stale-data warning and a configurable maximum position age. An operator-only or explicitly labeled historical mode can permit silent nodes.
Record the status/position timestamp used by the computation in the node metadata and include it in the signature. Do not silently return a fresh-looking tile for a stale position.
### 5. `meta.json` growth
Thousands of full node entries are not a good long-term shape for the current `meta.json`: the frontend downloads the whole document every three seconds (`useRfCoverage.ts:114-161`), and progressive writes already rewrite metadata during a run. A 416-entry on-demand map may be acceptable as a bounded first experiment if it contains compact index data and has a TTL/LRU cap, but it should not grow without bound or retain every historical node forever.
Preferred design: keep global `meta.json` stable and publish a compact node index or per-node manifest under an explicitly served node path. If the first release keeps `node_coverage` in `meta.json`, impose a maximum count/age, publish only available tiers and compact tile references, and test payload size and update frequency. Old node tiles also need a retention/garbage-collection policy so metadata never points at missing files and disk usage cannot grow indefinitely.
### 6. Frontend popup location
Add the action in `frontend/src/components/Map/NodePopupContent.tsx:135-159`, next to “Show LOS” for repeater nodes. Wire it through `MapLibreMap.tsx:1069-1100,1194-1279` and manage selected-node coverage in `App.tsx:133-136,192-203,623-628`. Extend the hook and overlay rather than creating a second raster protocol: `useRfCoverage.ts:37-100` needs node metadata, and `RfCoverageOverlay.tsx:37-89` already registers a generic tile list/bounds dataset.
The button should request or select a node job and show pending/available/stale/error states. It should not assume that a click immediately creates a tile, and it should provide a clear “back to network coverage” action because the overlay currently uses one fixed MapLibre source/layer (`RfCoverageOverlay.tsx:45-89`).
## Risks
- **Wrong terrain execution path.** Passing the nil production grid to `coverage.Raster` can fail. Loading a whole precision grid can exceed the container's memory budget; `third_party/hopreach/internal/compute/chunked.go:15-27` documents roughly 1.1 GB per padded zoom-13 tile at UK latitudes and several-GB whole-region behavior. Use chunked computation and benchmark it.
- **Overlapping global and node jobs.** The flock at `lock.go:9-33` is process-wide. A node job must be queued or explicitly rejected while a global job runs, and must not alter global checkpoints, global `meta.run`, or global completion state.
- **Partial publication.** A progressive node job can expose partial tiles. Publish a node manifest only after its required tier is complete, or make partial state explicit and ensure every listed file exists. Atomic metadata replacement and old-version retention are required for rollback.
- **Nginx/path exposure.** Broadening the location to arbitrary subpaths risks exposing files in the shared output volume. Keep node IDs canonical, bounded, and matched by an exact regex.
- **Identifier collisions and length.** A full pubkey is safe as a metadata key but is too long for a progressive tier name (`progressive.go:24` allows at most 32 characters). `coverage-node-<short-id>` also needs a defined collision policy; “short” is not enough. A hash-derived ID with a reverse lookup in metadata is safer.
- **Stale node positions.** The backend's repeater predicate (`hopreachCompatibility.ts:159-173`) checks coordinates and role but does not exclude silent nodes. The UI and metadata must carry freshness rather than treating any coordinate as current.
- **Best-server semantics.** The output is a single-transmitter footprint, not per-transmitter attribution from the existing merged PNG. It should be labeled accordingly so users do not compare it as a contribution layer.
- **Compute estimate.** `RasterBounds` pads by `rangeKm/110.574` in latitude and by longitude at the selected latitude (`coverage.go:31-52`). At 77.53 km the full padded span is about 155 km north-south; “155x110 km” is not the resulting symmetric approximate footprint. The live standard run took about 22 minutes to become available for 4380 repeaters, while precision was still 27/54 after hours. A one-site run may be much cheaper, but “few minutes” and “tens of minutes” need measured 4-CPU/8-GiB benchmarks, including cold and warm DEM-cache cases.
- **Metadata and disk growth.** Per-node tile arrays, repeated three-second polling, partial precision outputs, and retained old files can become a storage and bandwidth problem. Add TTL/LRU, quotas, cleanup, and payload-size tests before batch use.
- **Trigger abuse.** A public “Show coverage” action can become a compute DoS if every click starts work. Require internal authorization, deduplication, per-node cooldowns, and a queue limit.
- **Release and licensing.** A fork change requires the new public tag/source offer and the AGPL-3.0 plus Commons Clause obligations documented in `rf-coverage/SOURCE-OFFER.md:3-17` and `third_party/hopreach/README.md:177-185`. The release must update source/revision OCI labels and publish the corresponding source before exposing the service.
- **Digest-pinned deployment.** The documented rollout requires immutable signed image digests and clean source/revision labels (`docs/rf-coverage-rollout.md:96-117`, `docs/runbook-release-rollback.md:1-19`). A local `:local` compose build or mutable tag is not sufficient for the production change. Preserve the existing global tile contract and keep old artifacts for rollback.
## Recommended trigger mode
Implement `--node <pubkey>` as a one-shot calculator primitive, invoked by a small loopback-only queue/control endpoint in `hopreach-shareapi` or by an operator-side job submitter. Keep the nightly schedule for the global standard/precision run. The queue should enforce one HopReach process at a time, coalesce duplicate node requests, exclude silent nodes by default, use unique node checkpoint/manifest identities, and return a status that the popup can poll.
This gives the frontend a safe workflow—request, wait, display—without exposing a compute endpoint or coupling node work to the global `meta.run`. It also leaves room to add a carefully capped active-node batch later, after benchmark, memory, retention, and release-artifact evidence exists.
Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

+54
View File
@@ -0,0 +1,54 @@
# Beta shared-decoder port summary
## Status
Phases 2 and 3 from `PLAN-unify-resolvers.md` are implemented on
`pathing/beta-port`.
## What changed
- Added `backend/src/path-beta/sharedDecoder.ts`, a beta evidence adapter for
the shared `path-core/decoder.ts` Viterbi decoder and the sole production
`path-shared/scoring.ts` weight table.
- Ported the single-observer `resolve` job to the shared decoder. Observer
bounds, direct-receiver anchors, learned prefix/edge/transition/motif priors,
ML prefix scores, observed links, and decayed sticky assignments are supplied
as decoder evidence.
- Ported `resolveMulti` to lazy-style prefix-compatible grouping. Each group is
decoded once, and every observer result is projected from that canonical
decode rather than solved independently and stitched.
- Derived beta confidence from shared-decoder per-position margins, followed by
the existing model calibration.
- Removed the legacy inline-weight trellis, solve/retry/suffix cascade,
permutation enumeration, red fallback path builder, affinity scoring module,
and their now-unused constants/geometry helpers.
- Removed fallback output mode from the beta resolver. Unresolved decoder
positions split the rendered blue path into contiguous `purplePath` and
`extraPurplePaths` segments; no edge is invented across a gap.
- Preserved the worker job types and DTO field shape. Compatibility fields are
always `permutationCount: 0`, `redPath: null`, `redSegments: []`, and
`completionPaths: []`; beta results now use only `mode: 'resolved' | 'none'`.
- Added focused tests for canonical grouping, beta evidence flowing through the
shared decoder, and gap-safe per-observer projection.
## Verification
Run from `backend/`:
```text
npm run build
npm test
```
Results:
- TypeScript build: passed.
- Backend tests: 248 passed, 0 failed.
The production-data accuracy harness was not run in this worktree; its beta
extension is owned by the separate eval-harness phase/branch described in the
plan. This summary therefore makes no new numerical accuracy claim.
## Operational actions
No push, deployment, service restart, or other runtime mutation was performed.
+39
View File
@@ -0,0 +1,39 @@
# Path Core Extract Summary
## What moved
`backend/src/path-core/decoder.ts` now owns the lazy resolver's database-free
decode mechanics: observer-region candidate bounding/indexing, per-column
candidate capping, prefix/ML/anchor emissions, distance and learned-prior
transitions, the unresolved candidate, forward/backward max-product traversal,
and per-position margin/ambiguity calculation.
`backend/src/path-lazy/lazyResolver.ts` remains responsible for packet/node/prior
queries, privacy and network scoping, observer grouping, evidence-map creation,
and shaping the existing `LazyPathResult` DTO. It calls `decodePath` once per
canonical observer group.
## Evidence interface
`PathDecoderEvidence` supplies candidates and direct anchors; the shared weight
table and thresholds; and callbacks for prefix probabilities, ML prefix scores,
directed edge/transition/motif priors, and observed links. Optional callbacks
reserve the Phase-4 corridor interpolation, position-conditional transition,
and ITM-viability evidence without coupling the decoder to storage.
## Behavior notes
Existing score values, geographic gates, candidate ordering/caps, unresolved
baseline, and ambiguity threshold are unchanged. The new score slots
`corridorInterpolation`, `positionConditionalTransition`, and `itmViability`
default to `0`, so they are inert until later phases load evidence and explicitly
enable them. The ML evidence load threshold is also centralized in
`path-shared/scoring.ts`. No changes were needed in `path-lazy/evaluate.ts`.
## Verification
- `npm run build`: passed.
- `npm test`: passed (245/245).
- `evaluate.ts ukmesh 200`: completed without errors; reported Viterbi metrics
were unchanged to displayed precision. A fixed 32-packet old/new comparison
returned identical DTOs for every packet (`mismatches=0`).
+18
View File
@@ -0,0 +1,18 @@
# Frontend blue-path migration
## Changes
- Replaced legacy purple/red/completion path state with the canonical multi-observer DTO: `packetHash`, `network`, `canonicalPath`, `observers`, and `confidence`.
- Live overlays now use one canonical route from `resolve-multi`; unresolved hops do not create connecting geometry or a rendered alternate path.
- Added separate blue observer markers, preserving node metadata and click handling.
- Packet-detail maps consume the canonical DTO and no longer render legacy fallback geometry. Lazy paths remain available for the feed view when no multi-observer response is requested.
- Kept confidence-band arc styling and applied per-hop confidence where the DTO provides it.
- Added coverage for canonical aggregation, observer de-duplication, and unresolved-hop gaps.
## Verification
- `cd frontend && npm run build` — passed. Vite emitted the existing large-chunk warning.
- `cd frontend && npm test` — passed: 58 tests, 0 failures.
- `npm ci` was required because `frontend/node_modules` was absent; npm reported existing Node engine/deprecation warnings.
No deploy, push, service restart, or protected-file changes were made. The supplied `PLAN-unify-resolvers.md` remains untracked and was not included in the commit.
+38
View File
@@ -0,0 +1,38 @@
# GitHub cleanup status
Status: **blocked before push** (2026-08-03).
## Completed
- Consulted shared project memory and the tokensave code index before repository inspection.
- Built `cleanup/main-rewrite` from `origin/main` without the three RF planning commits or `IMPLEMENTATION-REPORT.md`.
- Prepared 13 clean commits: six main feature commits, six logical pathing commits, and the `.tokensave/` ignore rule. The branch is at `bca8219` and is 13 commits ahead of `origin/main` (`3c631e8`). It has not been renamed to `main` or pushed.
- Backend gate passed: `npm install`, `npm run build`, and `npm test` (249 passed, 0 failed).
## Blocking failure
The required frontend gate stopped at `cd frontend && npm install`:
```text
npm ERR! EACCES: permission denied, mkdir
/home/ben/meshcore-analytics/frontend/node_modules/playwright/node_modules/fsevents
```
The nested Playwright directories are owned by `root:root`, while the checkout is being used by `ben`. The VM is also running Node `v18.19.1` / npm `9.2.0`; the install reported dependencies requiring Node 22 or newer. Frontend build and tests were not run. Required next action: repair the dependency-directory ownership/install environment and use the repository-supported Node version, then rerun `npm install`, `npm run build`, and `npm test`. No ownership or dependency cleanup was guessed or performed.
## PII/secret scan
- Final prepared push diff (`origin/main..cleanup/main-rewrite`): no email addresses, `/home` or `/Users` paths, private IPs, credential-shaped tokens, API keys, or passwords found.
- The original nine-commit main range contained the VM address in the RF planning/review docs. Those docs, plus `IMPLEMENTATION-REPORT.md`, were excluded from the prepared history and remain untracked locally.
- The pathing integration diff contained no new PII/secret matches. Its five wave summary/baseline docs were excluded from the prepared history and remain untracked locally.
- The in-flight `wip/iata-upgrade-integration` snapshot commit contains operational private-network addresses and `/home/ben/meshcore-releases` paths in its unique patch; that branch was not pushed because the scan found those matches.
- The tracked baseline tree still contains pre-existing test/Compose/nginx private-network fixtures and deployment paths, a third-party font-license email, and the documented MeshCore public-channel key. No credential-shaped secret was found; the public-channel key is explicitly documented as protocol material rather than an application credential. These matches are outside the prepared push diff and were not altered.
## Not performed because of the blocker
- Nothing was pushed; `origin/main` is unchanged.
- No local or remote branches were deleted. The in-flight branches were not pushed.
- No worktrees were removed or pruned.
- The cleanup branch has not been renamed or fast-forwarded to `main`.
Local-only planning, summary, baseline, review, and implementation-report files are retained as untracked files in this VM checkout. This summary is also local-only and must not be committed.
+198
View File
@@ -0,0 +1,198 @@
# Stage 3 pathing accuracy summary
Date: 2026-08-03
Branch: `pathing/stage3`
Base: `0194f6d` (`pathing/merge-base`, merged shared decoder + beta port + harness)
## Outcome
The production lazy Viterbi now receives the one-byte, endpoint, geographic,
transition, and radio evidence used by the offline champion. On the full
1,259-packet gate it improved from **11.5% route / 15.3% hop** to **88.0% route
/ 97.4% hop**, with no unresolved positions and no resolver failures.
The requested prior-supported target passed: **96.2% route / 99.3% hop** versus
the target of at least 90% route. The unseen-corridor result was **83.3% route /
95.7% hop**: a 69.1-point route improvement over the 14.2% baseline, but 1.7
points short of the requested 85% route target.
## Diagnosis, measured rather than inferred
### Candidate construction
The positioned-node universe contains an average of 36.52 candidates per
one-byte prefix (median 37, p90 45, maximum 55). Of the 256 prefixes, 249 have
more than the old `MAX_COL=24`. Candidate rows were ordered by `node_id`, so the
old cap discarded evidence arbitrarily.
In the aligned 200-packet diagnostic, raising the cap to 128 improved route/hop
from 52.3%/70.7% to 55.2%/72.4%. The final cap is 64, which admits the measured
full universe (maximum 55) while retaining a bounded trellis. If a future
universe exceeds 64, candidates are scored by their emissions before truncation.
The aggregate evidence guards were a separate failure mode for packet hashes
with many incompatible historical groups. Four of 200 routes hit
`PATH_HISTORY_LIMIT`. Raising the bounded unique-hash and candidate-node loads
to 256 and 20,000, and deduplicating `(receiver, hop_count)` anchor observations,
removed all failures on the full 1,259-packet run.
### Observer bounds
The old bounds were constructed only from receivers. Source and receiver
endpoints are now included and the box is padded by `MAX_HOP_KM` (150 km), as in
the experiment. Disabling the padded bound did not help: the controlled run
fell from 55.2%/72.4% to 54.1%/71.9%. The bound is therefore retained as a useful
geographic filter, not removed.
### NULL abstention
The original 200-packet run left 87.6% of hop positions unresolved. Making
`NULL_BASELINE` noncompetitive by itself reduced unresolved output by 3.3
points but improved route accuracy only from 51.7% to 52.3%; abstention was real,
but it was not the main accuracy cause. The decoder now always chooses the best
real candidate when a column is nonempty and uses NULL only as a structural
fallback for an empty column. Marginal gaps still set the `ambiguous` flag.
### Prior granularity
`path_prefix_priors` contained zero two-character rows. It had 8,846 four-char
rows and 7,082 six-char rows, so the one-byte-degraded resolver missed the table
entirely. A one-byte fallback in the controlled diagnostic raised route/hop to
56.4%/73.0%, including 85.4%/93.2% on supported routes.
The replacement tables store true two-character keys. The decoder uses a
global one-byte frequency backoff (`log1p(count) × 0.3`) and the champion's
position-specific frequency (`log1p(count) × 1.2`). The global backoff was kept
because its 300-packet A/B improved unseen-corridor route accuracy; larger
weights regressed.
### Harness/pipeline mismatches
Two harness defects hid how much the decoder could recover:
- Gold rows retained historical `northeast`/`teesside` labels while positioned
nodes had been relabelled `ukmesh`, producing empty candidate columns. The
harness now resolves through the requested compatibility scope.
- Gold packets span 45 days while lazy resolution defaulted to the latest 168
hours. Historical evaluation now passes the selected packet timestamp and
reads a bounded ±30-second observation window.
The gold builder now matches the experiment's eligibility rules: known and
positioned source/receiver, uniquely resolved multibyte relays, terminal
receiver hash trimmed, and every source→relay→receiver edge at most 150 km.
Corridor novelty now means an exact source/receiver pair exists in the sampled
training prior rather than merely occurring earlier in raw packet history.
## Implemented evidence model
The shared decoder now scores the following champion signals:
- global one-byte prefix frequency: `log1p(count) × 0.3`;
- positional one-byte prefix frequency: `log1p(count) × 1.2`;
- exact `(source, receiver, position)` corridor frequency: `log1p(count) × 1.0`;
- general candidate-to-observer distance: `-distance / 80 km`;
- direct multi-observer position anchor: `-0.9 × distance / 80 km`, with a
150 km hard anchor gate;
- source→receiver corridor interpolation at `i/(N-1)`: `-distance / 55 km`;
- global directed transition: `log1p(count) × 2.0`;
- position-conditional transition: `log1p(count) × 1.2`, including source and
receiver endpoint edges;
- hop geography: `-distance / 40 km`, with a hard 150 km cap;
- `node_links.itm_viable` or `force_viable`: bonus `+0.8`, never a hard gate.
ML, edge-score, motif, and generic observed-link weights are zero because the
champion did not use them. The new priors and bonuses are enabled by default and
retain environment switches/weight overrides for controlled ablation.
## Rebuild and migration
Migration `033_path_champion_priors.sql` adds:
- `path_position_prefix_priors`;
- `path_corridor_priors`;
- `path_position_transition_priors`.
They are replaced transactionally with the existing prefix, transition, edge,
motif, and calibration rows under the same publication lease.
The prior rebuild now mirrors the experiment more closely: a deterministic 70%
packet-hash training split over 30 days, with identical packet/observer/source/
route observations deduplicated before aggregation. This avoids the old 6.25%
(`hash % 16 == 0`) sample wasting its budget on repeated copies. UKMesh packet
training uses the same `ukmesh`/`northeast`/`teesside` compatibility scope as the
resolver while nodes and links stay in the current UKMesh inventory.
The verified rebuild published, for the UKMesh model:
```text
packets=403302 top1=0.407 scale=0.778
position-prefix rows=3837 corridor rows=21450
position-transition rows=6290 edge rows=57991 motif rows=123281
```
The migration and rebuild were run locally for verification. No service was
deployed, pushed, or restarted.
## Full before/after gate
Each value is route accuracy / hop accuracy. Baseline values are from
`BASELINE-PATHING.md`; after values are the final `n=1259` run. The corrected
gold eligibility and sampled-prior corridor definition change stratum sizes, so
the table is an operational before/after rather than a paired-packet experiment.
| stratification | baseline lazy | final n | final lazy |
|---|---:|---:|---:|
| overall | 11.5% / 15.3% | 1,259 | **88.0% / 97.4%** |
| prior rare (≤1) | 6.4% / 10.2% | 832 | **83.8% / 96.4%** |
| prior mid (27) | 11.8% / 26.1% | 10 | **100.0% / 100.0%** |
| prior supported (≥8) | 26.7% / 33.2% | 417 | **96.2% / 99.3%** |
| route ≤3 | 10.4% / 11.1% | 390 | **93.1% / 96.5%** |
| route 4 | 11.9% / 14.9% | 145 | **81.4% / 94.8%** |
| route 56 | 12.2% / 13.9% | 262 | **90.1% / 98.0%** |
| route 78 | 13.7% / 17.1% | 201 | **83.1% / 97.0%** |
| route 912 | 9.9% / 12.4% | 216 | **88.9% / 98.3%** |
| route 13+ | 13.7% / 21.8% | 45 | **71.1% / 97.0%** |
| corridor seen | 7.0% / 6.9% | 512 | **94.9% / 99.2%** |
| corridor unseen | 14.2% / 19.7% | 747 | **83.3% / 95.7%** |
All four jobs on the final corpus:
| job | route | hop | unresolved | failures |
|---|---:|---:|---:|---:|
| legacy-greedy | 33.4% | 68.7% | 19.8% | 0 |
| **lazy-viterbi** | **88.0%** | **97.4%** | **0.0%** | **0** |
| beta-single | 27.5% | 64.6% | 15.8% | 0 |
| beta-multi | 2.7% | 7.6% | 90.0% | 0 |
## Verification
Commands completed successfully:
```text
cd backend
npm install
npm run build
npm test # 249 passed, 0 failed
npx tsx src/path-lazy/evaluate.ts ukmesh 1259
```
The full harness was run as one `taskset -c 0-3 nice -n 15` process. Rebuilds
and harnesses were never run concurrently.
## Remaining gap
The port closes most of the production/offline gap but does not reproduce the
offline 97.27% route score. The largest residual is route-level sensitivity to
one wrong byte-prefix collision: hop accuracy is 97.4%, yet complete-route
accuracy is 88.0%. This compounds most strongly on 13+ routes (71.1% route with
97.0% hop). Unseen corridors remain 1.7 points below the requested target.
Likely contributors are differences between the live compatibility-scoped
rebuild and the frozen experiment corpus, capped top-N prior tables, and genuine
one-byte collisions with equally plausible learned spines. The full test set
was not used for another tuning pass. Further work should use a frozen
train/validation/test export and inspect the remaining wrong-hop margins before
changing weights; beta job accuracy remains a separate later-stage issue and
was intentionally not changed under this task's file ownership.
+48
View File
@@ -0,0 +1,48 @@
# Phase 4 parallel-run override — Projects VPS ONLY (not for production)
# Rationale: a Postgres hot-standby is READ-ONLY, so app services that write at
# startup (backend owner-auth tables, worker schema ensure) cannot boot against
# the replica. For the dress rehearsal they point at the LIVE PRIMARY through
# the mig-tunnel (172.18.0.1:5433 → VM). The local timescaledb keeps streaming
# as standby for cutover. At cutover: promote replica, remove this override,
# DATABASE_URL returns to timescaledb:5432.
services:
# Schema already present on the replica from pg_basebackup — no-op.
db-migrate:
command: ["sh", "-c", "echo 'Phase-4: replica standby, migrations skipped (schema present from base backup)'; exit 0"]
# Writes duplicate test rows to the shared prod DB — not wanted in rehearsal.
synthetic-monitor:
command: ["sh", "-c", "echo 'Phase-4: synthetic-monitor disabled (would pollute shared prod DB)'; sleep infinity"]
backend:
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@172.18.0.1:5433/${POSTGRES_DB}
OWNER_DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@172.18.0.1:5433/${OWNER_POSTGRES_DB:-meshcore_owner_auth}
path-learning-worker:
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@172.18.0.1:5433/${POSTGRES_DB}
path-history-worker:
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@172.18.0.1:5433/${POSTGRES_DB}
health-worker:
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@172.18.0.1:5433/${POSTGRES_DB}
alert-receiver:
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@172.18.0.1:5433/${POSTGRES_DB}
link-worker:
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@172.18.0.1:5433/${POSTGRES_DB}
link-backfill-worker:
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@172.18.0.1:5433/${POSTGRES_DB}
mesh-health-check:
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@172.18.0.1:5433/${POSTGRES_DB}
+39
View File
@@ -0,0 +1,39 @@
// Mobile layout verification captures — run against the local app container (skips anubis).
// Usage: node /tmp/mobile-shot.mjs [outdir]
import { chromium } from 'playwright';
import { mkdirSync } from 'fs';
const outdir = process.argv[2] ?? '/tmp/mobile-shots';
mkdirSync(outdir, { recursive: true });
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1 Googlebot/2.1';
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
userAgent: UA,
});
const page = await context.newPage();
const shots = [
{ name: 'map-top', url: 'http://127.0.0.1:3003/', full: false },
{ name: 'map-full', url: 'http://127.0.0.1:3003/', full: true },
{ name: 'login', url: 'http://127.0.0.1:3003/login', full: true },
];
for (const s of shots) {
try {
await page.goto(s.url, { waitUntil: 'load', timeout: 30000 });
await page.waitForTimeout(4000);
await page.screenshot({ path: `${outdir}/${s.name}.png`, fullPage: s.full });
console.log(`OK ${s.name}`);
} catch (e) {
console.log(`FAIL ${s.name}: ${e.message.slice(0, 120)}`);
}
}
await browser.close();
console.log('done ->', outdir);
+1 -7
View File
@@ -11,16 +11,10 @@ log_type notice
connection_messages true
log_timestamp true
# Public/ingest WebSocket listener.
# WebSocket listener only (no plain TCP MQTT)
listener 9001
protocol websockets
# Internal-only TCP listener used by the Prometheus exporter. Compose does not
# publish this port on the host, and the normal password/ACL policy still
# applies.
listener 1883
protocol mqtt
allow_anonymous false
password_file /mosquitto/config/passwd
acl_file /mosquitto/config/acl