refactor(pathing): extract shared Viterbi decoder

This commit is contained in:
gadgethd committed 2026-08-03 15:12:35 +01:00
1 parent c430ab5693
commit bc1cf1b071
4 files changed
+567 -222

No files matched your search

+112
View File
@@ -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<string, readonly CandidateNode[]>,
overrides: Partial<PathDecoderEvidence> = {},
): 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']);
});
+378
View File
@@ -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<string, readonly CandidateNode[]>;
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<string, CandidateNode[]> {
const result = new Map<string, CandidateNode[]>();
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<number, DecodedHop> {
const pathLength = canonicalHashes.length;
const decoded = new Map<number, DecodedHop>();
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;
}
+41 -213
View File
@@ -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 = <T extends Record<string, unknown> = Record<string, unknown>>(
@@ -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<string, Array<{ nodeId: string; name: string | null; lat: number; lon: number }>>();
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<number, ResolvedEntry> {
const N = canonicalHashes.length;
const resolved = new Map<number, ResolvedEntry>();
if (N === 0) return resolved;
const cols: Cand[][] = [];
const emiss: number[][] = [];
const anchorsByPos: Array<Array<{ lat: number; lon: number }>> = [];
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({
+36 -9
View File
@@ -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;