mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-22 22:11:31 +00:00
fix: anchor confirmed paths and stabilize observer dots
This commit is contained in:
1 parent
2a4dd27bcd
commit
bf7d7ad85e
8 files changed
+344
-87
No files matched your search
@@ -19,6 +19,7 @@ import {
|
||||
decodeBetaCanonicalGroup,
|
||||
decodeBetaCanonicalGroupWithHeldPath,
|
||||
groupCompatibleObservations,
|
||||
mergeCompatiblePathHashes,
|
||||
projectCanonicalPathForObserver,
|
||||
type BetaObserverEntry,
|
||||
type BetaPathProjection,
|
||||
@@ -56,19 +57,48 @@ function edgeKey(receiverRegion: string, bucket: number, fromId: string, toId: s
|
||||
return `${receiverRegion}|${bucket}|${fromId}|${toId}`;
|
||||
}
|
||||
|
||||
export type PathPrefixPriorEvidence = {
|
||||
prefix: string;
|
||||
receiver_region: string;
|
||||
prev_prefix: string | null;
|
||||
node_id: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/** Collapse confirmed 2/3-byte training facts into calibrated one-byte priors. */
|
||||
export function buildOneBytePrefixProbabilities(
|
||||
rows: readonly PathPrefixPriorEvidence[],
|
||||
): Map<string, number> {
|
||||
const choiceCounts = new Map<string, number>();
|
||||
const groupTotals = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const prefix = normalizePathHash(row.prefix).slice(0, 2);
|
||||
const previous = normalizePathHash(row.prev_prefix).slice(0, 2);
|
||||
const count = Math.max(0, Number(row.count) || 0);
|
||||
if (prefix.length !== 2 || !row.receiver_region || !row.node_id || count === 0) continue;
|
||||
const group = `${row.receiver_region}|${prefix}|${previous}`;
|
||||
const choice = `${group}|${row.node_id}`;
|
||||
choiceCounts.set(choice, (choiceCounts.get(choice) ?? 0) + count);
|
||||
groupTotals.set(group, (groupTotals.get(group) ?? 0) + count);
|
||||
}
|
||||
|
||||
const probabilities = new Map<string, number>();
|
||||
for (const [choice, count] of choiceCounts) {
|
||||
const separator = choice.lastIndexOf('|');
|
||||
const group = separator >= 0 ? choice.slice(0, separator) : '';
|
||||
const total = groupTotals.get(group) ?? 0;
|
||||
if (total > 0) probabilities.set(choice, count / total);
|
||||
}
|
||||
return probabilities;
|
||||
}
|
||||
|
||||
async function buildLearningModel(
|
||||
network: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<PathLearningModel> {
|
||||
const [prefixRows, transitionRows, edgeRows, motifRows, calibrationRows] = await Promise.all([
|
||||
query<{
|
||||
prefix: string;
|
||||
receiver_region: string;
|
||||
prev_prefix: string | null;
|
||||
node_id: string;
|
||||
probability: number;
|
||||
}>(
|
||||
`SELECT prefix, receiver_region, prev_prefix, node_id, probability
|
||||
query<PathPrefixPriorEvidence>(
|
||||
`SELECT prefix, receiver_region, prev_prefix, node_id, count
|
||||
FROM path_prefix_priors
|
||||
WHERE network = $1
|
||||
ORDER BY count DESC
|
||||
@@ -129,13 +159,7 @@ async function buildLearningModel(
|
||||
),
|
||||
]);
|
||||
|
||||
const prefixProbabilities = new Map<string, number>();
|
||||
for (const row of prefixRows.rows) {
|
||||
prefixProbabilities.set(
|
||||
`${row.receiver_region}|${normalizePathHash(row.prefix)}|${normalizePathHash(row.prev_prefix)}|${row.node_id}`,
|
||||
Number(row.probability),
|
||||
);
|
||||
}
|
||||
const prefixProbabilities = buildOneBytePrefixProbabilities(prefixRows.rows);
|
||||
|
||||
const transitionProbabilities = new Map<string, number>();
|
||||
for (const row of transitionRows.rows) {
|
||||
@@ -301,7 +325,7 @@ async function loadContext(
|
||||
return context;
|
||||
}
|
||||
|
||||
type PreparedPacketObservation = {
|
||||
export type PreparedPacketObservation = {
|
||||
packet: PathPacket;
|
||||
rx: MeshNode | null;
|
||||
hashes: string[];
|
||||
@@ -347,22 +371,34 @@ function preparePacketObservation(packet: PathPacket, rx: MeshNode | null): Prep
|
||||
}
|
||||
|
||||
function compareCanonicalObservation(a: PreparedPacketObservation, b: PreparedPacketObservation): number {
|
||||
return b.hops.length - a.hops.length
|
||||
return Number(b.packet.path_hash_size_bytes ?? 0) - Number(a.packet.path_hash_size_bytes ?? 0)
|
||||
|| b.hops.length - a.hops.length
|
||||
|| b.rawHops.length - a.rawHops.length
|
||||
|| Number(Boolean(b.packet.path_hash_size_bytes)) - Number(Boolean(a.packet.path_hash_size_bytes))
|
||||
|| Number(Boolean(b.packet.src_node_id)) - Number(Boolean(a.packet.src_node_id))
|
||||
|| Number(b.packet.hop_count ?? 0) - Number(a.packet.hop_count ?? 0);
|
||||
}
|
||||
|
||||
function comparePreferredObservation(a: PreparedPacketObservation, b: PreparedPacketObservation): number {
|
||||
return b.hops.length - a.hops.length
|
||||
|| Number(Boolean(b.packet.path_hash_size_bytes)) - Number(Boolean(a.packet.path_hash_size_bytes))
|
||||
|| Number(b.packet.path_hash_size_bytes ?? 0) - Number(a.packet.path_hash_size_bytes ?? 0)
|
||||
|| Number(Boolean(b.packet.src_node_id)) - Number(Boolean(a.packet.src_node_id))
|
||||
|| a.rawHops.length - b.rawHops.length
|
||||
|| Number(a.packet.hop_count ?? Number.MAX_SAFE_INTEGER)
|
||||
- Number(b.packet.hop_count ?? Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
export function mergeObserverEvidence(
|
||||
previous: PreparedPacketObservation,
|
||||
current: PreparedPacketObservation,
|
||||
): PreparedPacketObservation {
|
||||
const mergedHops = mergeCompatiblePathHashes(previous.hops, current.hops);
|
||||
if (!mergedHops) {
|
||||
return compareCanonicalObservation(current, previous) < 0 ? current : previous;
|
||||
}
|
||||
const preferred = comparePreferredObservation(current, previous) < 0 ? current : previous;
|
||||
return { ...preferred, hops: mergedHops };
|
||||
}
|
||||
|
||||
function toObserverEntry(prepared: PreparedPacketObservation): BetaObserverEntry | null {
|
||||
if (!hasCoords(prepared.rx)) return null;
|
||||
return {
|
||||
@@ -662,9 +698,7 @@ export async function resolveBetaPathForPacketHash(
|
||||
if (prepared.ignoreForPathing) continue;
|
||||
const key = row.rx_node_id ?? '__no_observer__';
|
||||
const previous = preparedByObserver.get(key);
|
||||
if (!previous || compareCanonicalObservation(prepared, previous) < 0) {
|
||||
preparedByObserver.set(key, prepared);
|
||||
}
|
||||
preparedByObserver.set(key, previous ? mergeObserverEvidence(previous, prepared) : prepared);
|
||||
}
|
||||
const selected = [...preparedByObserver.values()].sort(comparePreferredObservation)[0];
|
||||
if (!selected) return null;
|
||||
@@ -801,9 +835,7 @@ async function resolveMultiObserverBetaPathFromRows(
|
||||
const prepared = preparePacketObservation(row, context.nodesById.get(row.rx_node_id) ?? null);
|
||||
if (prepared.ignoreForPathing) continue;
|
||||
const previous = byObserver.get(row.rx_node_id);
|
||||
if (!previous || compareCanonicalObservation(prepared, previous) < 0) {
|
||||
byObserver.set(row.rx_node_id, prepared);
|
||||
}
|
||||
byObserver.set(row.rx_node_id, previous ? mergeObserverEvidence(previous, prepared) : prepared);
|
||||
}
|
||||
if (byObserver.size > PATH_MULTI_MAX_OBSERVERS) throw new Error('PATH_HISTORY_LIMIT');
|
||||
|
||||
|
||||
@@ -1,6 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { canReusePathContext } from './resolver.js';
|
||||
import {
|
||||
buildOneBytePrefixProbabilities,
|
||||
canReusePathContext,
|
||||
mergeObserverEvidence,
|
||||
type PreparedPacketObservation,
|
||||
} from './resolver.js';
|
||||
import type { PathPacket } from './types.js';
|
||||
|
||||
function prepared(size: number, hops: string[]): PreparedPacketObservation {
|
||||
const packet: PathPacket = {
|
||||
packet_hash: 'packet',
|
||||
rx_node_id: 'observer',
|
||||
src_node_id: null,
|
||||
packet_type: 0,
|
||||
hop_count: hops.length,
|
||||
path_hashes: hops,
|
||||
path_hash_size_bytes: size,
|
||||
};
|
||||
return { packet, rx: null, hashes: hops, rawHops: hops, hops, ignoreForPathing: false };
|
||||
}
|
||||
|
||||
test('retains same-observer multibyte anchors while keeping the longer path tail', () => {
|
||||
const merged = mergeObserverEvidence(
|
||||
prepared(1, ['AA', 'BB', 'CC']),
|
||||
prepared(2, ['AA01', 'BB02']),
|
||||
);
|
||||
|
||||
assert.deepEqual(merged.hops, ['AA01', 'BB02', 'CC']);
|
||||
assert.equal(merged.packet.path_hash_size_bytes, 1, 'the longer observation remains the projection row');
|
||||
});
|
||||
|
||||
test('collapses multibyte path facts into calibrated one-byte prefix priors', () => {
|
||||
const probabilities = buildOneBytePrefixProbabilities([
|
||||
{ prefix: 'AA01', receiver_region: 'LON', prev_prefix: 'BB01', node_id: 'node-a', count: 3 },
|
||||
{ prefix: 'AA02', receiver_region: 'LON', prev_prefix: 'BBFF', node_id: 'node-a', count: 2 },
|
||||
{ prefix: 'AAFF', receiver_region: 'LON', prev_prefix: 'BB02', node_id: 'node-b', count: 5 },
|
||||
{ prefix: 'CC01', receiver_region: 'LON', prev_prefix: null, node_id: 'node-c', count: 4 },
|
||||
]);
|
||||
|
||||
assert.equal(probabilities.get('LON|AA|BB|node-a'), 0.5);
|
||||
assert.equal(probabilities.get('LON|AA|BB|node-b'), 0.5);
|
||||
assert.equal(probabilities.get('LON|CC||node-c'), 1);
|
||||
assert.equal([...probabilities.keys()].some((key) => key.includes('AA01')), false);
|
||||
});
|
||||
|
||||
test('interactive path context reuse expires normally', () => {
|
||||
assert.equal(canReusePathContext({
|
||||
|
||||
@@ -75,6 +75,16 @@ test('groups only prefix-compatible observer paths under one canonical decode',
|
||||
assert.deepEqual(groups[1]?.canonicalHashes, ['CC']);
|
||||
});
|
||||
|
||||
test('merges multibyte confirmations into a longer one-byte canonical path', () => {
|
||||
const coarse = entry('coarse', ['AA', 'BB', 'CC']);
|
||||
const confirmed = entry('confirmed', ['AA01', 'BB02']);
|
||||
const groups = groupCompatibleObservations([coarse, confirmed]);
|
||||
|
||||
assert.equal(groups.length, 1);
|
||||
assert.deepEqual(groups[0]?.canonicalHashes, ['AA01', 'BB02', 'CC']);
|
||||
assert.deepEqual(new Set(groups[0]?.members), new Set([coarse, confirmed]));
|
||||
});
|
||||
|
||||
test('beta canonical groups use the shared decoder evidence model', () => {
|
||||
const candidates = [
|
||||
meshNode('AA01', 51.0, 0.02),
|
||||
@@ -100,6 +110,30 @@ test('beta canonical groups use the shared decoder evidence model', () => {
|
||||
assert.equal(decoded.hops.get(1)?.nodeId, 'BB01');
|
||||
});
|
||||
|
||||
test('locks confirmed multibyte hops and decodes the remaining one-byte gap', () => {
|
||||
const candidates = [
|
||||
meshNode('AA01', 51.0, 0.02),
|
||||
meshNode('AAFF', 51.0, 0.03),
|
||||
meshNode('BB01', 51.2, 0.02),
|
||||
meshNode('BB02', 51.2, 0.03),
|
||||
];
|
||||
const betaContext = context(candidates);
|
||||
betaContext.learningModel.prefixProbabilities.set('unknown|BB|AA|BB01', 0.2);
|
||||
betaContext.learningModel.prefixProbabilities.set('unknown|BB|AA|BB02', 0.9);
|
||||
|
||||
const coarse = entry('coarse', ['AA', 'BB']);
|
||||
coarse.rx = meshNode('coarse', 51.3, 0.03);
|
||||
const confirmed = entry('confirmed', ['AA01']);
|
||||
confirmed.rx = meshNode('confirmed', 51.05, 0.02);
|
||||
const [group] = groupCompatibleObservations([coarse, confirmed]);
|
||||
const decoded = decodeBetaCanonicalGroup(group!, betaContext);
|
||||
|
||||
assert.deepEqual(decoded.canonicalHashes, ['AA01', 'BB']);
|
||||
assert.equal(decoded.hops.get(0)?.nodeId, 'AA01');
|
||||
assert.equal(decoded.hops.get(0)?.margin, Infinity);
|
||||
assert.equal(decoded.hops.get(1)?.nodeId, 'BB02');
|
||||
});
|
||||
|
||||
test('observer projection breaks at unresolved hops and never invents a connecting path', () => {
|
||||
const observer = entry('RX', ['AA', 'BB', 'CC']);
|
||||
observer.packet.packet_type = 4;
|
||||
|
||||
@@ -57,24 +57,59 @@ export type BetaPathProjection = {
|
||||
function pathsArePrefixCompatible(a: readonly string[], b: readonly string[]): boolean {
|
||||
const length = Math.min(a.length, b.length);
|
||||
for (let position = 0; position < length; position++) {
|
||||
if (normalizePathHash(a[position]) !== normalizePathHash(b[position])) return false;
|
||||
const left = normalizePathHash(a[position]);
|
||||
const right = normalizePathHash(b[position]);
|
||||
const shortestLength = Math.min(left.length, right.length);
|
||||
if (left.slice(0, shortestLength) !== right.slice(0, shortestLength)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function mergeCompatiblePathHashes(a: readonly string[], b: readonly string[]): string[] | null {
|
||||
if (!pathsArePrefixCompatible(a, b)) return null;
|
||||
const merged: string[] = [];
|
||||
const length = Math.max(a.length, b.length);
|
||||
for (let position = 0; position < length; position++) {
|
||||
const left = normalizePathHash(a[position]);
|
||||
const right = normalizePathHash(b[position]);
|
||||
merged.push(left.length >= right.length ? left : right);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function pathSpecificity(hashes: readonly string[]): number {
|
||||
return hashes.reduce((total, hash) => total + normalizePathHash(hash).length, 0);
|
||||
}
|
||||
|
||||
/** Group observer views exactly as the lazy resolver does: compatible prefixes share one decode. */
|
||||
export function groupCompatibleObservations(entries: readonly BetaObserverEntry[]): BetaCanonicalGroup[] {
|
||||
const sorted = [...entries]
|
||||
.filter((entry) => entry.hops.length > 0)
|
||||
.sort((a, b) => b.hops.length - a.hops.length || a.observerId.localeCompare(b.observerId));
|
||||
.sort((a, b) => pathSpecificity(b.hops) - pathSpecificity(a.hops)
|
||||
|| b.hops.length - a.hops.length
|
||||
|| a.observerId.localeCompare(b.observerId));
|
||||
const groups: BetaCanonicalGroup[] = [];
|
||||
for (const entry of sorted) {
|
||||
const existing = groups.find((group) => pathsArePrefixCompatible(group.canonicalHashes, entry.hops));
|
||||
const compatible = groups.flatMap((group) => {
|
||||
const merged = mergeCompatiblePathHashes(group.canonicalHashes, entry.hops);
|
||||
return merged ? [{ group, merged }] : [];
|
||||
});
|
||||
// A coarse one-byte observation can match two conflicting confirmed
|
||||
// routes. Keep it separate instead of attaching it to whichever group was
|
||||
// encountered first and inheriting arbitrary multibyte anchors.
|
||||
const exact = compatible.find(({ group }) => (
|
||||
group.canonicalHashes.length === entry.hops.length
|
||||
&& group.canonicalHashes.every((hash, position) => (
|
||||
normalizePathHash(hash) === normalizePathHash(entry.hops[position])
|
||||
))
|
||||
));
|
||||
const selected = exact ?? (compatible.length === 1 ? compatible[0] : undefined);
|
||||
const existing = selected?.group;
|
||||
if (existing) {
|
||||
existing.members.push(entry);
|
||||
if (entry.hops.length > existing.canonicalHashes.length) existing.canonicalHashes = [...entry.hops];
|
||||
existing.canonicalHashes = selected!.merged;
|
||||
} else {
|
||||
groups.push({ canonicalHashes: [...entry.hops], members: [entry] });
|
||||
groups.push({ canonicalHashes: entry.hops.map(normalizePathHash), members: [entry] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
@@ -90,16 +125,18 @@ function uniqueRegions(entries: readonly BetaObserverEntry[]): string[] {
|
||||
return [...regions];
|
||||
}
|
||||
|
||||
function directAnchorMap(group: BetaCanonicalGroup): Map<string, ObserverAnchor[]> {
|
||||
const anchors = new Map<string, ObserverAnchor[]>();
|
||||
function directAnchorMap(group: BetaCanonicalGroup): Map<number, ObserverAnchor[]> {
|
||||
const anchors = new Map<number, ObserverAnchor[]>();
|
||||
for (const entry of group.members) {
|
||||
if (!hasCoords(entry.rx)) continue;
|
||||
const position = entry.hops.length - 1;
|
||||
if (position < 0) continue;
|
||||
const hash = entry.hops[position];
|
||||
if (!hash || normalizePathHash(group.canonicalHashes[position]) !== normalizePathHash(hash)) continue;
|
||||
const key = `${position}:${normalizePathHash(hash)}`;
|
||||
const values = anchors.get(key) ?? [];
|
||||
if (!hash || !pathsArePrefixCompatible(
|
||||
[group.canonicalHashes[position] ?? ''],
|
||||
[hash],
|
||||
)) continue;
|
||||
const values = anchors.get(position) ?? [];
|
||||
if (!values.some((value) => value.nodeId === entry.observerId)) {
|
||||
values.push({
|
||||
lat: entry.rx.lat!,
|
||||
@@ -108,11 +145,36 @@ function directAnchorMap(group: BetaCanonicalGroup): Map<string, ObserverAnchor[
|
||||
elevationM: entry.rx.elevation_m,
|
||||
});
|
||||
}
|
||||
anchors.set(key, values);
|
||||
anchors.set(position, values);
|
||||
}
|
||||
return anchors;
|
||||
}
|
||||
|
||||
/** Resolve only collision-free 2/3-byte observations into hard trellis locks. */
|
||||
function confirmedMultibyteAssignments(
|
||||
group: BetaCanonicalGroup,
|
||||
candidates: readonly MeshNode[],
|
||||
): Map<number, string> {
|
||||
const evidenceByPosition = new Map<number, Set<string>>();
|
||||
for (const member of group.members) {
|
||||
for (let position = 0; position < member.hops.length; position++) {
|
||||
const hash = normalizePathHash(member.hops[position]);
|
||||
if (hash.length < 4) continue;
|
||||
const matches = candidates.filter((node) => nodePathHash(node.node_id, hash) === hash);
|
||||
if (matches.length !== 1) continue;
|
||||
const values = evidenceByPosition.get(position) ?? new Set<string>();
|
||||
values.add(matches[0]!.node_id);
|
||||
evidenceByPosition.set(position, values);
|
||||
}
|
||||
}
|
||||
|
||||
const confirmed = new Map<number, string>();
|
||||
for (const [position, values] of evidenceByPosition) {
|
||||
if (values.size === 1) confirmed.set(position, values.values().next().value!);
|
||||
}
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
function regionalMaximum(regions: readonly string[], read: (region: string) => number | undefined): number {
|
||||
let best = 0;
|
||||
for (const region of regions) best = Math.max(best, read(region) ?? 0);
|
||||
@@ -135,10 +197,16 @@ export function decodeBetaCanonicalGroup(
|
||||
baselineNodeIds?: readonly string[],
|
||||
): BetaSharedDecode {
|
||||
const canonicalHashes = group.canonicalHashes.map(normalizePathHash).filter(Boolean);
|
||||
const sourceNode = group.members
|
||||
.map((entry) => entry.packet.src_node_id
|
||||
? context.nodesById.get(entry.packet.src_node_id) ?? null
|
||||
: null)
|
||||
.find(hasCoords);
|
||||
const observerCoordinates = group.members
|
||||
.map((entry) => entry.rx)
|
||||
.filter(hasCoords)
|
||||
.map((node) => ({ lat: node.lat!, lon: node.lon! }));
|
||||
if (sourceNode) observerCoordinates.push({ lat: sourceNode.lat!, lon: sourceNode.lon! });
|
||||
const bounds = buildObserverBounds(observerCoordinates, pathingConfig.maxHopKm);
|
||||
const candidateEvidence: CandidateNodeEvidence[] = context.repeaterNodes.map((node) => ({
|
||||
nodeId: node.node_id,
|
||||
@@ -148,16 +216,32 @@ export function decodeBetaCanonicalGroup(
|
||||
elevationM: node.elevation_m,
|
||||
}));
|
||||
const candidatesByHash = indexCandidatesByHash(canonicalHashes, candidateEvidence, bounds);
|
||||
const confirmed = confirmedMultibyteAssignments(group, context.repeaterNodes);
|
||||
// A confirmed relay may sit outside the observer-only candidate bound on a
|
||||
// long path. Preserve that direct evidence explicitly before locking it.
|
||||
for (const [position, nodeId] of confirmed) {
|
||||
const hash = canonicalHashes[position];
|
||||
const node = context.nodesById.get(nodeId);
|
||||
if (!hash || !hasCoords(node)) continue;
|
||||
const values = candidatesByHash.get(hash) ?? [];
|
||||
if (!values.some((candidate) => candidate.nodeId === nodeId)) {
|
||||
values.push({
|
||||
nodeId: node.node_id,
|
||||
name: node.name,
|
||||
lat: node.lat!,
|
||||
lon: node.lon!,
|
||||
elevationM: node.elevation_m,
|
||||
});
|
||||
candidatesByHash.set(hash, values);
|
||||
}
|
||||
}
|
||||
const locked = new Map(fixed ?? []);
|
||||
for (const [position, nodeId] of confirmed) locked.set(position, nodeId);
|
||||
const anchors = directAnchorMap(group);
|
||||
const regions = uniqueRegions(group.members);
|
||||
const bucketHours = context.learningModel.bucketHours || 6;
|
||||
const bucket = currentHourBucket(bucketHours);
|
||||
const stickyStrength = clamp(1 - Number(stickyAgeFraction ?? 0), 0, 1);
|
||||
const sourceNode = group.members
|
||||
.map((entry) => entry.packet.src_node_id
|
||||
? context.nodesById.get(entry.packet.src_node_id) ?? null
|
||||
: null)
|
||||
.find(hasCoords);
|
||||
|
||||
const evidence: PathDecoderEvidence = {
|
||||
weights: SCORE,
|
||||
@@ -168,7 +252,7 @@ export function decodeBetaCanonicalGroup(
|
||||
ambiguityDelta: AMBIG_DELTA,
|
||||
maxColumnCandidates: MAX_COL,
|
||||
candidatesByHash,
|
||||
fixed,
|
||||
fixed: locked.size > 0 ? locked : undefined,
|
||||
sourceAnchor: sourceNode ? {
|
||||
lat: sourceNode.lat!,
|
||||
lon: sourceNode.lon!,
|
||||
@@ -176,10 +260,10 @@ export function decodeBetaCanonicalGroup(
|
||||
elevationM: sourceNode.elevation_m,
|
||||
} : undefined,
|
||||
endpointTransitionsGateOnly: true,
|
||||
directAnchors: (position, hash) => anchors.get(`${position}:${normalizePathHash(hash)}`) ?? [],
|
||||
directAnchors: (position) => anchors.get(position) ?? [],
|
||||
prefixProbability: (nodeId, hash, previousHash) => {
|
||||
const prefix = normalizePathHash(hash);
|
||||
const previous = normalizePathHash(previousHash);
|
||||
const prefix = normalizePathHash(hash).slice(0, 2);
|
||||
const previous = normalizePathHash(previousHash).slice(0, 2);
|
||||
const learned = regionalMaximum(regions, (region) => (
|
||||
context.learningModel.prefixProbabilities.get(`${region}|${prefix}|${previous}|${nodeId}`)
|
||||
?? context.learningModel.prefixProbabilities.get(`${region}|${prefix}||${nodeId}`)
|
||||
|
||||
@@ -80,6 +80,20 @@ test('decodes the strongest coherent chain from supplied evidence', () => {
|
||||
assert.equal(decoded.get(1)?.nodeId, 'C');
|
||||
});
|
||||
|
||||
test('backtracks one coherent chain instead of splicing tied max-marginals', () => {
|
||||
const decoded = decodePath(['AA', 'BB'], evidence(new Map([
|
||||
['AA', [node('A', 51, 0), node('B', 55, 0)]],
|
||||
// Candidate order deliberately makes independent per-position argmaxes
|
||||
// choose A then D, even though only A→C and B→D are physical.
|
||||
['BB', [node('D', 55, 0), node('C', 51, 0)]],
|
||||
])));
|
||||
|
||||
assert.deepEqual(
|
||||
[...decoded.values()].map((hop) => hop.nodeId),
|
||||
['B', 'D'],
|
||||
);
|
||||
});
|
||||
|
||||
test('chooses the real argmax even when the candidate has no prior support', () => {
|
||||
const decoded = decodePath(
|
||||
['AA'],
|
||||
|
||||
@@ -495,6 +495,7 @@ export function decodePathWithScore(
|
||||
|
||||
// Best prefix score ending at columns[position][candidate].
|
||||
const forward: number[][] = columns.map((column) => new Array(column.length).fill(-Infinity));
|
||||
const previousCandidate: number[][] = columns.map((column) => new Array(column.length).fill(-1));
|
||||
for (let candidate = 0; candidate < columns[0]!.length; candidate++) {
|
||||
const startTransition = evidence.sourceAnchor
|
||||
? endpointTransitionScore(endpointCandidate(evidence.sourceAnchor), columns[0]![candidate]!, evidence)
|
||||
@@ -508,6 +509,7 @@ export function decodePathWithScore(
|
||||
const currentEmission = emissions[position]![current]!;
|
||||
if (!isFinite(currentEmission)) continue;
|
||||
let best = -Infinity;
|
||||
let bestPrevious = -1;
|
||||
for (let previous = 0; previous < columns[position - 1]!.length; previous++) {
|
||||
if (!isFinite(forward[position - 1]![previous]!)) continue;
|
||||
const transition = transitionScore(
|
||||
@@ -518,9 +520,15 @@ export function decodePathWithScore(
|
||||
);
|
||||
if (!isFinite(transition)) continue;
|
||||
const total = forward[position - 1]![previous]! + transition;
|
||||
if (total > best) best = total;
|
||||
if (total > best) {
|
||||
best = total;
|
||||
bestPrevious = previous;
|
||||
}
|
||||
}
|
||||
if (isFinite(best)) {
|
||||
forward[position]![current] = best + currentEmission;
|
||||
previousCandidate[position]![current] = bestPrevious;
|
||||
}
|
||||
if (isFinite(best)) forward[position]![current] = best + currentEmission;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,31 +557,53 @@ export function decodePathWithScore(
|
||||
const total = transition + nextEmission + backward[position + 1]![next]!;
|
||||
if (total > best) best = total;
|
||||
}
|
||||
backward[position]![current] = isFinite(best) ? best : 0;
|
||||
backward[position]![current] = best;
|
||||
}
|
||||
}
|
||||
|
||||
// Backtrack one globally coherent Viterbi chain. Max-marginal argmaxes are
|
||||
// useful for confidence, but choosing them independently can splice two
|
||||
// equally-scored physical paths into an adjacent hop that was never viable.
|
||||
let score = -Infinity;
|
||||
let terminalCandidate = -1;
|
||||
for (let candidate = 0; candidate < columns[pathLength - 1]!.length; candidate++) {
|
||||
const total = forward[pathLength - 1]![candidate]!
|
||||
+ backward[pathLength - 1]![candidate]!;
|
||||
if (total > score) {
|
||||
score = total;
|
||||
terminalCandidate = candidate;
|
||||
}
|
||||
}
|
||||
const chosenCandidates = new Array<number>(pathLength).fill(-1);
|
||||
if (terminalCandidate >= 0 && isFinite(score)) {
|
||||
chosenCandidates[pathLength - 1] = terminalCandidate;
|
||||
for (let position = pathLength - 1; position > 0; position--) {
|
||||
const chosen = chosenCandidates[position]!;
|
||||
if (chosen < 0) break;
|
||||
chosenCandidates[position - 1] = previousCandidate[position]![chosen]!;
|
||||
}
|
||||
}
|
||||
|
||||
for (let position = 0; position < pathLength; position++) {
|
||||
const hash = canonicalHashes[position]!;
|
||||
let bestCandidate = -1;
|
||||
let bestMarginal = -Infinity;
|
||||
let secondMarginal = -Infinity;
|
||||
const chosenCandidate = chosenCandidates[position]!;
|
||||
const chosenMarginal = chosenCandidate >= 0
|
||||
? forward[position]![chosenCandidate]! + backward[position]![chosenCandidate]!
|
||||
: -Infinity;
|
||||
let bestAlternativeMarginal = -Infinity;
|
||||
for (let candidate = 0; candidate < columns[position]!.length; candidate++) {
|
||||
if (candidate === chosenCandidate) continue;
|
||||
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;
|
||||
}
|
||||
if (marginal > bestAlternativeMarginal) bestAlternativeMarginal = marginal;
|
||||
}
|
||||
const best = bestCandidate >= 0
|
||||
? columns[position]![bestCandidate]!
|
||||
const best = chosenCandidate >= 0
|
||||
? columns[position]![chosenCandidate]!
|
||||
: UNRESOLVED_CANDIDATE;
|
||||
const margin = isFinite(secondMarginal) ? bestMarginal - secondMarginal : Infinity;
|
||||
const margin = isFinite(bestAlternativeMarginal)
|
||||
? chosenMarginal - bestAlternativeMarginal
|
||||
: Infinity;
|
||||
decoded.set(position, {
|
||||
hash,
|
||||
nodeId: best.nodeId,
|
||||
@@ -581,17 +611,10 @@ export function decodePathWithScore(
|
||||
lat: best.lat,
|
||||
lon: best.lon,
|
||||
margin,
|
||||
ambiguous: best.nodeId !== null && isFinite(secondMarginal)
|
||||
ambiguous: best.nodeId !== null && isFinite(bestAlternativeMarginal)
|
||||
&& margin < evidence.ambiguityDelta,
|
||||
});
|
||||
}
|
||||
|
||||
let score = -Infinity;
|
||||
for (let candidate = 0; candidate < columns[pathLength - 1]!.length; candidate++) {
|
||||
const total = forward[pathLength - 1]![candidate]!
|
||||
+ backward[pathLength - 1]![candidate]!;
|
||||
if (total > score) score = total;
|
||||
}
|
||||
return { hops: decoded, score };
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
cachedTerrainElevation,
|
||||
easeArcProgress,
|
||||
interpolateArcPosition,
|
||||
OBSERVER_LAYER_ID,
|
||||
observerLayerForFrame,
|
||||
registerAerialPaths,
|
||||
renderedPosition,
|
||||
terrainAwarePosition,
|
||||
@@ -44,6 +46,21 @@ test('new packet paths coexist without resetting earlier path lifetimes', () =>
|
||||
assert.equal(registry.get(packetBKey)?.startedAt, 500, 'unchanged paths do not restart their TTL');
|
||||
});
|
||||
|
||||
test('observer dots retain one un-stroked layer across data updates', () => {
|
||||
const populated = observerLayerForFrame([{
|
||||
position: [-1, 51],
|
||||
nodeId: 'observer-a',
|
||||
renderedPosition: [-1, 51, 0],
|
||||
}]);
|
||||
const empty = observerLayerForFrame([]);
|
||||
|
||||
assert.equal(populated.id, OBSERVER_LAYER_ID);
|
||||
assert.equal(empty.id, OBSERVER_LAYER_ID);
|
||||
assert.equal(populated.props.stroked, false);
|
||||
assert.equal(populated.props.getLineWidth, 0);
|
||||
assert.deepEqual(empty.props.data, []);
|
||||
});
|
||||
|
||||
test('a later observer reuses the common trunk and registers only its new branch', () => {
|
||||
const registry = new Map<string, PathRegistryEntry>();
|
||||
const firstObserver: AerialPath = {
|
||||
|
||||
@@ -57,7 +57,7 @@ type RenderedNode = AerialPathNode & {
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
type RenderedObserverNode = AerialPathNode & {
|
||||
export type RenderedObserverNode = AerialPathNode & {
|
||||
renderedPosition: DeckPosition;
|
||||
};
|
||||
|
||||
@@ -86,6 +86,7 @@ export type PathRegistryEntry = {
|
||||
};
|
||||
|
||||
const EMPTY_OBSERVER_NODES: AerialPathNode[] = [];
|
||||
export const OBSERVER_LAYER_ID = 'resolved-path-observers-stable-fill-v2';
|
||||
|
||||
function aerialNodeKey(node: AerialPathNode): string {
|
||||
return `${node.position[0].toFixed(6)},${node.position[1].toFixed(6)}`;
|
||||
@@ -447,6 +448,27 @@ function uniqueNodes(segments: RenderedSegment[]): RenderedNode[] {
|
||||
return [...nodesByKey.values()];
|
||||
}
|
||||
|
||||
/** Keep one Deck.gl layer alive while its observer data changes in place. */
|
||||
export function observerLayerForFrame(
|
||||
observerNodes: RenderedObserverNode[],
|
||||
onNodeClick?: (node: AerialPathNode) => void,
|
||||
): ScatterplotLayer<RenderedObserverNode> {
|
||||
return new ScatterplotLayer<RenderedObserverNode>({
|
||||
id: OBSERVER_LAYER_ID,
|
||||
data: observerNodes,
|
||||
getPosition: (node) => node.renderedPosition,
|
||||
getFillColor: [59, 130, 246, 255],
|
||||
getRadius: 8,
|
||||
radiusUnits: 'pixels',
|
||||
// The base-map node remains underneath this fill. A transient outline on
|
||||
// a newly-created layer exposed its white stroke during packet changes.
|
||||
stroked: false,
|
||||
getLineWidth: 0,
|
||||
pickable: Boolean(onNodeClick),
|
||||
onClick: ({ object }: PickingInfo<AerialPathNode>) => { if (object) onNodeClick?.(object); },
|
||||
});
|
||||
}
|
||||
|
||||
function layersForFrame(
|
||||
segments: RenderedSegment[],
|
||||
nodes: RenderedNode[],
|
||||
@@ -501,22 +523,10 @@ function layersForFrame(
|
||||
}));
|
||||
}
|
||||
|
||||
if (observerNodes.length > 0) {
|
||||
layers.push(new ScatterplotLayer<RenderedObserverNode>({
|
||||
id: 'resolved-path-observers',
|
||||
data: observerNodes,
|
||||
getPosition: (node) => node.renderedPosition,
|
||||
getFillColor: [59, 130, 246, 255],
|
||||
getLineColor: [255, 255, 255, 235],
|
||||
getRadius: 8,
|
||||
radiusUnits: 'pixels',
|
||||
stroked: true,
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: 2,
|
||||
pickable: Boolean(onNodeClick),
|
||||
onClick: ({ object }: PickingInfo<AerialPathNode>) => { if (object) onNodeClick?.(object); },
|
||||
}));
|
||||
}
|
||||
// Always submit the same layer ID, including for an empty data set. Deck.gl
|
||||
// then transfers layer state across packet updates instead of destroying and
|
||||
// recreating the observer layer on an empty/non-empty transition.
|
||||
layers.push(observerLayerForFrame(observerNodes, onNodeClick));
|
||||
|
||||
if (pulses.length > 0) {
|
||||
layers.push(new ScatterplotLayer<LeadingPulse>({
|
||||
|
||||
Reference in new issue
Block a user