feat(pathing): add physics gates and held-path refinement

This commit is contained in:
gadgethd committed 2026-08-04 11:04:04 +01:00
1 parent 469c84ed13
commit 5dd15d94c1
18 files changed
+592 -38

No files matched your search

+3 -1
View File
@@ -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,
+2
View File
@@ -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,
+7 -2
View File
@@ -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<T>(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<T | null>;
};
@@ -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<number>;
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,
});
+6
View File
@@ -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,
+10 -2
View File
@@ -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<void> {
const stickyEntry = getStickyNodeMap(finalHash, network);
const stickyMap = stickyEntry ? Object.fromEntries(stickyEntry.hashToNodeId) : undefined;
const stickyAgeFraction = stickyEntry?.ageFraction;
resolvePool.runBackground<{ stickyUpdates?: Record<string, string> }>({ type: 'resolveMulti', packetHash: finalHash, network, ...(stickyMap ? { stickyMap, stickyAgeFraction } : {}) })
const heldPath = getHeldPath(finalHash, network);
resolvePool.runBackground<{
stickyUpdates?: Record<string, string>;
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<void> {
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 */ })
@@ -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,
});
});
+30
View File
@@ -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<string, HeldPathEntry>({
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
+27 -3
View File
@@ -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<string, string>;
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 }); });
});
+25 -2
View File
@@ -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<string, BetaResolveContext>({
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);
@@ -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');
});
+185 -10
View File
@@ -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<number, DecodedHop>;
hopConfidences: ReadonlyMap<number, number>;
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<string, ObserverAnchor[
const anchors = new Map<string, ObserverAnchor[]>();
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<string, string>,
stickyAgeFraction?: number,
fixed?: ReadonlyMap<number, string>,
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<number, number>();
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<number, DecodedHop>();
const hopConfidences = new Map<number, number>();
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<string, string>,
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<number, string>();
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 = {
+13
View File
@@ -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<string, string>;
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 */
});
+17
View File
@@ -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);
});
+175 -7
View File
@@ -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<number, string>;
/** 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<number, DecodedHop>; 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<number, DecodedHop> {
): DecodedPathResult {
const pathLength = canonicalHashes.length;
const decoded = new Map<number, DecodedHop>();
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<number, DecodedHop> {
return decodePathWithScore(canonicalHashes, evidence).hops;
}
@@ -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,
});
+17 -2
View File
@@ -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<T>(
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<T | null>;
};
@@ -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;
})();
+5
View File
@@ -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,
+11 -9
View File
@@ -275,6 +275,7 @@ export function usePacketPathOverlay({
): Promise<MultiObserverBetaResponse | null> => {
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<MultiObserverBetaResponse | null> = 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);