From 457eb77cbb24af4124d527992c194e2a317eb2f3 Mon Sep 17 00:00:00 2001 From: gadgethd <111318106+gadgethd@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:13:11 +0100 Subject: [PATCH] fix(pathing): expose canonical path metadata in public DTOs --- backend/src/path-beta/resolver.ts | 64 ++++++++++++++++++++++++- backend/src/pathing/pathingPublicDto.ts | 64 +++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/backend/src/path-beta/resolver.ts b/backend/src/path-beta/resolver.ts index b3be347..c8f3550 100644 --- a/backend/src/path-beta/resolver.ts +++ b/backend/src/path-beta/resolver.ts @@ -414,11 +414,27 @@ function findSharedPathPrefix(pathsByObserver: readonly string[][]): string[] { return first.slice(0, length); } +export type BetaCanonicalPathNode = { + position: number; + hash: string; + nodeId: string | null; + name: string | null; + lat: number | null; + lon: number | null; + ambiguous: boolean; + confidence: number | null; +}; + +export type BetaPathObserver = { observerId: string }; + export type BetaResolvedPayload = { ok: boolean; packetHash: string; mode: 'resolved' | 'none'; confidence: number | null; + canonicalPath: BetaCanonicalPathNode[]; + observers: BetaPathObserver[]; + network: string; permutationCount: number; remainingHops: number | null; purplePath: [number, number][] | null; @@ -492,6 +508,25 @@ function maskPath( return path.map((point) => hiddenCoordMask.get(hiddenCoordKey(point[0], point[1])) ?? point); } +function buildCanonicalPath(decoded: BetaSharedDecode): BetaCanonicalPathNode[] { + const nodes: BetaCanonicalPathNode[] = []; + for (let position = 0; position < decoded.canonicalHashes.length; position++) { + const hop = decoded.hops.get(position); + const confidence = decoded.hopConfidences.get(position) ?? null; + nodes.push({ + position, + hash: decoded.canonicalHashes[position] ?? hop?.hash ?? '', + nodeId: hop?.nodeId ?? null, + name: hop?.name ?? null, + lat: hop?.lat ?? null, + lon: hop?.lon ?? null, + ambiguous: hop?.ambiguous ?? (confidence == null || confidence < BETA_PURPLE_THRESHOLD), + confidence, + }); + } + return nodes; +} + function maskResolvedPayload( payload: BetaResolvedPayload, hiddenCoordMask: ReadonlyMap, @@ -539,6 +574,9 @@ function emptyPayload( packetHash, mode: 'none', confidence: null, + canonicalPath: [], + observers: [], + network: '', permutationCount: 0, remainingHops, purplePath: null, @@ -561,6 +599,7 @@ function payloadFromProjection( packetHash: string, entry: BetaObserverEntry, projection: BetaPathProjection, + canonical: { canonicalPath: BetaCanonicalPathNode[]; observers: BetaPathObserver[]; network: string }, ): BetaResolvedPayload { if (!projection.purplePath) { return emptyPayload(packetHash, entry.packet, entry.hashes.length, projection.remainingHops); @@ -570,6 +609,9 @@ function payloadFromProjection( packetHash, mode: 'resolved', confidence: projection.confidence, + canonicalPath: canonical.canonicalPath, + observers: canonical.observers, + network: canonical.network, permutationCount: 0, remainingHops: projection.remainingHops, purplePath: projection.purplePath, @@ -670,7 +712,11 @@ export async function resolveBetaPathForPacketHash( ? context.nodesById.get(selectedEntry.packet.src_node_id) ?? null : null; const projection = projectCanonicalPathForObserver(selectedEntry, decoded, context, source); - const payload = payloadFromProjection(packetHash, selectedEntry, projection); + const payload = payloadFromProjection(packetHash, selectedEntry, projection, { + canonicalPath: buildCanonicalPath(decoded), + observers: [{ observerId: selectedEntry.observerId }], + network, + }); if (payload.mode === 'resolved' && shouldTouchPredictedOnline(options)) { await recordPredictedOnline(projection.nodeIds); } @@ -692,8 +738,12 @@ export type RegionLink = { export type MultiObserverResolvedPayload = { ok: boolean; packetHash: string; + network: string; observerCount: number; sharedPrefixLength: number; + canonicalPath: BetaCanonicalPathNode[]; + observers: BetaPathObserver[]; + confidence: number | null; results: BetaResolvedPayload[]; regionLinks?: RegionLink[]; stickyUpdates?: Record; @@ -821,6 +871,7 @@ export async function resolveMultiObserverBetaPath( const groups = groupCompatibleObservations(entries); const hiddenCoordMask = buildHiddenCoordMask(context.nodesById); const results: BetaResolvedPayload[] = []; + let firstCanonicalPath: BetaCanonicalPathNode[] = []; const predictedOnlineNodeIds = new Set(); const stickyUpdates: Record = {}; const stickyConflicts = new Set(); @@ -829,6 +880,7 @@ export async function resolveMultiObserverBetaPath( for (const group of groups) { throwIfResolutionAborted(options); const decoded = decodeBetaCanonicalGroup(group, context, stickyMap, stickyAgeFraction); + if (firstCanonicalPath.length === 0) firstCanonicalPath = buildCanonicalPath(decoded); for (let position = 0; position < decoded.canonicalHashes.length; position++) { const hop = decoded.hops.get(position); const confidence = decoded.hopConfidences.get(position) ?? 0; @@ -842,7 +894,11 @@ export async function resolveMultiObserverBetaPath( ? context.nodesById.get(entry.packet.src_node_id) ?? null : null; const projection = projectCanonicalPathForObserver(entry, decoded, context, source); - const payload = payloadFromProjection(packetHash, entry, projection); + const payload = payloadFromProjection(packetHash, entry, projection, { + canonicalPath: buildCanonicalPath(decoded), + observers: group.members.map((member) => ({ observerId: member.observerId })), + network, + }); results.push(maskResolvedPayload(payload, hiddenCoordMask)); if (payload.mode === 'resolved') { for (const nodeId of projection.nodeIds) predictedOnlineNodeIds.add(nodeId); @@ -865,8 +921,12 @@ export async function resolveMultiObserverBetaPath( return { ok: true, packetHash, + network, observerCount: entries.length, sharedPrefixLength, + canonicalPath: firstCanonicalPath, + observers: entries.map((entry) => ({ observerId: entry.observerId })), + confidence: results[0]?.confidence ?? null, results, ...(regionLinks.length > 0 ? { regionLinks } : {}), ...(Object.keys(stickyUpdates).length > 0 ? { stickyUpdates } : {}), diff --git a/backend/src/pathing/pathingPublicDto.ts b/backend/src/pathing/pathingPublicDto.ts index 2c730f0..fdcf59a 100644 --- a/backend/src/pathing/pathingPublicDto.ts +++ b/backend/src/pathing/pathingPublicDto.ts @@ -9,11 +9,27 @@ export type PublicPathExplanation = { limitations?: string[]; }; +export type PublicCanonicalPathNode = { + position: number; + hash: string; + nodeId: string | null; + name: string | null; + lat: number | null; + lon: number | null; + ambiguous: boolean; + confidence: number | null; +}; + +export type PublicPathObserver = { observerId: string }; + export type PublicBetaResultDto = { ok: boolean; packetHash: string; mode: 'resolved' | 'fallback' | 'none'; confidence: number | null; + canonicalPath: PublicCanonicalPathNode[]; + observers: PublicPathObserver[]; + network: string; permutationCount: number; remainingHops: number | null; purplePath: PublicPathPoint[] | null; @@ -27,8 +43,12 @@ export type PublicBetaResultDto = { export type PublicMultiObserverDto = { ok: boolean; packetHash: string; + network: string; observerCount: number; sharedPrefixLength: number; + canonicalPath: PublicCanonicalPathNode[]; + observers: PublicPathObserver[]; + confidence: number | null; results: PublicBetaResultDto[]; }; @@ -108,6 +128,41 @@ function projectExplanation(value: unknown): PublicPathExplanation | undefined { }; } +function projectCanonicalPath(value: unknown): PublicCanonicalPathNode[] { + if (!Array.isArray(value)) return []; + const nodes: PublicCanonicalPathNode[] = []; + for (const item of value) { + if (!isPlainRecord(item)) continue; + const hash = ownValue(item, 'hash'); + const nodeId = ownValue(item, 'nodeId'); + const name = ownValue(item, 'name'); + if (typeof hash !== 'string') continue; + nodes.push({ + position: finiteNumber(ownValue(item, 'position'), -1), + hash, + nodeId: typeof nodeId === 'string' ? nodeId : null, + name: typeof name === 'string' ? name : null, + lat: nullableNumber(ownValue(item, 'lat')), + lon: nullableNumber(ownValue(item, 'lon')), + ambiguous: ownValue(item, 'ambiguous') === true, + confidence: nullableNumber(ownValue(item, 'confidence')), + }); + } + return nodes; +} + +function projectObservers(value: unknown): PublicPathObserver[] { + if (!Array.isArray(value)) return []; + const observers: PublicPathObserver[] = []; + for (const item of value) { + if (isPlainRecord(item)) { + const observerId = ownValue(item, 'observerId'); + if (typeof observerId === 'string') observers.push({ observerId }); + } + } + return observers; +} + export function toPublicBetaResultDto(value: unknown): PublicBetaResultDto { if (!isPlainRecord(value)) throw new Error('INVALID_PATH_RESULT'); const packetHash = ownValue(value, 'packetHash'); @@ -117,11 +172,15 @@ export function toPublicBetaResultDto(value: unknown): PublicBetaResultDto { throw new Error('INVALID_PATH_RESULT'); } const explanation = projectExplanation(ownValue(value, 'explanation')); + const network = ownValue(value, 'network'); return { ok: ownValue(value, 'ok') === true, packetHash, mode, confidence: nullableNumber(ownValue(value, 'confidence')), + canonicalPath: projectCanonicalPath(ownValue(value, 'canonicalPath')), + observers: projectObservers(ownValue(value, 'observers')), + network: typeof network === 'string' ? network : '', permutationCount: finiteNumber(ownValue(value, 'permutationCount')), remainingHops: nullableNumber(ownValue(value, 'remainingHops')), purplePath: projectPath(ownValue(value, 'purplePath')), @@ -138,11 +197,16 @@ export function toPublicMultiObserverDto(value: unknown): PublicMultiObserverDto const packetHash = ownValue(value, 'packetHash'); const results = ownValue(value, 'results'); if (typeof packetHash !== 'string' || !Array.isArray(results)) throw new Error('INVALID_PATH_RESULT'); + const network = ownValue(value, 'network'); return { ok: ownValue(value, 'ok') === true, packetHash, + network: typeof network === 'string' ? network : '', observerCount: finiteNumber(ownValue(value, 'observerCount')), sharedPrefixLength: finiteNumber(ownValue(value, 'sharedPrefixLength')), + canonicalPath: projectCanonicalPath(ownValue(value, 'canonicalPath')), + observers: projectObservers(ownValue(value, 'observers')), + confidence: nullableNumber(ownValue(value, 'confidence')), results: results.map(toPublicBetaResultDto), }; }