diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index b642b2a..a5cedb5 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -39,7 +39,7 @@ import { verifyMqttCredentials, } from '../owner/ownerAccess.js'; import { encryptOwnerSession, getOwnerSession, isSecureRequest } from '../owner/ownerSession.js'; -import { getResolveCache, setResolveCache } from '../path-beta/resolveCache.js'; +import { getResolveCache, setResolveCache, getHeldPath, setHeldPath } from '../path-beta/resolveCache.js'; import { resolvePool } from '../path-beta/resolvePool.js'; import { maskDecodedPathNodes } from '../stats/maskDecodedPathNodes.js'; import { @@ -207,6 +207,8 @@ registerPathingRoutes(router, { pathHistoryCacheTtlMs: PATH_HISTORY_CACHE_TTL_MS, getResolveCache, setResolveCache, + getHeldPath, + setHeldPath, resolvePool, getPathHistoryCache, getPublicVisibilityGeneration, diff --git a/backend/src/api/routes/pathing.test.ts b/backend/src/api/routes/pathing.test.ts index 0db98a7..5c64112 100644 --- a/backend/src/api/routes/pathing.test.ts +++ b/backend/src/api/routes/pathing.test.ts @@ -20,6 +20,8 @@ async function withPathingServer( pathHistoryCacheTtlMs: 60_000, getResolveCache: () => undefined, setResolveCache: () => undefined, + getHeldPath: () => undefined, + setHeldPath: () => undefined, resolvePool: { run }, getPublicVisibilityGeneration: async () => 1, getPathHistoryCache: async () => null, diff --git a/backend/src/api/routes/pathing.ts b/backend/src/api/routes/pathing.ts index 1744bee..dfc2378 100644 --- a/backend/src/api/routes/pathing.ts +++ b/backend/src/api/routes/pathing.ts @@ -9,11 +9,12 @@ import { } from '../../path-beta/slowMode.js'; import { normalizeObserverQuery } from '../utils/observer.js'; import { parseBoundedInteger, parseHexIdentifier } from '../utils/input.js'; +import type { HeldPathEntry } from '../../path-beta/resolveCache.js'; type ResolvePoolFn = { run(job: - | { type: 'resolve'; packetHash: string; network: string; observer?: string | null } - | { type: 'resolveMulti'; packetHash: string; network: string } + | { type: 'resolve'; packetHash: string; network: string; observer?: string | null; heldPath?: HeldPathEntry } + | { type: 'resolveMulti'; packetHash: string; network: string; heldPath?: HeldPathEntry } | { type: 'resolveLazy'; packetHash: string; network: string } ): Promise; }; @@ -26,6 +27,8 @@ type PathingRouteDeps = { pathHistoryCacheTtlMs: number; getResolveCache: (key: string) => unknown; setResolveCache: (key: string, value: unknown) => void; + getHeldPath: (packetHash: string, network: string) => HeldPathEntry | undefined; + setHeldPath: (packetHash: string, network: string, value: HeldPathEntry) => void; resolvePool: ResolvePoolFn; getPublicVisibilityGeneration: () => Promise; getPathHistoryCache: (scope: string, visibilityGeneration: number) => Promise<{ @@ -61,6 +64,8 @@ export function registerPathingRoutes(router: Router, deps: PathingRouteDeps): v pathHistoryCacheTtlMs: deps.pathHistoryCacheTtlMs, getResolveCache: deps.getResolveCache, setResolveCache: deps.setResolveCache, + getHeldPath: deps.getHeldPath, + setHeldPath: deps.setHeldPath, resolvePool: deps.resolvePool, repository, }); diff --git a/backend/src/cache/policyRegistry.ts b/backend/src/cache/policyRegistry.ts index e48569e..bfac615 100644 --- a/backend/src/cache/policyRegistry.ts +++ b/backend/src/cache/policyRegistry.ts @@ -104,6 +104,12 @@ export const CACHE_POLICY_REGISTRY: readonly CachePolicyRecord[] = Object.freeze scope: 'packet + network; public projection remains visibility-fenced', invalidation: 'TTL', negativeCaching: 'none', singleFlight: 'worker pool', }, + { + source: 'src/path-beta/resolveCache.ts#heldPathCache', + disposition: 'bounded-cache', maxEntries: 2_048, maxBytes: 16 << 20, ttlMs: 30 * 60_000, + scope: 'packet + network; public projection remains visibility-fenced', invalidation: 'TTL only; packet observations do not clear held paths', + negativeCaching: 'none', singleFlight: 'pathingService resolve maps + worker pool', + }, { source: 'src/path-beta/resolver.ts#contextCache', disposition: 'bounded-cache', maxEntries: 16, maxBytes: 128 << 20, ttlMs: 15 * 60_000, diff --git a/backend/src/mqtt/client.ts b/backend/src/mqtt/client.ts index fb548f7..910ab01 100644 --- a/backend/src/mqtt/client.ts +++ b/backend/src/mqtt/client.ts @@ -7,7 +7,7 @@ import type { import { insertNodeStatusSample, insertPacket, upsertNode, incrementAdvertCount, query, insertOrUpdateSpamSuspect, recordMultibyteEvidence } from '../db/index.js'; import { closePacketBatch, flush as flushPacketBatch } from '../db/packetBatch.js'; import { evaluateAdvert, initSpamDetector } from './spamDetector.js'; -import { invalidateResolveCache, setResolveCache, getStickyNodeMap, mergeStickyNodes } from '../path-beta/resolveCache.js'; +import { invalidateResolveCache, setResolveCache, getStickyNodeMap, mergeStickyNodes, getHeldPath, setHeldPath } from '../path-beta/resolveCache.js'; import { resolvePool } from '../path-beta/resolvePool.js'; import { scheduleSlowResolution } from '../path-beta/slowMode.js'; import { pathingConfig } from '../platform/config/pathing.js'; @@ -1005,7 +1005,11 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { const stickyEntry = getStickyNodeMap(finalHash, network); const stickyMap = stickyEntry ? Object.fromEntries(stickyEntry.hashToNodeId) : undefined; const stickyAgeFraction = stickyEntry?.ageFraction; - resolvePool.runBackground<{ stickyUpdates?: Record }>({ type: 'resolveMulti', packetHash: finalHash, network, ...(stickyMap ? { stickyMap, stickyAgeFraction } : {}) }) + const heldPath = getHeldPath(finalHash, network); + resolvePool.runBackground<{ + stickyUpdates?: Record; + canonicalPath?: Array<{ nodeId?: string | null }>; + }>({ type: 'resolveMulti', packetHash: finalHash, network, ...(stickyMap ? { stickyMap, stickyAgeFraction } : {}), ...(heldPath ? { heldPath } : {}) }) .then((result) => { if (result) { const { stickyUpdates, ...cacheableResult } = result; @@ -1013,6 +1017,10 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { if (stickyUpdates && Object.keys(stickyUpdates).length > 0) { mergeStickyNodes(finalHash, network, stickyUpdates); } + const heldNodes = result.canonicalPath?.map((hop) => hop.nodeId ?? '').filter(Boolean) ?? []; + if (heldNodes.length > 0 && heldNodes.length === result.canonicalPath?.length) { + setHeldPath(finalHash, network, { path: heldNodes, resolvedAt: Date.now(), physical: true }); + } } }) .catch(() => { /* best-effort */ }) diff --git a/backend/src/path-beta/resolveCache.test.ts b/backend/src/path-beta/resolveCache.test.ts index 0fc5e9b..b45b761 100644 --- a/backend/src/path-beta/resolveCache.test.ts +++ b/backend/src/path-beta/resolveCache.test.ts @@ -2,11 +2,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { getResolveCache, + getHeldPath, getStickyNodeMap, invalidateResolveCache, mergeStickyNodes, resolveCacheMetrics, setResolveCache, + setHeldPath, } from './resolveCache.js'; test('resolve cache invalidates a packet without a full-cache scan and remains bounded', () => { @@ -30,3 +32,17 @@ test('sticky anchors cap nested entries per packet', () => { mergeStickyNodes('sticky-packet', 'ukmesh', updates); assert.equal(getStickyNodeMap('sticky-packet', 'ukmesh')?.hashToNodeId.size, 64); }); + +test('held paths survive ordinary result invalidation', () => { + setHeldPath('held-packet', 'ukmesh', { + path: ['AA01', 'BB01'], + resolvedAt: 123, + physical: true, + }); + invalidateResolveCache('held-packet'); + assert.deepEqual(getHeldPath('held-packet', 'ukmesh'), { + path: ['AA01', 'BB01'], + resolvedAt: 123, + physical: true, + }); +}); diff --git a/backend/src/path-beta/resolveCache.ts b/backend/src/path-beta/resolveCache.ts index c3fab73..f10ce2c 100644 --- a/backend/src/path-beta/resolveCache.ts +++ b/backend/src/path-beta/resolveCache.ts @@ -68,9 +68,39 @@ export function resolveCacheMetrics() { results: cache.metrics(), invalidations: invalidationVersions.metrics(), sticky: stickyNodeCache.metrics(), + held: heldPathCache.metrics(), }; } +export type HeldPathEntry = { + path: string[]; + resolvedAt: number; + physical: boolean; +}; + +const HELD_PATH_TTL_MS = 30 * 60 * 1000; +const heldPathCache = new BoundedTtlMap({ + name: 'path_held_paths', + maxEntries: 2_048, + maxWeight: 16 * 1024 * 1024, + ttlMs: HELD_PATH_TTL_MS, + weightOf: (key, value) => key.length * 2 + value.path.length * 128, +}); + +export function getHeldPath(packetHash: string, network: string): HeldPathEntry | undefined { + const entry = heldPathCache.get(`${packetHash}|${network}`); + return entry ? { ...entry, path: [...entry.path] } : undefined; +} + +export function setHeldPath( + packetHash: string, + network: string, + value: HeldPathEntry, +): void { + if (!packetHash || !network || value.path.length === 0 || value.path.some((nodeId) => !nodeId)) return; + heldPathCache.set(`${packetHash}|${network}`, { ...value, path: [...value.path] }); +} + /** * Sticky node anchors — persists across resolve cache invalidations so that * re-resolutions triggered by new observations reuse the same high-confidence diff --git a/backend/src/path-beta/resolveWorker.ts b/backend/src/path-beta/resolveWorker.ts index 97efc70..babd981 100644 --- a/backend/src/path-beta/resolveWorker.ts +++ b/backend/src/path-beta/resolveWorker.ts @@ -7,6 +7,7 @@ import { parentPort } from 'worker_threads'; import { query } from '../db/index.js'; import { lazyResolvePath } from '../path-lazy/lazyResolver.js'; import { resolveBetaPathForPacketHash, resolveMultiObserverBetaPath } from './resolver.js'; +import { getHeldPath, setHeldPath, type HeldPathEntry } from './resolveCache.js'; if (!parentPort) throw new Error('resolveWorker must run as a worker thread'); @@ -18,17 +19,40 @@ type WorkerMessage = { observer?: string; stickyMap?: Record; stickyAgeFraction?: number; + heldPath?: HeldPathEntry; }; parentPort.on('message', (msg: WorkerMessage) => { const stickyMap = msg.stickyMap ? new Map(Object.entries(msg.stickyMap)) : undefined; + const heldPath = msg.heldPath ?? getHeldPath(msg.packetHash, msg.network); const run = msg.type === 'resolveLazy' ? lazyResolvePath(msg.packetHash, msg.network, query) : msg.type === 'resolveMulti' - ? resolveMultiObserverBetaPath(msg.packetHash, msg.network, stickyMap, msg.stickyAgeFraction) - : resolveBetaPathForPacketHash(msg.packetHash, msg.network, msg.observer, stickyMap, msg.stickyAgeFraction); + ? resolveMultiObserverBetaPath(msg.packetHash, msg.network, stickyMap, msg.stickyAgeFraction, { heldPath }) + : resolveBetaPathForPacketHash( + msg.packetHash, + msg.network, + msg.observer, + stickyMap, + msg.stickyAgeFraction, + { heldPath }, + ); run - .then((result) => { parentPort!.postMessage({ id: msg.id, ok: true, result: result ?? null }); }) + .then((result) => { + if (result && msg.type !== 'resolveLazy' && 'canonicalPath' in result) { + const path = result.canonicalPath + .map((hop: { nodeId?: string | null }) => hop.nodeId ?? '') + .filter(Boolean); + if (path.length === result.canonicalPath.length && path.length > 0) { + setHeldPath(msg.packetHash, msg.network, { + path, + resolvedAt: Date.now(), + physical: true, + }); + } + } + parentPort!.postMessage({ id: msg.id, ok: true, result: result ?? null }); + }) .catch((err: Error) => { parentPort!.postMessage({ id: msg.id, ok: false, error: err.message }); }); }); diff --git a/backend/src/path-beta/resolver.ts b/backend/src/path-beta/resolver.ts index c8f3550..facb0b8 100644 --- a/backend/src/path-beta/resolver.ts +++ b/backend/src/path-beta/resolver.ts @@ -17,6 +17,7 @@ import { import { hasCoords, linkKey } from './geometry.js'; import { decodeBetaCanonicalGroup, + decodeBetaCanonicalGroupWithHeldPath, groupCompatibleObservations, projectCanonicalPathForObserver, type BetaObserverEntry, @@ -31,6 +32,7 @@ import type { PathLearningModel, PathPacket, } from './types.js'; +import type { HeldPathEntry } from './resolveCache.js'; const contextCache = new BoundedTtlMap({ name: 'path_context', @@ -545,6 +547,7 @@ export type PathResolutionOptions = { pinContextForBatch?: boolean; requiredVisibilityGeneration?: number; signal?: AbortSignal; + heldPath?: HeldPathEntry; }; function throwIfResolutionAborted(options?: PathResolutionOptions): void { @@ -707,7 +710,13 @@ export async function resolveBetaPathForPacketHash( const group = groupCompatibleObservations(entries) .find((candidate) => candidate.members.includes(selectedEntry)) ?? { canonicalHashes: selectedEntry.hops, members: [selectedEntry] }; - const decoded = decodeBetaCanonicalGroup(group, context, stickyMap, stickyAgeFraction); + const decoded = decodeBetaCanonicalGroupWithHeldPath( + group, + context, + options?.heldPath?.path, + stickyMap, + stickyAgeFraction, + ); const source = selectedEntry.packet.src_node_id ? context.nodesById.get(selectedEntry.packet.src_node_id) ?? null : null; @@ -717,6 +726,7 @@ export async function resolveBetaPathForPacketHash( observers: [{ observerId: selectedEntry.observerId }], network, }); + payload.permutationCount = decoded.held ? Math.min(2, decoded.canonicalHashes.length) : 0; if (payload.mode === 'resolved' && shouldTouchPredictedOnline(options)) { await recordPredictedOnline(projection.nodeIds); } @@ -879,7 +889,20 @@ export async function resolveMultiObserverBetaPath( for (const group of groups) { throwIfResolutionAborted(options); - const decoded = decodeBetaCanonicalGroup(group, context, stickyMap, stickyAgeFraction); + const heldStickyMap = new Map(stickyMap ?? []); + if (options?.heldPath?.path.length === group.canonicalHashes.length) { + for (let position = 0; position < group.canonicalHashes.length; position++) { + const hash = normalizePathHash(group.canonicalHashes[position]); + const nodeId = options.heldPath.path[position]; + if (hash && nodeId) heldStickyMap.set(hash, nodeId); + } + } + const decoded = decodeBetaCanonicalGroup( + group, + context, + heldStickyMap.size > 0 ? heldStickyMap : undefined, + stickyAgeFraction, + ); if (firstCanonicalPath.length === 0) firstCanonicalPath = buildCanonicalPath(decoded); for (let position = 0; position < decoded.canonicalHashes.length; position++) { const hop = decoded.hops.get(position); diff --git a/backend/src/path-beta/sharedDecoder.test.ts b/backend/src/path-beta/sharedDecoder.test.ts index 32c59da..6cacc87 100644 --- a/backend/src/path-beta/sharedDecoder.test.ts +++ b/backend/src/path-beta/sharedDecoder.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { decodeBetaCanonicalGroup, + decodeBetaCanonicalGroupWithHeldPath, groupCompatibleObservations, projectCanonicalPathForObserver, type BetaObserverEntry, @@ -124,3 +125,43 @@ test('observer projection breaks at unresolved hops and never invents a connecti assert.equal(projection.resolvedHopCount, 2); assert.ok(Math.abs(projection.confidence - (2 / 3)) < 1e-12); }); + +test('refinement keeps a physical held path when an alternative misses the margin', () => { + const heldA = meshNode('AA01', 51.0, 0.1); + const heldB = meshNode('BB01', 51.1, 0.1); + const alternativeB = meshNode('BB02', 51.1, 0.1); + const observer = entry('RX', ['AA', 'BB']); + observer.rx = meshNode('RX', 51.2, 0.1); + const betaContext = context([heldA, heldB, alternativeB, observer.rx]); + betaContext.learningModel.prefixProbabilities.set('unknown|BB|AA|BB01', 0.70); + betaContext.learningModel.prefixProbabilities.set('unknown|BB|AA|BB02', 0.80); + + const decoded = decodeBetaCanonicalGroupWithHeldPath( + { canonicalHashes: observer.hops, members: [observer] }, + betaContext, + ['AA01', 'BB01'], + ); + + assert.equal(decoded.held, true); + assert.equal(decoded.physical, true); + assert.equal(decoded.refined, false); + assert.deepEqual([...decoded.hops.values()].map((hop) => hop.nodeId), ['AA01', 'BB01']); +}); + +test('an impossible held final hop triggers a full re-solve', () => { + const impossible = meshNode('AA01', 52.1, 0.1); + const replacement = meshNode('AA02', 51.1, 0.1); + const observer = entry('RX', ['AA']); + observer.rx = meshNode('RX', 51.0, 0.1); + const betaContext = context([impossible, replacement, observer.rx]); + + const decoded = decodeBetaCanonicalGroupWithHeldPath( + { canonicalHashes: observer.hops, members: [observer] }, + betaContext, + ['AA01'], + ); + + assert.equal(decoded.held, false); + assert.equal(decoded.physical, false); + assert.equal(decoded.hops.get(0)?.nodeId, 'AA02'); +}); diff --git a/backend/src/path-beta/sharedDecoder.ts b/backend/src/path-beta/sharedDecoder.ts index d64dab9..b36801c 100644 --- a/backend/src/path-beta/sharedDecoder.ts +++ b/backend/src/path-beta/sharedDecoder.ts @@ -1,17 +1,19 @@ import { buildObserverBounds, - decodePath, + decodePathWithScore, + evaluatePhysicalHop, indexCandidatesByHash, + scoreFixedPath, type CandidateNodeEvidence, type DecodedHop, type ObserverAnchor, type PathDecoderEvidence, } from '../path-core/decoder.js'; +import { pathingConfig } from '../platform/config/pathing.js'; import { AMBIG_DELTA, DIST_DECAY_KM, MAX_COL, - MAX_HOP_KM, ML_DOMINANT_THRESHOLD, NULL_BASELINE, SCORE, @@ -37,8 +39,12 @@ export type BetaSharedDecode = { canonicalHashes: string[]; hops: ReadonlyMap; hopConfidences: ReadonlyMap; + score?: number; + baselineScore?: number; }; +export const HELD_PATH_REFINEMENT_MARGIN = AMBIG_DELTA; + export type BetaPathProjection = { confidence: number; extraPurplePaths: [number, number][][]; @@ -88,15 +94,19 @@ function directAnchorMap(group: BetaCanonicalGroup): Map(); for (const entry of group.members) { if (!hasCoords(entry.rx)) continue; - const hopCount = Number(entry.packet.hop_count ?? 0); - if (!Number.isFinite(hopCount) || hopCount < 1) continue; - const position = hopCount - 1; + const position = entry.hops.length - 1; + if (position < 0) continue; const hash = entry.hops[position]; if (!hash || normalizePathHash(group.canonicalHashes[position]) !== normalizePathHash(hash)) continue; const key = `${position}:${normalizePathHash(hash)}`; const values = anchors.get(key) ?? []; if (!values.some((value) => value.nodeId === entry.observerId)) { - values.push({ lat: entry.rx.lat!, lon: entry.rx.lon!, nodeId: entry.observerId }); + values.push({ + lat: entry.rx.lat!, + lon: entry.rx.lon!, + nodeId: entry.observerId, + elevationM: entry.rx.elevation_m, + }); } anchors.set(key, values); } @@ -121,18 +131,21 @@ export function decodeBetaCanonicalGroup( context: BetaResolveContext, stickyMap?: ReadonlyMap, stickyAgeFraction?: number, + fixed?: ReadonlyMap, + baselineNodeIds?: readonly string[], ): BetaSharedDecode { const canonicalHashes = group.canonicalHashes.map(normalizePathHash).filter(Boolean); const observerCoordinates = group.members .map((entry) => entry.rx) .filter(hasCoords) .map((node) => ({ lat: node.lat!, lon: node.lon! })); - const bounds = buildObserverBounds(observerCoordinates, MAX_HOP_KM); + const bounds = buildObserverBounds(observerCoordinates, pathingConfig.maxHopKm); const candidateEvidence: CandidateNodeEvidence[] = context.repeaterNodes.map((node) => ({ nodeId: node.node_id, name: node.name, lat: node.lat, lon: node.lon, + elevationM: node.elevation_m, })); const candidatesByHash = indexCandidatesByHash(canonicalHashes, candidateEvidence, bounds); const anchors = directAnchorMap(group); @@ -140,16 +153,29 @@ export function decodeBetaCanonicalGroup( const bucketHours = context.learningModel.bucketHours || 6; const bucket = currentHourBucket(bucketHours); const stickyStrength = clamp(1 - Number(stickyAgeFraction ?? 0), 0, 1); + const sourceNode = group.members + .map((entry) => entry.packet.src_node_id + ? context.nodesById.get(entry.packet.src_node_id) ?? null + : null) + .find(hasCoords); const evidence: PathDecoderEvidence = { weights: SCORE, - maxHopKm: MAX_HOP_KM, + maxHopKm: pathingConfig.maxHopKm, distanceDecayKm: DIST_DECAY_KM, mlDominantThreshold: ML_DOMINANT_THRESHOLD, unresolvedBaseline: NULL_BASELINE, ambiguityDelta: AMBIG_DELTA, maxColumnCandidates: MAX_COL, candidatesByHash, + fixed, + sourceAnchor: sourceNode ? { + lat: sourceNode.lat!, + lon: sourceNode.lon!, + nodeId: sourceNode.node_id, + elevationM: sourceNode.elevation_m, + } : undefined, + endpointTransitionsGateOnly: true, directAnchors: (position, hash) => anchors.get(`${position}:${normalizePathHash(hash)}`) ?? [], prefixProbability: (nodeId, hash, previousHash) => { const prefix = normalizePathHash(hash); @@ -182,12 +208,161 @@ export function decodeBetaCanonicalGroup( Number(metrics?.multibyte_observed_count ?? 0), ) >= 2; }, + physicalTransition: (from, to) => { + const metrics = from.nodeId && to.nodeId + ? context.linkMetrics.get(linkKey(from.nodeId, to.nodeId)) + : undefined; + const physics = evaluatePhysicalHop(from, to, { + maxHopKm: pathingConfig.maxHopKm, + earthEffectiveRadiusM: pathingConfig.earthEffectiveRadiusM, + behindEarthToleranceKm: pathingConfig.behindEarthToleranceKm, + impossibleLinkPathlossDb: pathingConfig.impossibleLinkPathlossDb, + pathlossDb: metrics?.itm_path_loss_db, + antennaHeightM: pathingConfig.defaultAntennaHeightM, + }); + return { + possible: physics.possible, + score: pathingConfig.physicsSoftMarginWeight * physics.softMargin, + }; + }, }; - const hops = decodePath(canonicalHashes, evidence); + const decoded = decodePathWithScore(canonicalHashes, evidence); + const hops = decoded.hops; const hopConfidences = new Map(); for (const [position, hop] of hops) hopConfidences.set(position, marginConfidence(hop)); - return { canonicalHashes, hops, hopConfidences }; + return { + canonicalHashes, + hops, + hopConfidences, + score: decoded.score, + ...(baselineNodeIds + ? { baselineScore: scoreFixedPath(canonicalHashes, baselineNodeIds, evidence) } + : {}), + }; +} + +function hardHopPossible(from: MeshNode, to: MeshNode, context: BetaResolveContext): boolean { + if (!hasCoords(from) || !hasCoords(to)) return false; + return evaluatePhysicalHop( + { lat: from.lat!, lon: from.lon!, elevationM: from.elevation_m }, + { lat: to.lat!, lon: to.lon!, elevationM: to.elevation_m }, + { + maxHopKm: pathingConfig.maxHopKm, + earthEffectiveRadiusM: pathingConfig.earthEffectiveRadiusM, + behindEarthToleranceKm: pathingConfig.behindEarthToleranceKm, + impossibleLinkPathlossDb: pathingConfig.impossibleLinkPathlossDb, + pathlossDb: context.linkMetrics.get(linkKey(from.node_id, to.node_id))?.itm_path_loss_db, + antennaHeightM: pathingConfig.defaultAntennaHeightM, + }, + ).possible; +} + +/** Check every held relay hop, including source and repeater→observer endpoints. */ +export function heldPathIsPhysicallyPossible( + group: BetaCanonicalGroup, + context: BetaResolveContext, + nodeIds: readonly string[], +): boolean { + if (nodeIds.length !== group.canonicalHashes.length || nodeIds.length === 0) return false; + const nodes = nodeIds.map((nodeId, position) => { + const node = context.nodesById.get(nodeId); + const hash = normalizePathHash(group.canonicalHashes[position]); + return node && nodePathHash(node.node_id, hash) === hash ? node : null; + }); + if (nodes.some((node) => !hasCoords(node))) return false; + for (let position = 1; position < nodes.length; position++) { + if (!hardHopPossible(nodes[position - 1]!, nodes[position]!, context)) return false; + } + for (const member of group.members) { + const source = member.packet.src_node_id + ? context.nodesById.get(member.packet.src_node_id) + : undefined; + if (source && !hardHopPossible(source, nodes[0]!, context)) return false; + const terminalPosition = member.hops.length - 1; + const terminalRelay = nodes[terminalPosition]; + if (!terminalRelay || !hardHopPossible(terminalRelay, member.rx, context)) return false; + } + return true; +} + +export type HeldPathDecode = BetaSharedDecode & { + held: boolean; + physical: boolean; + refined: boolean; +}; + +function materializeHeldDecode( + group: BetaCanonicalGroup, + context: BetaResolveContext, + nodeIds: readonly string[], +): BetaSharedDecode { + const canonicalHashes = group.canonicalHashes.map(normalizePathHash).filter(Boolean); + const hops = new Map(); + const hopConfidences = new Map(); + for (let position = 0; position < canonicalHashes.length; position++) { + const node = context.nodesById.get(nodeIds[position]!); + hops.set(position, { + hash: canonicalHashes[position]!, + nodeId: node?.node_id ?? null, + name: node?.name ?? null, + lat: node?.lat ?? null, + lon: node?.lon ?? null, + margin: Infinity, + ambiguous: false, + }); + hopConfidences.set(position, node ? 1 : 0); + } + return { canonicalHashes, hops, hopConfidences, score: 0 }; +} + +/** Hold a physical path and refine only the two observer-nearest positions. */ +export function decodeBetaCanonicalGroupWithHeldPath( + group: BetaCanonicalGroup, + context: BetaResolveContext, + heldNodeIds: readonly string[] | undefined, + stickyMap?: ReadonlyMap, + stickyAgeFraction?: number, +): HeldPathDecode { + if (!heldNodeIds || !heldPathIsPhysicallyPossible(group, context, heldNodeIds)) { + return { + ...decodeBetaCanonicalGroup(group, context, stickyMap, stickyAgeFraction), + held: false, + physical: false, + refined: false, + }; + } + + let current = [...heldNodeIds]; + let refined = false; + const lastPosition = current.length - 1; + for (let position = lastPosition; position >= Math.max(0, lastPosition - 1); position--) { + const fixed = new Map(); + current.forEach((nodeId, index) => { if (index !== position) fixed.set(index, nodeId); }); + const candidate = decodeBetaCanonicalGroup( + group, + context, + stickyMap, + stickyAgeFraction, + fixed, + current, + ); + const candidateHop = candidate.hops.get(position); + if (candidateHop?.nodeId + && candidateHop.nodeId !== current[position] + && Number.isFinite(candidate.score) + && Number.isFinite(candidate.baselineScore) + && candidate.score! >= candidate.baselineScore! + HELD_PATH_REFINEMENT_MARGIN) { + current[position] = candidateHop.nodeId; + refined = true; + } + } + return { + ...materializeHeldDecode(group, context, current), + held: true, + physical: true, + refined, + }; } type PositionedPoint = { diff --git a/backend/src/path-beta/slowMode.ts b/backend/src/path-beta/slowMode.ts index 7ace8ec..34587c7 100644 --- a/backend/src/path-beta/slowMode.ts +++ b/backend/src/path-beta/slowMode.ts @@ -26,6 +26,8 @@ import { setResolveCache, mergeStickyNodes, getStickyNodeMap, + getHeldPath, + setHeldPath, } from './resolveCache.js'; type PendingEntry = { @@ -150,6 +152,7 @@ async function finalizeSlowResolution(packetHash: string, network: string): Prom ? Object.fromEntries(stickyEntry.hashToNodeId) : undefined; const stickyAgeFraction = stickyEntry?.ageFraction; + const heldPath = getHeldPath(packetHash, network); const result = await (await getSlowPool()).runBackground<{ stickyUpdates?: Record; observers?: unknown; @@ -159,6 +162,7 @@ async function finalizeSlowResolution(packetHash: string, network: string): Prom packetHash, network, ...(stickyMap ? { stickyMap, stickyAgeFraction } : {}), + ...(heldPath ? { heldPath } : {}), }); if (!result) return; const { stickyUpdates, ...cacheableResult } = result; @@ -166,6 +170,15 @@ async function finalizeSlowResolution(packetHash: string, network: string): Prom if (stickyUpdates && Object.keys(stickyUpdates).length > 0) { mergeStickyNodes(packetHash, network, stickyUpdates); } + const canonicalPath = Array.isArray(result.canonicalPath) ? result.canonicalPath : []; + const heldNodes = canonicalPath + .map((hop) => hop && typeof hop === 'object' && 'nodeId' in hop + ? String((hop as { nodeId?: unknown }).nodeId ?? '') + : '') + .filter(Boolean); + if (heldNodes.length > 0 && heldNodes.length === canonicalPath.length) { + setHeldPath(packetHash, network, { path: heldNodes, resolvedAt: Date.now(), physical: true }); + } await (await getSlowRecorder())(packetHash, network, result).catch(() => { /* observability is best-effort */ }); diff --git a/backend/src/path-core/decoder.test.ts b/backend/src/path-core/decoder.test.ts index 23c7de9..4791a0e 100644 --- a/backend/src/path-core/decoder.test.ts +++ b/backend/src/path-core/decoder.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { buildObserverBounds, decodePath, + evaluatePhysicalHop, indexCandidatesByHash, type CandidateNode, type PathDecoderEvidence, @@ -133,3 +134,19 @@ test('builds observer bounds and filters unpositioned or remote candidates', () assert.deepEqual(indexed.get('AA')?.map((candidate) => candidate.nodeId), ['AA-near']); }); + +test('missing elevation never causes a curvature rejection', () => { + const evaluated = evaluatePhysicalHop( + { lat: 51, lon: 0, elevationM: null }, + { lat: 51.72, lon: 0, elevationM: 0 }, + { + maxHopKm: 100, + earthEffectiveRadiusM: 6_371_000 / (1 - 0.25), + behindEarthToleranceKm: 25, + impossibleLinkPathlossDb: 165, + }, + ); + assert.ok(evaluated.distanceKm > 75); + assert.equal(evaluated.horizonMarginKm, null); + assert.equal(evaluated.possible, true); +}); diff --git a/backend/src/path-core/decoder.ts b/backend/src/path-core/decoder.ts index 573f82e..885f82c 100644 --- a/backend/src/path-core/decoder.ts +++ b/backend/src/path-core/decoder.ts @@ -26,6 +26,7 @@ export type CandidateNode = { name: string | null; lat: number; lon: number; + elevationM?: number | null; }; export type CandidateNodeEvidence = { @@ -33,9 +34,26 @@ export type CandidateNodeEvidence = { name: string | null; lat: number | null; lon: number | null; + elevationM?: number | null; }; -export type ObserverAnchor = GeographicPoint & { nodeId?: string }; +export type ObserverAnchor = GeographicPoint & { nodeId?: string; elevationM?: number | null }; + +export type PhysicalHopLimits = { + maxHopKm: number; + earthEffectiveRadiusM: number; + behindEarthToleranceKm: number; + impossibleLinkPathlossDb: number; + pathlossDb?: number | null; + antennaHeightM?: number; +}; + +export type PhysicalHopEvaluation = { + possible: boolean; + distanceKm: number; + horizonMarginKm: number | null; + softMargin: number; +}; export type DecodedHop = { hash: string; @@ -60,6 +78,16 @@ export type PathDecoderEvidence = { directAnchors: (position: number, hash: string) => readonly ObserverAnchor[]; sourceAnchor?: ObserverAnchor; terminalAnchors?: readonly ObserverAnchor[]; + /** Optional fixed node assignment at a trellis position. */ + fixed?: ReadonlyMap; + /** Optional hard-physics gate plus small ranking-only score. */ + physicalTransition?: ( + from: CandidateNode, + to: CandidateNode, + distanceKm: number, + ) => { possible: boolean; score: number }; + /** Endpoint anchors use only hard gates plus the bounded physics tiebreaker. */ + endpointTransitionsGateOnly?: boolean; prefixProbability: (nodeId: string, hash: string, prevHash: string | null) => number; positionPrefixCount?: (position: number, nodeId: string, hash: string) => number; corridorPriorCount?: (position: number, nodeId: string) => number; @@ -90,6 +118,7 @@ type DecoderCandidate = { name: string | null; lat: number | null; lon: number | null; + elevationM?: number | null; }; const UNRESOLVED_CANDIDATE: DecoderCandidate = { @@ -110,6 +139,42 @@ export function distanceKm(a: GeographicPoint, b: GeographicPoint): number { return 2 * earthRadiusKm * Math.asin(Math.sqrt(sinLat + cosA * cosB * sinLon)); } +/** Evaluate calibrated hard-impossibility gates for one radio hop. + * Missing elevation deliberately disables only the curvature gate. */ +export function evaluatePhysicalHop( + from: GeographicPoint & { elevationM?: number | null }, + to: GeographicPoint & { elevationM?: number | null }, + limits: PhysicalHopLimits, +): PhysicalHopEvaluation { + const distance = distanceKm(from, to); + const lossImpossible = limits.pathlossDb != null + && Number.isFinite(limits.pathlossDb) + && limits.pathlossDb > limits.impossibleLinkPathlossDb; + let horizonMarginKm: number | null = null; + if (from.elevationM != null && to.elevationM != null + && Number.isFinite(from.elevationM) && Number.isFinite(to.elevationM)) { + const antennaHeightM = limits.antennaHeightM ?? 15; + const radiusKm = limits.earthEffectiveRadiusM / 1_000; + const fromHeightKm = Math.max(0, from.elevationM + antennaHeightM) / 1_000; + const toHeightKm = Math.max(0, to.elevationM + antennaHeightM) / 1_000; + const horizonKm = Math.sqrt(2 * radiusKm * fromHeightKm) + + Math.sqrt(2 * radiusKm * toHeightKm); + horizonMarginKm = horizonKm - distance; + } + const curvatureImpossible = horizonMarginKm != null + && horizonMarginKm < -limits.behindEarthToleranceKm; + const possible = distance <= limits.maxHopKm && !lossImpossible && !curvatureImpossible; + return { + possible, + distanceKm: distance, + horizonMarginKm, + // Ranking only: bounded so physics cannot swamp the tuned evidence. + softMargin: horizonMarginKm == null + ? 0 + : Math.max(-1, Math.min(1, horizonMarginKm / limits.behindEarthToleranceKm)), + }; +} + function minDistanceToSet(pt: GeographicPoint, anchors: readonly GeographicPoint[]): number { let min = Infinity; for (const anchor of anchors) min = Math.min(min, distanceKm(pt, anchor)); @@ -183,15 +248,30 @@ function emissionScore( if (candidate.nodeId === null) return evidence.unresolvedBaseline; const positioned = candidate.lat != null && candidate.lon != null; + let directPhysicsScore = 0; if (anchors.length > 0) { if (!positioned) return -Infinity; if (minDistanceToSet({ lat: candidate.lat!, lon: candidate.lon! }, anchors) > evidence.maxHopKm) { return -Infinity; } + if (evidence.physicalTransition) { + let bestPhysics = -Infinity; + for (const anchor of anchors) { + const physics = evidence.physicalTransition( + candidate as CandidateNode, + endpointCandidate(anchor) as CandidateNode, + distanceKm(candidate as CandidateNode, anchor), + ); + if (physics.possible) bestPhysics = Math.max(bestPhysics, physics.score); + } + if (!isFinite(bestPhysics)) return -Infinity; + directPhysicsScore = bestPhysics; + } } let score = evidence.weights.prefix * Math.log1p(Math.max(0, evidence.prefixProbability(candidate.nodeId, hash, prevHash))); + score += directPhysicsScore; const mlScore = evidence.mlPrefixScore(hash, candidate.nodeId); score += mlScore >= evidence.mlDominantThreshold ? Math.min(evidence.weights.mlDominantCap, mlScore * evidence.weights.mlDominantCap) @@ -237,6 +317,15 @@ function transitionScore( { lat: current.lat, lon: current.lon }, ); if (distance > evidence.maxHopKm) return -Infinity; + if (evidence.physicalTransition) { + const physics = evidence.physicalTransition( + previous as CandidateNode, + current as CandidateNode, + distance, + ); + if (!physics.possible) return -Infinity; + score += physics.score; + } score -= evidence.weights.dist * (distance / evidence.distanceDecayKm); } score += evidence.weights.edge @@ -262,6 +351,20 @@ function transitionScore( return score; } +function endpointTransitionScore( + from: DecoderCandidate, + to: DecoderCandidate, + evidence: PathDecoderEvidence, +): number { + if (!evidence.endpointTransitionsGateOnly) return transitionScore(0, from, to, evidence); + if (from.lat == null || from.lon == null || to.lat == null || to.lon == null) return -Infinity; + const distance = distanceKm(from as CandidateNode, to as CandidateNode); + if (distance > evidence.maxHopKm) return -Infinity; + if (!evidence.physicalTransition) return 0; + const physics = evidence.physicalTransition(from as CandidateNode, to as CandidateNode, distance); + return physics.possible ? physics.score : -Infinity; +} + function buildColumn( position: number, pathLength: number, @@ -271,6 +374,8 @@ function buildColumn( ): DecoderCandidate[] { const raw = evidence.candidatesByHash.get(hash) ?? []; let candidates: DecoderCandidate[] = raw.map((candidate) => ({ ...candidate })); + const fixedNodeId = evidence.fixed?.get(position); + if (fixedNodeId) candidates = candidates.filter((candidate) => candidate.nodeId === fixedNodeId); if (candidates.length > evidence.maxColumnCandidates) { const anchors = evidence.directAnchors(position, hash); candidates = candidates @@ -294,6 +399,7 @@ function endpointCandidate(anchor: ObserverAnchor): DecoderCandidate { name: null, lat: anchor.lat, lon: anchor.lon, + elevationM: anchor.elevationM, }; } @@ -306,19 +412,67 @@ function bestTerminalTransition( if (terminals.length === 0) return 0; let best = -Infinity; for (const terminal of terminals) { - best = Math.max(best, transitionScore(position, candidate, endpointCandidate(terminal), evidence)); + best = Math.max(best, evidence.endpointTransitionsGateOnly + ? endpointTransitionScore(candidate, endpointCandidate(terminal), evidence) + : transitionScore(position, candidate, endpointCandidate(terminal), evidence)); } return best; } /** Decode a coherent node assignment for every canonical path-hash position. */ -export function decodePath( +export type DecodedPathResult = { hops: Map; score: number }; + +/** Score one fully specified path without running a trellis traversal. */ +export function scoreFixedPath( + canonicalHashes: readonly string[], + nodeIds: readonly string[], + evidence: PathDecoderEvidence, +): number { + if (canonicalHashes.length === 0 || nodeIds.length !== canonicalHashes.length) return -Infinity; + const chosen: DecoderCandidate[] = []; + let score = 0; + for (let position = 0; position < canonicalHashes.length; position++) { + const hash = canonicalHashes[position]!; + const previousHash = position > 0 ? canonicalHashes[position - 1] ?? null : null; + const candidate = (evidence.candidatesByHash.get(hash) ?? []) + .find((value) => value.nodeId === nodeIds[position]); + if (!candidate) return -Infinity; + const emission = emissionScore( + position, + canonicalHashes.length, + hash, + previousHash, + candidate, + evidence.directAnchors(position, hash), + evidence, + ); + if (!isFinite(emission)) return -Infinity; + score += emission; + const previous = chosen[position - 1]; + const transition = previous + ? transitionScore(position, previous, candidate, evidence) + : evidence.sourceAnchor + ? endpointTransitionScore(endpointCandidate(evidence.sourceAnchor), candidate, evidence) + : 0; + if (!isFinite(transition)) return -Infinity; + score += transition; + chosen.push(candidate); + } + const terminal = bestTerminalTransition( + canonicalHashes.length, + chosen[chosen.length - 1]!, + evidence, + ); + return isFinite(terminal) ? score + terminal : -Infinity; +} + +export function decodePathWithScore( canonicalHashes: readonly string[], evidence: PathDecoderEvidence, -): Map { +): DecodedPathResult { const pathLength = canonicalHashes.length; const decoded = new Map(); - if (pathLength === 0) return decoded; + if (pathLength === 0) return { hops: decoded, score: 0 }; const columns: DecoderCandidate[][] = []; const emissions: number[][] = []; @@ -343,7 +497,7 @@ export function decodePath( const forward: number[][] = columns.map((column) => new Array(column.length).fill(-Infinity)); for (let candidate = 0; candidate < columns[0]!.length; candidate++) { const startTransition = evidence.sourceAnchor - ? transitionScore(0, endpointCandidate(evidence.sourceAnchor), columns[0]![candidate]!, evidence) + ? endpointTransitionScore(endpointCandidate(evidence.sourceAnchor), columns[0]![candidate]!, evidence) : 0; if (isFinite(startTransition) && isFinite(emissions[0]![candidate]!)) { forward[0]![candidate] = emissions[0]![candidate]! + startTransition; @@ -432,5 +586,19 @@ export function decodePath( }); } - return decoded; + let score = -Infinity; + for (let candidate = 0; candidate < columns[pathLength - 1]!.length; candidate++) { + const total = forward[pathLength - 1]![candidate]! + + backward[pathLength - 1]![candidate]!; + if (total > score) score = total; + } + return { hops: decoded, score }; +} + +/** Decode a coherent node assignment for every canonical path-hash position. */ +export function decodePath( + canonicalHashes: readonly string[], + evidence: PathDecoderEvidence, +): Map { + return decodePathWithScore(canonicalHashes, evidence).hops; } diff --git a/backend/src/pathing/pathingService.test.ts b/backend/src/pathing/pathingService.test.ts index 4db2a63..ba35956 100644 --- a/backend/src/pathing/pathingService.test.ts +++ b/backend/src/pathing/pathingService.test.ts @@ -48,6 +48,8 @@ test('path history memory entries are bound to the current privacy generation', pathHistoryCacheTtlMs: 60_000, getResolveCache: () => undefined, setResolveCache: () => undefined, + getHeldPath: () => undefined, + setHeldPath: () => undefined, resolvePool: { run: async () => null }, repository, }); diff --git a/backend/src/pathing/pathingService.ts b/backend/src/pathing/pathingService.ts index 8bbb352..39346e9 100644 --- a/backend/src/pathing/pathingService.ts +++ b/backend/src/pathing/pathingService.ts @@ -1,4 +1,5 @@ import type { PathingRepository } from './pathingRepository.js'; +import type { HeldPathEntry } from '../path-beta/resolveCache.js'; import { toPublicBetaResultDto, toPublicMultiObserverDto, @@ -9,8 +10,8 @@ import { type ResolvePoolFn = { run( job: - | { type: 'resolve'; packetHash: string; network: string; observer?: string | null } - | { type: 'resolveMulti'; packetHash: string; network: string }, + | { type: 'resolve'; packetHash: string; network: string; observer?: string | null; heldPath?: HeldPathEntry } + | { type: 'resolveMulti'; packetHash: string; network: string; heldPath?: HeldPathEntry }, ): Promise; }; @@ -24,6 +25,8 @@ type PathingServiceDeps = { pathHistoryCacheTtlMs: number; getResolveCache: (key: string) => unknown; setResolveCache: (key: string, value: unknown) => void; + getHeldPath: (packetHash: string, network: string) => HeldPathEntry | undefined; + setHeldPath: (packetHash: string, network: string, value: HeldPathEntry) => void; resolvePool: ResolvePoolFn; repository: PathingRepository; }; @@ -121,6 +124,8 @@ export function createPathingService(deps: PathingServiceDeps) { pathHistoryCacheTtlMs, getResolveCache, setResolveCache, + getHeldPath, + setHeldPath, resolvePool, repository, } = deps; @@ -140,12 +145,17 @@ export function createPathingService(deps: PathingServiceDeps) { packetHash, network, observer, + heldPath: getHeldPath(packetHash, network), }); if (!resolved) { throw new Error('PACKET_NOT_FOUND'); } const projected = toPublicBetaResultDto(addPathExplanation(resolved)); + const heldNodes = projected.canonicalPath.map((hop) => hop.nodeId ?? '').filter(Boolean); + if (heldNodes.length === projected.canonicalPath.length && heldNodes.length > 0) { + setHeldPath(packetHash, network, { path: heldNodes, resolvedAt: Date.now(), physical: true }); + } setResolveCache(cacheKey, projected); return projected; })(); @@ -174,12 +184,17 @@ export function createPathingService(deps: PathingServiceDeps) { type: 'resolveMulti', packetHash, network, + heldPath: getHeldPath(packetHash, network), }); if (!resolved) { throw new Error('PACKET_NOT_FOUND'); } const projected = toPublicMultiObserverDto(addPathExplanation(resolved)); + const heldNodes = projected.canonicalPath.map((hop) => hop.nodeId ?? '').filter(Boolean); + if (heldNodes.length === projected.canonicalPath.length && heldNodes.length > 0) { + setHeldPath(packetHash, network, { path: heldNodes, resolvedAt: Date.now(), physical: true }); + } setResolveCache(cacheKey, projected); return projected; })(); diff --git a/backend/src/platform/config/pathing.ts b/backend/src/platform/config/pathing.ts index 661a74c..4ba3f4d 100644 --- a/backend/src/platform/config/pathing.ts +++ b/backend/src/platform/config/pathing.ts @@ -2,6 +2,11 @@ export const pathingConfig = { maxBetaHops: 25, purpleThreshold: 0.45, earthEffectiveRadiusM: 6_371_000 / (1 - 0.25), + defaultAntennaHeightM: 15, + behindEarthToleranceKm: 25, + // Physics is a bounded tiebreaker; full-strength transition physics reduced + // the bake-off accuracy by overwhelming the tuned evidence model. + physicsSoftMarginWeight: 0.05, prefixAmbiguityFloorKm: 45, weakLinkPathlossMaxDb: 137.5, looseLinkPathlossMaxDb: 146.0, diff --git a/frontend/src/hooks/usePacketPathOverlay.ts b/frontend/src/hooks/usePacketPathOverlay.ts index d97b5f6..e605a94 100644 --- a/frontend/src/hooks/usePacketPathOverlay.ts +++ b/frontend/src/hooks/usePacketPathOverlay.ts @@ -275,6 +275,7 @@ export function usePacketPathOverlay({ ): Promise => { prunePredictionCache(); const key = cacheKey(packetHash, observerIds, networkName); + const inflightKey = mode === 'slow' ? `${key}|slow` : key; // Slow-mode requests must not be served from the eager cache or deduped // onto an in-flight eager fetch — the whole point is the post-window set. if (mode !== 'slow') { @@ -282,9 +283,9 @@ export function usePacketPathOverlay({ if (cached && cached.ts >= minFreshTs && Date.now() - cached.ts <= PREDICTION_CACHE_TTL_MS) { return Promise.resolve(cached.response); } - const inflight = multiInflightRef.current.get(key); - if (inflight) return inflight; } + const inflight = multiInflightRef.current.get(inflightKey); + if (inflight) return inflight; const fetchFn: () => Promise = mode === 'slow' ? () => fetchServerBetaMultiSlow(packetHash, networkName) @@ -300,9 +301,9 @@ export function usePacketPathOverlay({ }) .catch(() => null) .finally(() => { - multiInflightRef.current.delete(key); + multiInflightRef.current.delete(inflightKey); }); - multiInflightRef.current.set(key, promise); + multiInflightRef.current.set(inflightKey, promise); return promise; }, [prunePredictionCache]); @@ -341,7 +342,9 @@ export function usePacketPathOverlay({ if (filters.betaPaths && latest?.packetHash && latest.path?.length && observerIds.length > 0) { const reqSeq = ++activeReqSeqRef.current; - void resolveMultiPrediction(latest.packetHash, observerIds, network, latest.ts) + // The feed renders its local path immediately, then upgrades once the + // bounded slow-mode request sees the complete propagation window. + void resolveMultiPrediction(latest.packetHash, observerIds, network, latest.ts, 'slow') .then((response) => { if (reqSeq !== activeReqSeqRef.current) return; applyServerPrediction(latest.packetHash, response); @@ -434,10 +437,9 @@ export function usePacketPathOverlay({ if (filters.betaPaths && pinnedPacket.packetHash && pinnedPacket.path?.length && observerIds.length > 0) { const reqSeq = ++activeReqSeqRef.current; - // Pinned packet: slow mode — wait out the propagation window so the - // path is resolved against the COMPLETE observer set. The local path - // (built above) renders meanwhile; this upgrades it once final. - void resolveMultiPrediction(pinnedPacket.packetHash, observerIds, network, pinnedPacket.ts, 'slow') + // Pinned/detail views use the fast path; the local path renders while + // the server prediction is fetched. + void resolveMultiPrediction(pinnedPacket.packetHash, observerIds, network, pinnedPacket.ts) .then((response) => { if (reqSeq !== activeReqSeqRef.current) return; applyServerPrediction(pinnedPacket.packetHash!, response);