diff --git a/backend/src/path-beta/resolver.ts b/backend/src/path-beta/resolver.ts index aa8b129..277ce6e 100644 --- a/backend/src/path-beta/resolver.ts +++ b/backend/src/path-beta/resolver.ts @@ -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 { + const choiceCounts = new Map(); + const groupTotals = new Map(); + 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(); + 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 { 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( + `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(); - 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(); 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'); diff --git a/backend/src/path-beta/resolverContext.test.ts b/backend/src/path-beta/resolverContext.test.ts index 25bd997..42568a0 100644 --- a/backend/src/path-beta/resolverContext.test.ts +++ b/backend/src/path-beta/resolverContext.test.ts @@ -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({ diff --git a/backend/src/path-beta/sharedDecoder.test.ts b/backend/src/path-beta/sharedDecoder.test.ts index 6cacc87..8fd8f3c 100644 --- a/backend/src/path-beta/sharedDecoder.test.ts +++ b/backend/src/path-beta/sharedDecoder.test.ts @@ -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; diff --git a/backend/src/path-beta/sharedDecoder.ts b/backend/src/path-beta/sharedDecoder.ts index f4a84e7..a8ae2f6 100644 --- a/backend/src/path-beta/sharedDecoder.ts +++ b/backend/src/path-beta/sharedDecoder.ts @@ -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 { - const anchors = new Map(); +function directAnchorMap(group: BetaCanonicalGroup): Map { + const anchors = new Map(); 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 { + const evidenceByPosition = new Map>(); + 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(); + values.add(matches[0]!.node_id); + evidenceByPosition.set(position, values); + } + } + + const confirmed = new Map(); + 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}`) diff --git a/backend/src/path-core/decoder.test.ts b/backend/src/path-core/decoder.test.ts index 4791a0e..fba4a29 100644 --- a/backend/src/path-core/decoder.test.ts +++ b/backend/src/path-core/decoder.test.ts @@ -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'], diff --git a/backend/src/path-core/decoder.ts b/backend/src/path-core/decoder.ts index 885f82c..937880f 100644 --- a/backend/src/path-core/decoder.ts +++ b/backend/src/path-core/decoder.ts @@ -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(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 }; } diff --git a/frontend/src/components/Map/AnimatedPathOverlay.test.ts b/frontend/src/components/Map/AnimatedPathOverlay.test.ts index 4d2d5bd..7cb3229 100644 --- a/frontend/src/components/Map/AnimatedPathOverlay.test.ts +++ b/frontend/src/components/Map/AnimatedPathOverlay.test.ts @@ -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(); const firstObserver: AerialPath = { diff --git a/frontend/src/components/Map/AnimatedPathOverlay.tsx b/frontend/src/components/Map/AnimatedPathOverlay.tsx index 09f587e..227d718 100644 --- a/frontend/src/components/Map/AnimatedPathOverlay.tsx +++ b/frontend/src/components/Map/AnimatedPathOverlay.tsx @@ -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 { + return new ScatterplotLayer({ + 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) => { if (object) onNodeClick?.(object); }, + }); +} + function layersForFrame( segments: RenderedSegment[], nodes: RenderedNode[], @@ -501,22 +523,10 @@ function layersForFrame( })); } - if (observerNodes.length > 0) { - layers.push(new ScatterplotLayer({ - 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) => { 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({