From bc1cf1b0712d06da23dff87068fcaf9da04bad14 Mon Sep 17 00:00:00 2001 From: gadgethd <111318106+gadgethd@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:10:10 +0100 Subject: [PATCH] refactor(pathing): extract shared Viterbi decoder --- backend/src/path-core/decoder.test.ts | 112 ++++++++ backend/src/path-core/decoder.ts | 378 ++++++++++++++++++++++++++ backend/src/path-lazy/lazyResolver.ts | 254 +++-------------- backend/src/path-shared/scoring.ts | 45 ++- 4 files changed, 567 insertions(+), 222 deletions(-) create mode 100644 backend/src/path-core/decoder.test.ts create mode 100644 backend/src/path-core/decoder.ts diff --git a/backend/src/path-core/decoder.test.ts b/backend/src/path-core/decoder.test.ts new file mode 100644 index 0000000..f177042 --- /dev/null +++ b/backend/src/path-core/decoder.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildObserverBounds, + decodePath, + indexCandidatesByHash, + type CandidateNode, + type PathDecoderEvidence, +} from './decoder.js'; +import { + AMBIG_DELTA, + DIST_DECAY_KM, + MAX_COL, + MAX_HOP_KM, + ML_DOMINANT_THRESHOLD, + NULL_BASELINE, + SCORE, +} from '../path-shared/scoring.js'; + +const node = (nodeId: string, lat: number, lon: number): CandidateNode => ({ + nodeId, + name: nodeId, + lat, + lon, +}); + +function evidence( + candidatesByHash: ReadonlyMap, + overrides: Partial = {}, +): PathDecoderEvidence { + return { + weights: SCORE, + maxHopKm: MAX_HOP_KM, + distanceDecayKm: DIST_DECAY_KM, + mlDominantThreshold: ML_DOMINANT_THRESHOLD, + unresolvedBaseline: NULL_BASELINE, + ambiguityDelta: AMBIG_DELTA, + maxColumnCandidates: MAX_COL, + candidatesByHash, + directAnchors: () => [], + prefixProbability: () => 0, + mlPrefixScore: () => 0, + directedEdgeScore: () => 0, + transitionProbability: () => 0, + motifProbability: () => 0, + hasObservedLink: () => false, + ...overrides, + }; +} + +test('decodes the strongest coherent chain from supplied evidence', () => { + const candidates = new Map([ + ['AA', [node('A', 51, 0), node('B', 51, 0)]], + ['BB', [node('C', 51.1, 0), node('D', 51.1, 0)]], + ]); + const decoded = decodePath(['AA', 'BB'], evidence(candidates, { + prefixProbability: (nodeId) => ({ A: 0.2, B: 0.3, C: 0.2, D: 0.3 })[nodeId] ?? 0, + transitionProbability: (from, to) => from === 'A' && to === 'C' ? 1 : 0, + corridorInterpolation: () => { throw new Error('zero-weight corridor evidence was read'); }, + positionConditionalTransition: () => { + throw new Error('zero-weight position-transition evidence was read'); + }, + isItmViable: () => { throw new Error('zero-weight ITM evidence was read'); }, + })); + + assert.equal(decoded.get(0)?.nodeId, 'A'); + assert.equal(decoded.get(1)?.nodeId, 'C'); +}); + +test('prefers the unresolved baseline over an unsupported candidate', () => { + const decoded = decodePath( + ['AA'], + evidence(new Map([['AA', [node('A', 51, 0)]]])), + ); + assert.equal(decoded.get(0)?.nodeId, null); + assert.equal(decoded.get(0)?.ambiguous, false); +}); + +test('hard-gates candidates outside a direct observer anchor hop', () => { + const decoded = decodePath( + ['AA'], + evidence(new Map([['AA', [node('near', 51, 1), node('far', 55, 1)]]]), { + directAnchors: () => [{ lat: 51, lon: 1 }], + prefixProbability: (nodeId) => nodeId === 'far' ? 1 : 0.2, + }), + ); + assert.equal(decoded.get(0)?.nodeId, 'near'); +}); + +test('returns the marginal gap and flags a near-equal real candidate', () => { + const decoded = decodePath( + ['AA'], + evidence(new Map([['AA', [node('A', 51, 0), node('B', 51, 0)]]]), { + prefixProbability: (nodeId) => nodeId === 'A' ? 0.30 : 0.27, + }), + ); + const hop = decoded.get(0)!; + assert.equal(hop.nodeId, 'A'); + assert.ok(Math.abs(hop.margin - 0.009) < 1e-12); + assert.equal(hop.ambiguous, true); +}); + +test('builds observer bounds and filters unpositioned or remote candidates', () => { + const bounds = buildObserverBounds([{ lat: 51, lon: 1 }], MAX_HOP_KM); + const indexed = indexCandidatesByHash(['AA'], [ + { nodeId: 'AA-near', name: null, lat: 51.1, lon: 1 }, + { nodeId: 'AA-remote', name: null, lat: 60, lon: 1 }, + { nodeId: 'AA-zero', name: null, lat: 0, lon: 0 }, + ], bounds); + + assert.deepEqual(indexed.get('AA')?.map((candidate) => candidate.nodeId), ['AA-near']); +}); diff --git a/backend/src/path-core/decoder.ts b/backend/src/path-core/decoder.ts new file mode 100644 index 0000000..5c9913a --- /dev/null +++ b/backend/src/path-core/decoder.ts @@ -0,0 +1,378 @@ +/** + * Evidence-driven Viterbi decoder shared by every path-resolution surface. + * + * This module is deliberately database-agnostic. Callers load candidate nodes, + * priors, ML scores, observer anchors, and optional experimental evidence, then + * expose them through {@link PathDecoderEvidence}. The decoder owns geographic + * candidate bounding/capping, emissions, transitions, the unresolved baseline, + * max-product trellis traversal, and per-position margin/ambiguity calculation. + */ +import type { PathScoreWeights } from '../path-shared/scoring.js'; + +export type GeographicPoint = { lat: number; lon: number }; + +export type GeographicBounds = { + minLat: number; + maxLat: number; + minLon: number; + maxLon: number; +}; + +export type CandidateNode = { + nodeId: string; + name: string | null; + lat: number; + lon: number; +}; + +export type CandidateNodeEvidence = { + nodeId: string; + name: string | null; + lat: number | null; + lon: number | null; +}; + +export type ObserverAnchor = GeographicPoint & { nodeId?: string }; + +export type DecodedHop = { + hash: string; + nodeId: string | null; + name: string | null; + lat: number | null; + lon: number | null; + /** Best-chain score gap to the next-best candidate at this position. */ + margin: number; + ambiguous: boolean; +}; + +export type PathDecoderEvidence = { + weights: PathScoreWeights; + maxHopKm: number; + distanceDecayKm: number; + mlDominantThreshold: number; + unresolvedBaseline: number; + ambiguityDelta: number; + maxColumnCandidates: number; + candidatesByHash: ReadonlyMap; + directAnchors: (position: number, hash: string) => readonly ObserverAnchor[]; + prefixProbability: (nodeId: string, hash: string, prevHash: string | null) => number; + mlPrefixScore: (hash: string, nodeId: string) => number; + directedEdgeScore: (fromNodeId: string, toNodeId: string) => number; + transitionProbability: (fromNodeId: string, toNodeId: string) => number; + motifProbability: (fromNodeId: string, toNodeId: string) => number; + hasObservedLink: (fromNodeId: string, toNodeId: string) => boolean; + /** Optional Phase-4 emission evidence. Return a normalized candidate fit. */ + corridorInterpolation?: ( + position: number, + pathLength: number, + candidate: CandidateNode, + ) => number; + /** Optional Phase-4 transition evidence for (position, from)→to. */ + positionConditionalTransition?: ( + position: number, + fromNodeId: string, + toNodeId: string, + ) => number; + /** Optional Phase-4 bonus evidence. False is neutral, never a hard gate. */ + isItmViable?: (fromNodeId: string, toNodeId: string) => boolean; +}; + +type DecoderCandidate = { + nodeId: string | null; + name: string | null; + lat: number | null; + lon: number | null; +}; + +const UNRESOLVED_CANDIDATE: DecoderCandidate = { + nodeId: null, + name: null, + lat: null, + lon: null, +}; + +export function distanceKm(a: GeographicPoint, b: GeographicPoint): number { + const earthRadiusKm = 6371; + const dLat = (b.lat - a.lat) * (Math.PI / 180); + const dLon = (b.lon - a.lon) * (Math.PI / 180); + const sinLat = Math.sin(dLat / 2) ** 2; + const sinLon = Math.sin(dLon / 2) ** 2; + const cosA = Math.cos(a.lat * (Math.PI / 180)); + const cosB = Math.cos(b.lat * (Math.PI / 180)); + return 2 * earthRadiusKm * Math.asin(Math.sqrt(sinLat + cosA * cosB * sinLon)); +} + +function minDistanceToSet(pt: GeographicPoint, anchors: readonly GeographicPoint[]): number { + let min = Infinity; + for (const anchor of anchors) min = Math.min(min, distanceKm(pt, anchor)); + return min; +} + +function isInBounds(pt: GeographicPoint, bounds: GeographicBounds): boolean { + return pt.lat >= bounds.minLat && pt.lat <= bounds.maxLat + && pt.lon >= bounds.minLon && pt.lon <= bounds.maxLon; +} + +/** Build the observer-region bound used to reject geographically remote candidates. */ +export function buildObserverBounds( + observerPositions: readonly GeographicPoint[], + paddingKm: number, +): GeographicBounds | null { + if (observerPositions.length === 0) return null; + const lats = observerPositions.map((position) => position.lat); + const lons = observerPositions.map((position) => position.lon); + const padLat = paddingKm / 111; + const midLat = (Math.min(...lats) + Math.max(...lats)) / 2; + const padLon = paddingKm / (111 * Math.cos(midLat * (Math.PI / 180))); + return { + minLat: Math.min(...lats) - padLat, + maxLat: Math.max(...lats) + padLat, + minLon: Math.min(...lons) - padLon, + maxLon: Math.max(...lons) + padLon, + }; +} + +/** + * Convert loaded candidate evidence into hash-indexed decoder candidates while + * applying the same coordinate and observer-region gates as the lazy resolver. + */ +export function indexCandidatesByHash( + hashes: readonly string[], + candidates: readonly CandidateNodeEvidence[], + bounds: GeographicBounds | null, +): Map { + const result = new Map(); + for (const candidate of candidates) { + if (candidate.lat == null || candidate.lon == null + || candidate.lat === 0 || candidate.lon === 0) continue; + const point = { lat: candidate.lat, lon: candidate.lon }; + if (bounds && !isInBounds(point, bounds)) continue; + const upperNodeId = candidate.nodeId.toUpperCase(); + for (const hash of hashes) { + if (!upperNodeId.startsWith(hash)) continue; + if (!result.has(hash)) result.set(hash, []); + result.get(hash)!.push({ + nodeId: candidate.nodeId, + name: candidate.name, + lat: candidate.lat, + lon: candidate.lon, + }); + break; + } + } + return result; +} + +function emissionScore( + position: number, + pathLength: number, + hash: string, + prevHash: string | null, + candidate: DecoderCandidate, + anchors: readonly ObserverAnchor[], + evidence: PathDecoderEvidence, +): number { + if (candidate.nodeId === null) return evidence.unresolvedBaseline; + + const positioned = candidate.lat != null && candidate.lon != null; + if (anchors.length > 0) { + if (!positioned) return -Infinity; + if (minDistanceToSet({ lat: candidate.lat!, lon: candidate.lon! }, anchors) > evidence.maxHopKm) { + return -Infinity; + } + } + + let score = evidence.weights.prefix + * evidence.prefixProbability(candidate.nodeId, hash, prevHash); + const mlScore = evidence.mlPrefixScore(hash, candidate.nodeId); + score += mlScore >= evidence.mlDominantThreshold + ? Math.min(evidence.weights.mlDominantCap, mlScore * evidence.weights.mlDominantCap) + : Math.min(evidence.weights.mlWeakCap, mlScore * evidence.weights.mlWeakCap); + + if (anchors.length > 0 && positioned) { + const distance = minDistanceToSet({ lat: candidate.lat!, lon: candidate.lon! }, anchors); + score += evidence.weights.anchor * Math.max(0, 1 - distance / evidence.maxHopKm); + } + + if (evidence.weights.corridorInterpolation !== 0 && evidence.corridorInterpolation) { + score += evidence.weights.corridorInterpolation + * evidence.corridorInterpolation(position, pathLength, candidate as CandidateNode); + } + return score; +} + +function transitionScore( + position: number, + previous: DecoderCandidate, + current: DecoderCandidate, + evidence: PathDecoderEvidence, +): number { + if (previous.nodeId === null || current.nodeId === null) return 0; + let score = 0; + if (previous.lat != null && previous.lon != null && current.lat != null && current.lon != null) { + const distance = distanceKm( + { lat: previous.lat, lon: previous.lon }, + { lat: current.lat, lon: current.lon }, + ); + if (distance > evidence.maxHopKm) return -Infinity; + score += evidence.weights.dist * Math.exp(-distance / evidence.distanceDecayKm); + } + score += evidence.weights.edge + * evidence.directedEdgeScore(previous.nodeId, current.nodeId); + score += evidence.weights.transition + * evidence.transitionProbability(previous.nodeId, current.nodeId); + score += evidence.weights.motif + * evidence.motifProbability(previous.nodeId, current.nodeId); + if (evidence.hasObservedLink(previous.nodeId, current.nodeId)) { + score += evidence.weights.link; + } + if (evidence.weights.positionConditionalTransition !== 0 + && evidence.positionConditionalTransition) { + score += evidence.weights.positionConditionalTransition + * evidence.positionConditionalTransition(position, previous.nodeId, current.nodeId); + } + if (evidence.weights.itmViability !== 0 + && evidence.isItmViable?.(previous.nodeId, current.nodeId)) { + score += evidence.weights.itmViability; + } + return score; +} + +function buildColumn( + position: number, + pathLength: number, + hash: string, + prevHash: string | null, + evidence: PathDecoderEvidence, +): DecoderCandidate[] { + const raw = evidence.candidatesByHash.get(hash) ?? []; + let candidates: DecoderCandidate[] = raw.map((candidate) => ({ ...candidate })); + if (candidates.length > evidence.maxColumnCandidates) { + const anchors = evidence.directAnchors(position, hash); + candidates = candidates + .map((candidate) => ({ + candidate, + score: emissionScore(position, pathLength, hash, prevHash, candidate, anchors, evidence), + })) + .sort((a, b) => b.score - a.score) + .slice(0, evidence.maxColumnCandidates) + .map(({ candidate }) => candidate); + } + candidates.push(UNRESOLVED_CANDIDATE); + return candidates; +} + +/** Decode a coherent node assignment for every canonical path-hash position. */ +export function decodePath( + canonicalHashes: readonly string[], + evidence: PathDecoderEvidence, +): Map { + const pathLength = canonicalHashes.length; + const decoded = new Map(); + if (pathLength === 0) return decoded; + + const columns: DecoderCandidate[][] = []; + const emissions: number[][] = []; + for (let position = 0; position < pathLength; position++) { + const hash = canonicalHashes[position]!; + const prevHash = position > 0 ? (canonicalHashes[position - 1] ?? null) : null; + const anchors = evidence.directAnchors(position, hash); + const column = buildColumn(position, pathLength, hash, prevHash, evidence); + columns.push(column); + emissions.push(column.map((candidate) => emissionScore( + position, + pathLength, + hash, + prevHash, + candidate, + anchors, + evidence, + ))); + } + + // Best prefix score ending at columns[position][candidate]. + const forward: number[][] = columns.map((column) => new Array(column.length).fill(-Infinity)); + for (let candidate = 0; candidate < columns[0]!.length; candidate++) { + forward[0]![candidate] = emissions[0]![candidate]!; + } + for (let position = 1; position < pathLength; position++) { + for (let current = 0; current < columns[position]!.length; current++) { + const currentEmission = emissions[position]![current]!; + if (!isFinite(currentEmission)) continue; + let best = -Infinity; + for (let previous = 0; previous < columns[position - 1]!.length; previous++) { + if (!isFinite(forward[position - 1]![previous]!)) continue; + const transition = transitionScore( + position, + columns[position - 1]![previous]!, + columns[position]![current]!, + evidence, + ); + if (!isFinite(transition)) continue; + const total = forward[position - 1]![previous]! + transition; + if (total > best) best = total; + } + if (isFinite(best)) forward[position]![current] = best + currentEmission; + } + } + + // Best suffix score starting immediately after columns[position][candidate]. + const backward: number[][] = columns.map((column) => new Array(column.length).fill(-Infinity)); + for (let candidate = 0; candidate < columns[pathLength - 1]!.length; candidate++) { + backward[pathLength - 1]![candidate] = 0; + } + for (let position = pathLength - 2; position >= 0; position--) { + for (let current = 0; current < columns[position]!.length; current++) { + let best = -Infinity; + for (let next = 0; next < columns[position + 1]!.length; next++) { + const nextEmission = emissions[position + 1]![next]!; + if (!isFinite(nextEmission) || !isFinite(backward[position + 1]![next]!)) continue; + const transition = transitionScore( + position + 1, + columns[position]![current]!, + columns[position + 1]![next]!, + evidence, + ); + if (!isFinite(transition)) continue; + const total = transition + nextEmission + backward[position + 1]![next]!; + if (total > best) best = total; + } + backward[position]![current] = isFinite(best) ? best : 0; + } + } + + for (let position = 0; position < pathLength; position++) { + const hash = canonicalHashes[position]!; + let bestCandidate = -1; + let bestMarginal = -Infinity; + let secondMarginal = -Infinity; + for (let candidate = 0; candidate < columns[position]!.length; candidate++) { + if (!isFinite(forward[position]![candidate]!) + || !isFinite(backward[position]![candidate]!)) continue; + const marginal = forward[position]![candidate]! + backward[position]![candidate]!; + if (marginal > bestMarginal) { + secondMarginal = bestMarginal; + bestMarginal = marginal; + bestCandidate = candidate; + } else if (marginal > secondMarginal) { + secondMarginal = marginal; + } + } + const best = bestCandidate >= 0 + ? columns[position]![bestCandidate]! + : UNRESOLVED_CANDIDATE; + const margin = isFinite(secondMarginal) ? bestMarginal - secondMarginal : Infinity; + decoded.set(position, { + hash, + nodeId: best.nodeId, + name: best.name, + lat: best.lat, + lon: best.lon, + margin, + ambiguous: best.nodeId !== null && isFinite(secondMarginal) + && margin < evidence.ambiguityDelta, + }); + } + + return decoded; +} diff --git a/backend/src/path-lazy/lazyResolver.ts b/backend/src/path-lazy/lazyResolver.ts index 6afa79c..ba0effb 100644 --- a/backend/src/path-lazy/lazyResolver.ts +++ b/backend/src/path-lazy/lazyResolver.ts @@ -39,14 +39,15 @@ * path at position = path_hashes.length (i.e. right after the last relay * it heard through). Observer positions are ground-truth. * - * Scoring weights and prior key-formats live in ../path-shared/scoring.ts so - * the lazy and beta resolvers cannot silently drift apart. + * Decode mechanics live in ../path-core/decoder.ts. This wrapper owns database + * evidence loading, observer grouping, and the public lazy-path DTO only. */ import { privateNodePacketNetworkMatchSql } from '../privacy/networkScope.js'; import { MAX_HOP_KM, SCORE, ML_DOMINANT_THRESHOLD, + ML_SCORE_LOAD_THRESHOLD, DIST_DECAY_KM, NULL_BASELINE, AMBIG_DELTA, @@ -54,6 +55,14 @@ import { transitionKey, motif2Key, } from '../path-shared/scoring.js'; +import { + buildObserverBounds, + decodePath, + distanceKm, + indexCandidatesByHash, + type CandidateNodeEvidence, + type PathDecoderEvidence, +} from '../path-core/decoder.js'; import { expandResolverScope } from '../networks.js'; type QueryFn = = Record>( @@ -123,36 +132,6 @@ function networkScope(network: string | null): string[] | null { return network == null ? null : expandResolverScope(network); } -function distKm( - a: { lat: number; lon: number }, - b: { lat: number; lon: number }, -): number { - const R = 6371; - const dLat = (b.lat - a.lat) * (Math.PI / 180); - const dLon = (b.lon - a.lon) * (Math.PI / 180); - const sinLat = Math.sin(dLat / 2) ** 2; - const sinLon = Math.sin(dLon / 2) ** 2; - const cosA = Math.cos(a.lat * (Math.PI / 180)); - const cosB = Math.cos(b.lat * (Math.PI / 180)); - return 2 * R * Math.asin(Math.sqrt(sinLat + cosA * cosB * sinLon)); -} - -function minDistToSet( - pt: { lat: number; lon: number }, - anchors: Array<{ lat: number; lon: number }>, -): number { - let min = Infinity; - for (const a of anchors) min = Math.min(min, distKm(pt, a)); - return min; -} - -type Bounds = { minLat: number; maxLat: number; minLon: number; maxLon: number }; - -function inBounds(pt: { lat: number; lon: number }, b: Bounds): boolean { - return pt.lat >= b.minLat && pt.lat <= b.maxLat && - pt.lon >= b.minLon && pt.lon <= b.maxLon; -} - type ObsEntry = { rx_node_id: string; path_hashes: string[]; // normalized uppercase @@ -284,21 +263,8 @@ export async function lazyResolvePath( } // ── 4. Bounding box from all observer positions ───────────────────────── - let observerBounds: Bounds | null = null; const obsCoords = [...observerPositions.values()]; - if (obsCoords.length > 0) { - const lats = obsCoords.map((p) => p.lat); - const lons = obsCoords.map((p) => p.lon); - const padLat = MAX_HOP_KM / 111; - const midLat = (Math.min(...lats) + Math.max(...lats)) / 2; - const padLon = MAX_HOP_KM / (111 * Math.cos(midLat * (Math.PI / 180))); - observerBounds = { - minLat: Math.min(...lats) - padLat, - maxLat: Math.max(...lats) + padLat, - minLon: Math.min(...lons) - padLon, - maxLon: Math.max(...lons) + padLon, - }; - } + const observerBounds = buildObserverBounds(obsCoords, MAX_HOP_KM); // ── 5. Group observers by prefix-compatible path_hashes ───────────────── const obsEntries: ObsEntry[] = canonicalObs.rows.map((row) => { @@ -334,7 +300,7 @@ export async function lazyResolvePath( const allUniqueHashes = [...new Set(groups.flatMap((g) => g.canonicalHashes))]; if (allUniqueHashes.length > LAZY_MAX_UNIQUE_HASHES) throw new Error('PATH_HISTORY_LIMIT'); - const nodesByHash = new Map>(); + let candidateNodeEvidence: CandidateNodeEvidence[] = []; if (allUniqueHashes.length > 0) { const whereClauses = allUniqueHashes.map((_, i) => `upper(node_id) LIKE $${i + 2}`); @@ -354,20 +320,14 @@ export async function lazyResolvePath( [scopedNetworks, ...allUniqueHashes.map((h) => h + '%'), LAZY_MAX_CANDIDATE_NODES + 1], ); if (nodeResult.rows.length > LAZY_MAX_CANDIDATE_NODES) throw new Error('PATH_HISTORY_LIMIT'); - - for (const node of nodeResult.rows) { - if (node.lat == null || node.lon == null || node.lat === 0 || node.lon === 0) continue; - if (observerBounds && !inBounds(node as { lat: number; lon: number }, observerBounds)) continue; - const id = node.node_id.toUpperCase(); - for (const hash of allUniqueHashes) { - if (id.startsWith(hash)) { - if (!nodesByHash.has(hash)) nodesByHash.set(hash, []); - nodesByHash.get(hash)!.push({ nodeId: node.node_id, name: node.name, lat: node.lat!, lon: node.lon! }); - break; - } - } - } + candidateNodeEvidence = nodeResult.rows.map((node) => ({ + nodeId: node.node_id, + name: node.name, + lat: node.lat, + lon: node.lon, + })); } + const nodesByHash = indexCandidatesByHash(allUniqueHashes, candidateNodeEvidence, observerBounds); // ── 7. Global cross-group direct anchor map ───────────────────────────── // Key: `${position}:${hash}`. An observer with hop_count = P+1 received @@ -443,8 +403,8 @@ export async function lazyResolvePath( ? query<{ hash_2char: string; node_id: string; score: string; observation_count: string }>( `SELECT hash_2char, node_id, score::text, observation_count::text FROM ml_path_prefix_scores - WHERE ($1::text[] IS NULL OR network = ANY($1)) AND hash_2char = ANY($2) AND score >= 0.80`, - [scopedNetworks, allUnique2charHashes], + WHERE ($1::text[] IS NULL OR network = ANY($1)) AND hash_2char = ANY($2) AND score >= $3`, + [scopedNetworks, allUnique2charHashes, ML_SCORE_LOAD_THRESHOLD], ) : Promise.resolve({ rows: [] as { hash_2char: string; node_id: string; score: string; observation_count: string }[] }), uniqueCandidateNodeIds.length > 0 @@ -556,157 +516,25 @@ export async function lazyResolvePath( confidenceBias = Number(chosen.confidence_bias) || 0; } - // ── 9. Decode each group into a LazyPath via a per-group Viterbi ───────── - type Cand = { nodeId: string | null; name: string | null; lat: number | null; lon: number | null }; - const NULL_CAND: Cand = { nodeId: null, name: null, lat: null, lon: null }; - - type ResolvedEntry = { - hash: string; - nodeId: string | null; - name: string | null; - lat: number | null; - lon: number | null; - ambiguous: boolean; + const decoderEvidence: PathDecoderEvidence = { + weights: SCORE, + maxHopKm: MAX_HOP_KM, + distanceDecayKm: DIST_DECAY_KM, + mlDominantThreshold: ML_DOMINANT_THRESHOLD, + unresolvedBaseline: NULL_BASELINE, + ambiguityDelta: AMBIG_DELTA, + maxColumnCandidates: MAX_COL, + candidatesByHash: nodesByHash, + directAnchors: (position, hash) => globalDirectAnchors.get(`${position}:${hash}`) ?? [], + prefixProbability: getPrefixProb, + mlPrefixScore: getMlScore, + directedEdgeScore: getEdgeDirected, + transitionProbability: getTransitionProb, + motifProbability: getMotif2, + hasObservedLink: hasLink, }; - // Emission: how well `cand` fits the hash on its own. - function emission(hash: string, prevHash: string | null, cand: Cand, anchors: Array<{ lat: number; lon: number }>): number { - if (cand.nodeId === null) return NULL_BASELINE; - const positioned = cand.lat != null && cand.lon != null; - // Hard direct-receiver gate: when an anchor exists for this position the - // relay must be positioned and within one hop of the observer. - if (anchors.length > 0) { - if (!positioned) return -Infinity; - if (minDistToSet({ lat: cand.lat!, lon: cand.lon! }, anchors) > MAX_HOP_KM) return -Infinity; - } - let e = 0; - e += SCORE.prefix * getPrefixProb(cand.nodeId, hash, prevHash); - const ml = getMlScore(hash, cand.nodeId); - e += ml >= ML_DOMINANT_THRESHOLD ? Math.min(SCORE.mlDominantCap, ml * SCORE.mlDominantCap) - : Math.min(SCORE.mlWeakCap, ml * SCORE.mlWeakCap); - if (anchors.length > 0 && positioned) { - const d = minDistToSet({ lat: cand.lat!, lon: cand.lon! }, anchors); - e += SCORE.anchor * Math.max(0, 1 - d / MAX_HOP_KM); - } - return e; - } - - // Transition: how well `cur` (receiver-side) follows `prev` (source-side). - function transition(prev: Cand, cur: Cand): number { - if (prev.nodeId === null || cur.nodeId === null) return 0; // unresolved wildcard, neutral - let t = 0; - if (prev.lat != null && prev.lon != null && cur.lat != null && cur.lon != null) { - const d = distKm({ lat: prev.lat, lon: prev.lon }, { lat: cur.lat, lon: cur.lon }); - if (d > MAX_HOP_KM) return -Infinity; // physically impossible hop - t += SCORE.dist * Math.exp(-d / DIST_DECAY_KM); - } - t += SCORE.edge * getEdgeDirected(prev.nodeId, cur.nodeId); - t += SCORE.transition * getTransitionProb(prev.nodeId, cur.nodeId); - t += SCORE.motif * getMotif2(prev.nodeId, cur.nodeId); - if (hasLink(prev.nodeId, cur.nodeId)) t += SCORE.link; - return t; - } - - function buildColumn(pos: number, hash: string, prevHash: string | null): Cand[] { - const raw = nodesByHash.get(hash) ?? []; - let cands: Cand[] = raw.map((c) => ({ nodeId: c.nodeId, name: c.name, lat: c.lat, lon: c.lon })); - if (cands.length > MAX_COL) { - // Keep the strongest by standalone evidence when a 1-byte prefix is shared widely. - const anchors = globalDirectAnchors.get(`${pos}:${hash}`) ?? []; - cands = cands - .map((c) => ({ c, s: emission(hash, prevHash, c, anchors) })) - .sort((a, b) => b.s - a.s) - .slice(0, MAX_COL) - .map((x) => x.c); - } - cands.push(NULL_CAND); - return cands; - } - - // Run the max-product Viterbi over canonicalHashes[0..N-1] and return the - // best coherent assignment per position (+ ambiguity from marginal gaps). - function decodeChain(canonicalHashes: string[]): Map { - const N = canonicalHashes.length; - const resolved = new Map(); - if (N === 0) return resolved; - - const cols: Cand[][] = []; - const emiss: number[][] = []; - const anchorsByPos: Array> = []; - for (let pos = 0; pos < N; pos++) { - const hash = canonicalHashes[pos]!; - const prevHash = pos > 0 ? (canonicalHashes[pos - 1] ?? null) : null; - const anchors = globalDirectAnchors.get(`${pos}:${hash}`) ?? []; - anchorsByPos.push(anchors); - const col = buildColumn(pos, hash, prevHash); - cols.push(col); - emiss.push(col.map((c) => emission(hash, prevHash, c, anchors))); - } - - // Forward max-product: best score of a prefix ending at cols[pos][j]. - // The NULL_CAND in every column (neutral, finite transitions) guarantees a - // connected path, so an infeasible real→real hop simply isn't taken — the - // chain routes through the unresolved wildcard instead of teleporting. - const fwd: number[][] = cols.map((c) => new Array(c.length).fill(-Infinity)); - for (let j = 0; j < cols[0]!.length; j++) fwd[0]![j] = emiss[0]![j]!; - for (let pos = 1; pos < N; pos++) { - for (let j = 0; j < cols[pos]!.length; j++) { - const ej = emiss[pos]![j]!; - if (!isFinite(ej)) continue; - let best = -Infinity; - for (let k = 0; k < cols[pos - 1]!.length; k++) { - if (!isFinite(fwd[pos - 1]![k]!)) continue; - const t = transition(cols[pos - 1]![k]!, cols[pos]![j]!); - if (!isFinite(t)) continue; - const total = fwd[pos - 1]![k]! + t; - if (total > best) best = total; - } - if (isFinite(best)) fwd[pos]![j] = best + ej; - } - } - - // Backward max-product: best score of a suffix starting at cols[pos][j]. - const bwd: number[][] = cols.map((c) => new Array(c.length).fill(-Infinity)); - for (let j = 0; j < cols[N - 1]!.length; j++) bwd[N - 1]![j] = 0; - for (let pos = N - 2; pos >= 0; pos--) { - for (let j = 0; j < cols[pos]!.length; j++) { - let best = -Infinity; - for (let k = 0; k < cols[pos + 1]!.length; k++) { - const ek = emiss[pos + 1]![k]!; - if (!isFinite(ek) || !isFinite(bwd[pos + 1]![k]!)) continue; - const t = transition(cols[pos]![j]!, cols[pos + 1]![k]!); - if (!isFinite(t)) continue; - const total = t + ek + bwd[pos + 1]![k]!; - if (total > best) best = total; - } - bwd[pos]![j] = isFinite(best) ? best : 0; // suffix may end here - } - } - - for (let pos = 0; pos < N; pos++) { - const hash = canonicalHashes[pos]!; - // Best-path-through marginal for each candidate at this position. - let bestJ = -1, bestM = -Infinity, secondNodeM = -Infinity; - for (let j = 0; j < cols[pos]!.length; j++) { - if (!isFinite(fwd[pos]![j]!) || !isFinite(bwd[pos]![j]!)) continue; - const m = fwd[pos]![j]! + bwd[pos]![j]!; - if (m > bestM) { secondNodeM = bestM; bestM = m; bestJ = j; } - else if (m > secondNodeM) { secondNodeM = m; } - } - const best = bestJ >= 0 ? cols[pos]![bestJ]! : NULL_CAND; - const ambiguous = best.nodeId !== null && isFinite(secondNodeM) && (bestM - secondNodeM) < AMBIG_DELTA; - resolved.set(pos, { - hash, - nodeId: best.nodeId, - name: best.name, - lat: best.lat, - lon: best.lon, - ambiguous, - }); - } - - return resolved; - } + // ── 9. Decode each group and shape the public lazy-path DTO ────────────── const paths: LazyPath[] = []; @@ -735,7 +563,7 @@ export async function lazyResolvePath( canonicalHashEntries.push({ position: i, hash: h, appearances }); } - const resolved = decodeChain(canonicalHashes); + const resolved = decodePath(canonicalHashes, decoderEvidence); // ── Build canonicalPath: relay nodes + observer nodes ────────────────── const canonicalPath: LazyPathNode[] = canonicalHashEntries.map(({ position, hash, appearances }) => { @@ -771,7 +599,7 @@ export async function lazyResolvePath( if (obsPos) { const prevRelay = resolved.get(obsPosition - 1); const distOk = !prevRelay?.lat || !prevRelay?.lon || - distKm(obsPos, { lat: prevRelay.lat, lon: prevRelay.lon }) <= MAX_HOP_KM; + distanceKm(obsPos, { lat: prevRelay.lat, lon: prevRelay.lon }) <= MAX_HOP_KM; if (distOk) { validLat = obsPos.lat; validLon = obsPos.lon; } } canonicalPath.push({ diff --git a/backend/src/path-shared/scoring.ts b/backend/src/path-shared/scoring.ts index 1cc9b66..1c9e7d6 100644 --- a/backend/src/path-shared/scoring.ts +++ b/backend/src/path-shared/scoring.ts @@ -1,18 +1,16 @@ /** * Shared scoring weights and prior key-formats for the path resolvers. * - * The lazy resolver (`path-lazy/lazyResolver.ts`) and the beta resolver - * (`path-beta/resolver.ts`) both score candidate hops with the same evidence - * (prefix/transition/edge/motif priors, ML scores, node_links, hop distance). - * Historically each kept its own inline copies of the weights and the key - * strings, which let them drift (and forced bug-fixes to be applied twice). + * The shared decoder scores candidate hops with prefix/transition/edge/motif + * priors, ML scores, node_links, and hop distance. The lazy resolver uses it + * today; the beta resolvers will adopt it in later extraction phases. * * This module is the single source of truth for: * - the prior key formats (must match how `path-learning/rebuild.ts` stores them); * - the relative scoring weights used by the Viterbi decoder. * * `path-beta/resolver.ts` predates this module and still has its own inline - * weights; converge it here when convenient. New code should import from here. + * weights; later phases will converge it here. New code should import from here. */ /** Generous but realistic upper bound for a single LoRa hop, in km. */ @@ -32,24 +30,53 @@ export function motif2Key(fromNodeId: string, toNodeId: string): string { return `${fromNodeId}>${toNodeId}`; } -/** Scoring weights for the lazy Viterbi decoder. */ -export const SCORE = { +/** + * Weights consumed by the shared Viterbi decoder. Evidence providers may pass + * a different table for experiments, but production resolvers should use + * {@link SCORE} so there is one tuning surface. + */ +export type PathScoreWeights = Readonly<{ + prefix: number; + mlDominantCap: number; + mlWeakCap: number; + anchor: number; + corridorInterpolation: number; + edge: number; + transition: number; + positionConditionalTransition: number; + motif: number; + link: number; + itmViability: number; + dist: number; +}>; + +/** Scoring weights for the shared Viterbi decoder. */ +export const SCORE: PathScoreWeights = { // Emission (how well a node fits a hash position on its own) prefix: 0.30, // historical prefix→node probability mlDominantCap: 0.20, // ML score >= mlDominantThreshold mlWeakCap: 0.06, // ML score below the dominant threshold anchor: 0.12, // proximity to a direct-receiver observer anchor (0..1) + // Reserved for Phase 4. Kept inert until the matching evidence is loaded. + corridorInterpolation: 0, // source→receiver corridor position fit // Transition (how well two adjacent nodes form a real hop) edge: 0.28, // path_edge_priors directed score transition: 0.25, // path_transition_priors directed probability + // Reserved for Phase 4. Kept inert until the matching evidence is loaded. + positionConditionalTransition: 0, // (position, from)→to probability motif: 0.18, // path_motif_priors 2-gram directed probability link: 0.20, // confirmed node_links pair + // Reserved for Phase 4. Bonus-only; never use ITM viability as a hard gate. + itmViability: 0, dist: 0.10, // distance shaping exp(-d/22), only when both ends are positioned -} as const; +}; /** ML score at/above this is treated as strong (dominant) evidence. */ export const ML_DOMINANT_THRESHOLD = 0.85; +/** Lowest ML prefix score loaded as candidate evidence. */ +export const ML_SCORE_LOAD_THRESHOLD = 0.80; + /** Distance-decay constant (km) for the transition distance-shaping term. */ export const DIST_DECAY_KM = 22;