mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-22 05:52:29 +00:00
feat(pathing): add physics gates and held-path refinement
This commit is contained in:
1 parent
469c84ed13
commit
5dd15d94c1
18 files changed
+592
-38
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Vendored
+6
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 }); });
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 */
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user