fix: anchor confirmed paths and stabilize observer dots

This commit is contained in:
gadgethd committed 2026-08-27 23:48:01 +00:00
1 parent 2a4dd27bcd
commit bf7d7ad85e
8 files changed
+344 -87

No files matched your search

+57 -25
View File
@@ -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');
+44 -1
View File
@@ -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;
+104 -20
View File
@@ -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}`)
+14
View File
@@ -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'],
+47 -24
View File
@@ -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>({