mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-22 22:11:31 +00:00
refactor(pathing): extract shared Viterbi decoder
This commit is contained in:
1 parent
c430ab5693
commit
bc1cf1b071
4 files changed
+567
-222
No files matched your search
@@ -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']);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user