diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index ab4d838..44cd514 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -4,6 +4,7 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node: import mqtt from 'mqtt'; import { getNodes, getNodeHistory, getRecentPackets, query, MIN_LINK_OBSERVATIONS } from '../db/index.js'; import { getWorkerHealthOverview } from '../health/status.js'; +import { resolveBetaPathForPacketHash } from '../path-beta/resolver.js'; const router = Router(); const OWNER_COOKIE_NAME = 'meshcore_owner_session'; @@ -297,6 +298,28 @@ router.get('/packets/recent', async (req, res) => { } }); +// GET /api/path-beta/resolve?hash=&network=teesside|ukmesh|all +router.get('/path-beta/resolve', async (req, res) => { + try { + const packetHash = String(req.query['hash'] ?? '').trim(); + if (!packetHash) { + res.status(400).json({ error: 'Missing hash query parameter' }); + return; + } + const networkRaw = String(req.query['network'] ?? 'teesside').trim().toLowerCase(); + const network = networkRaw === 'ukmesh' || networkRaw === 'all' ? networkRaw : 'teesside'; + const resolved = await resolveBetaPathForPacketHash(packetHash, network); + if (!resolved) { + res.status(404).json({ error: 'Packet not found' }); + return; + } + res.json(resolved); + } catch (err) { + console.error('[api] GET /path-beta/resolve', (err as Error).message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // GET /api/stats router.get('/stats', async (req, res) => { try { @@ -496,6 +519,84 @@ router.get('/path-learning', async (req, res) => { } }); +// GET /api/path-sim/latest — latest path simulation run summary +router.get('/path-sim/latest', async (req, res) => { + try { + const network = (req.query['network'] as string | undefined)?.trim().toLowerCase(); + const hasNetwork = Boolean(network && network !== 'all'); + const result = await query<{ + id: number; + started_at: string; + completed_at: string | null; + network: string; + packets_total: number; + packets_eligible: number; + packets_fully_resolved: number; + packets_unresolved: number; + truncated_searches: number; + permutation_histogram: Record; + remaining_hops_histogram: Record; + summary: Record; + }>( + `SELECT id, started_at::text, completed_at::text, network, + packets_total, packets_eligible, packets_fully_resolved, packets_unresolved, truncated_searches, + permutation_histogram, remaining_hops_histogram, summary + FROM path_simulation_runs + ${hasNetwork ? 'WHERE network = $1' : ''} + ORDER BY started_at DESC + LIMIT 1`, + hasNetwork ? [network] : [], + ); + if (result.rows.length < 1) { + res.status(404).json({ error: 'No path simulation runs found yet' }); + return; + } + res.json(result.rows[0]); + } catch (err) { + console.error('[api] GET /path-sim/latest', (err as Error).message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// GET /api/path-sim/history?limit=20&network=all +router.get('/path-sim/history', async (req, res) => { + try { + const network = (req.query['network'] as string | undefined)?.trim().toLowerCase(); + const hasNetwork = Boolean(network && network !== 'all'); + const limit = Math.min(200, Math.max(1, Number(req.query['limit'] ?? 20))); + const result = await query<{ + id: number; + started_at: string; + completed_at: string | null; + network: string; + packets_total: number; + packets_eligible: number; + packets_fully_resolved: number; + packets_unresolved: number; + truncated_searches: number; + permutation_histogram: Record; + remaining_hops_histogram: Record; + summary: Record; + }>( + `SELECT id, started_at::text, completed_at::text, network, + packets_total, packets_eligible, packets_fully_resolved, packets_unresolved, truncated_searches, + permutation_histogram, remaining_hops_histogram, summary + FROM path_simulation_runs + ${hasNetwork ? 'WHERE network = $1' : ''} + ORDER BY started_at DESC + LIMIT ${hasNetwork ? '$2' : '$1'}`, + hasNetwork ? [network, limit] : [limit], + ); + res.json({ + count: result.rows.length, + runs: result.rows, + }); + } catch (err) { + console.error('[api] GET /path-sim/history', (err as Error).message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // GET /api/health — public health overview with worker status and history router.get('/health', async (_req, res) => { try { diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 464c202..47e8275 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -255,3 +255,43 @@ CREATE TABLE IF NOT EXISTS frontend_error_events ( ); CREATE INDEX IF NOT EXISTS frontend_error_events_time_idx ON frontend_error_events(time DESC); + +-- ─── Beta/red path simulation run reports ─────────────────────────────────── + +CREATE TABLE IF NOT EXISTS path_simulation_runs ( + id BIGSERIAL PRIMARY KEY, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + network TEXT NOT NULL DEFAULT 'all', + packets_total INTEGER NOT NULL DEFAULT 0, + packets_eligible INTEGER NOT NULL DEFAULT 0, + packets_fully_resolved INTEGER NOT NULL DEFAULT 0, + packets_unresolved INTEGER NOT NULL DEFAULT 0, + truncated_searches INTEGER NOT NULL DEFAULT 0, + permutation_histogram JSONB NOT NULL DEFAULT '{}'::jsonb, + remaining_hops_histogram JSONB NOT NULL DEFAULT '{}'::jsonb, + summary JSONB NOT NULL DEFAULT '{}'::jsonb +); +CREATE INDEX IF NOT EXISTS path_simulation_runs_started_idx + ON path_simulation_runs(started_at DESC); + +CREATE TABLE IF NOT EXISTS path_sim_population ( + generation INTEGER NOT NULL, + variant_id TEXT NOT NULL, + params JSONB NOT NULL DEFAULT '{}'::jsonb, + fitness DOUBLE PRECISION, + run_id BIGINT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (generation, variant_id) +); +CREATE INDEX IF NOT EXISTS path_sim_population_generation_idx + ON path_sim_population(generation, fitness DESC NULLS LAST); + +CREATE TABLE IF NOT EXISTS path_sim_evolution_state ( + id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + current_generation INTEGER NOT NULL DEFAULT 1, + evolved_generation INTEGER NOT NULL DEFAULT 0, + best_variant_id TEXT, + best_fitness DOUBLE PRECISION, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/backend/src/health/status.ts b/backend/src/health/status.ts index 6ee5b25..98bbed5 100644 --- a/backend/src/health/status.ts +++ b/backend/src/health/status.ts @@ -86,6 +86,7 @@ async function currentWorkers(): Promise { healthRecent, healthLast, backfillState, + pathSimLatest, ] = await Promise.all([ r.llen('meshcore:viewshed_jobs'), r.llen('meshcore:link_jobs'), @@ -109,6 +110,10 @@ async function currentWorkers(): Promise { `SELECT COUNT(*)::text AS links, MAX(last_observed)::text AS last_observed FROM node_links`, ), + query<{ completed_at: string | null }>( + `SELECT MAX(completed_at)::text AS completed_at + FROM path_simulation_runs`, + ), ]); const stats = systemStats(); @@ -124,6 +129,8 @@ async function currentWorkers(): Promise { const learningRecent = learningLast ? (Date.now() - Date.parse(learningLast)) <= 60 * 60_000 : false; const backfillLinks = Number(backfillState.rows[0]?.links ?? 0); const backfillLast = backfillState.rows[0]?.last_observed ?? null; + const pathSimLast = pathSimLatest.rows[0]?.completed_at ?? null; + const pathSimRecent = pathSimLast ? (Date.now() - Date.parse(pathSimLast)) <= 6 * 60 * 60_000 : false; return [ { @@ -176,6 +183,16 @@ async function currentWorkers(): Promise { mem_used_pct: memPct, disk_used_pct: diskPct, }, + { + worker_name: 'path-sim-worker', + status: pathSimRecent ? 'running' : 'idle', + queue_depth: 0, + processed_1h: pathSimLast && (Date.now() - Date.parse(pathSimLast)) <= 60 * 60_000 ? 1 : 0, + last_activity_at: pathSimLast, + cpu_load_1m: load, + mem_used_pct: memPct, + disk_used_pct: diskPct, + }, ]; } diff --git a/backend/src/path-beta/resolver.ts b/backend/src/path-beta/resolver.ts new file mode 100644 index 0000000..d0a5031 --- /dev/null +++ b/backend/src/path-beta/resolver.ts @@ -0,0 +1,1380 @@ +import { MIN_LINK_OBSERVATIONS, query } from '../db/index.js'; + +type MeshNode = { + node_id: string; + name: string | null; + lat: number | null; + lon: number | null; + iata: string | null; + role: number | null; + elevation_m: number | null; +}; + +type NodeCoverage = { + node_id: string; + radius_m: number | null; +}; + +type LinkMetrics = { + observed_count: number; + itm_path_loss_db: number | null; + count_a_to_b: number | null; + count_b_to_a: number | null; +}; + +type PathLearningModel = { + prefixProbabilities: Map; + transitionProbabilities: Map; + edgeScores: Map; + motifProbabilities: Map; + confidenceScale: number; + confidenceBias: number; + bucketHours: number; +}; + +type PathPacket = { + packet_hash: string; + rx_node_id: string | null; + src_node_id: string | null; + packet_type: number | null; + hop_count: number | null; + path_hashes: string[] | null; +}; + +type BetaResolveContext = { + loadedAt: number; + nodesById: Map; + coverageByNode: Map; + linkPairs: Set; + linkMetrics: Map; + learningModel: PathLearningModel; +}; + +const MAX_BETA_HOPS = 25; +const BETA_PURPLE_THRESHOLD = 0.45; +const R_EFF_M = 6_371_000 / (1 - 0.25); +const PREFIX_AMBIGUITY_FLOOR_KM = 45; +const WEAK_LINK_PATHLOSS_MAX_DB = 137.88; +const MAX_HOP_KM = 127.19 * 1.609344; +const CONTEXT_TTL_MS = 60_000; +const MODEL_LIMIT = 6000; +const MAX_PERMUTATION_HOP_KM = MAX_HOP_KM; +const MAX_RENDER_PERMUTATIONS = 24; +const MAX_PERMUTATION_STATES = 120_000; + +const contextCache = new Map(); + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function hasCoords(n: MeshNode | null | undefined): n is MeshNode { + return Boolean(n && typeof n.lat === 'number' && typeof n.lon === 'number'); +} + +function linkKey(a: string, b: string): string { + return a < b ? `${a}:${b}` : `${b}:${a}`; +} + +function distKm(a: MeshNode, b: MeshNode): number { + const midLat = ((a.lat! + b.lat!) / 2) * (Math.PI / 180); + const dlat = (a.lat! - b.lat!) * 111; + const dlon = (a.lon! - b.lon!) * 111 * Math.cos(midLat); + return Math.hypot(dlat, dlon); +} + +function hasLoS(a: MeshNode, b: MeshNode): boolean { + const hA = (a.elevation_m ?? 0) + 5; + const hB = (b.elevation_m ?? 0) + 5; + const d = distKm(a, b) * 1000; + if (d < 1) return true; + for (let i = 1; i < 20; i++) { + const t = i / 20; + const x = t * d; + const los = hA + (hB - hA) * t; + const bulge = x * (d - x) / (2 * R_EFF_M); + if (los < bulge) return false; + } + return true; +} + +function nodeRange(nodeId: string, coverageByNode: Map): number { + const radiusM = coverageByNode.get(nodeId); + if (!radiusM) return 50; + return Math.min(80, Math.max(50, radiusM / 1000)); +} + +function canReach(a: MeshNode, b: MeshNode, coverageByNode: Map): boolean { + const threshold = Math.max(nodeRange(a.node_id, coverageByNode), nodeRange(b.node_id, coverageByNode)); + return distKm(a, b) < threshold; +} + +function currentHourBucket(bucketHours: number): number { + const now = new Date(); + return Math.floor(now.getUTCHours() / bucketHours); +} + +function edgeKey(receiverRegion: string, bucket: number, fromId: string, toId: string): string { + return `${receiverRegion}|${bucket}|${fromId}|${toId}`; +} + +function motifKey(receiverRegion: string, bucket: number, nodeIds: string[]): string { + return `${receiverRegion}|${bucket}|${nodeIds.length}|${nodeIds.join('>')}`; +} + +function minimumDirectionalSupport(observedCount: number): number { + if (observedCount >= 120) return 0.45; + if (observedCount >= 70) return 0.42; + if (observedCount >= 35) return 0.38; + if (observedCount >= 20) return 0.34; + if (observedCount >= 10) return 0.30; + return 0.26; +} + +function directionalSupport(meta: LinkMetrics, fromId: string, toId: string): number { + const key = linkKey(fromId, toId); + const [aId] = key.split(':'); + const ab = Number(meta.count_a_to_b ?? 0); + const ba = Number(meta.count_b_to_a ?? 0); + const total = ab + ba; + if (total <= 0) return 0; + const fromTo = fromId === aId ? ab : ba; + return fromTo / total; +} + +function confirmedLinkConfidence( + meta: LinkMetrics | undefined, + fromId: string, + toId: string, + prior?: { prefix?: number; transition?: number; motif?: number; edge?: number; ambiguity?: number }, +): number { + if (!meta) return 0; + + const observed = Number(meta.observed_count ?? 0); + const pathLoss = meta.itm_path_loss_db; + let base: number; + if (pathLoss == null) { + base = observed >= 60 ? 0.68 : observed >= 30 ? 0.56 : 0.34; + } else if (pathLoss <= 120) { + base = 0.95; + } else if (pathLoss <= 125) { + base = 0.9; + } else if (pathLoss <= 130) { + base = 0.84; + } else if (pathLoss <= 133) { + base = 0.78; + } else if (pathLoss <= 135) { + base = 0.7; + } else { + base = 0.56; + } + + const direction = directionalSupport(meta, fromId, toId); + const minDir = minimumDirectionalSupport(observed); + const dirScale = direction >= minDir + ? 1 + (direction - minDir) * 0.38 + : Math.max(0.45, 1 - (minDir - direction) * 1.8); + + const confidence = base * Math.min(1.25, Math.max(0.45, dirScale)); + const priorBoost = Number(prior?.prefix ?? 0) + + Number(prior?.transition ?? 0) + + Number(prior?.motif ?? 0) + + Number(prior?.edge ?? 0) + + Number(prior?.ambiguity ?? 0); + return clamp(confidence + priorBoost, 0, 1); +} + +function buildClashAdjacency( + candidates: MeshNode[], + linkPairs: Set, + linkMetrics: Map, +): Map> { + const byId = new Map(); + for (const node of candidates) byId.set(node.node_id, node); + + const adjacency = new Map>(); + for (const key of linkPairs) { + const [aId, bId] = key.split(':'); + if (!aId || !bId) continue; + if (!byId.has(aId) || !byId.has(bId)) continue; + const meta = linkMetrics.get(key); + const pathLoss = meta?.itm_path_loss_db; + if (pathLoss == null || pathLoss > WEAK_LINK_PATHLOSS_MAX_DB) continue; + if (!adjacency.has(aId)) adjacency.set(aId, new Set()); + if (!adjacency.has(bId)) adjacency.set(bId, new Set()); + adjacency.get(aId)!.add(bId); + adjacency.get(bId)!.add(aId); + } + return adjacency; +} + +function isWeakOrBetter(meta: LinkMetrics | undefined): boolean { + const pathLoss = meta?.itm_path_loss_db; + return pathLoss != null && pathLoss <= WEAK_LINK_PATHLOSS_MAX_DB; +} + +function strongConfirmedFloor(meta: LinkMetrics | undefined): number { + if (!meta) return 0; + const observed = meta.observed_count ?? 0; + const pathLoss = meta.itm_path_loss_db; + if (observed >= 120 && pathLoss != null && pathLoss <= 125) return 0.82; + if (observed >= 70 && pathLoss != null && pathLoss <= 130) return 0.76; + if (observed >= 35 && pathLoss != null && pathLoss <= 133) return 0.70; + return 0; +} + +function attachSrcToPath( + lowPath: [number, number][] | null, + purplePath: [number, number][] | null, + src: MeshNode | null, + forceIncludeSource: boolean, +): [number, number][] | null { + if (!forceIncludeSource || !src || typeof src.lat !== 'number' || typeof src.lon !== 'number') return lowPath; + const srcPt: [number, number] = [src.lat, src.lon]; + const anchor = lowPath?.[0] ?? purplePath?.[0]; + if (!anchor) return lowPath; + if (Math.abs(anchor[0] - srcPt[0]) <= 0.0001 && Math.abs(anchor[1] - srcPt[1]) <= 0.0001) return lowPath; + return lowPath ? ([srcPt, ...lowPath] as [number, number][]) : [srcPt, anchor]; +} + +function samePoint(a: [number, number], b: [number, number], epsilon = 1e-4): boolean { + return Math.abs(a[0] - b[0]) <= epsilon && Math.abs(a[1] - b[1]) <= epsilon; +} + +function trimRedToPurpleStitch( + redPath: [number, number][] | null, + purplePath: [number, number][] | null, +): [number, number][] | null { + if (!redPath || redPath.length < 2) return null; + if (!purplePath || purplePath.length < 2) return redPath; + const stitch = purplePath[0]; + if (!stitch) return redPath; + const idx = redPath.findIndex((point) => samePoint(point, stitch)); + if (idx <= 0) return redPath; + const trimmed = redPath.slice(0, idx + 1); + return trimmed.length >= 2 ? trimmed : null; +} + +function trimPathToStartStitch( + path: [number, number][] | null, + startStitch: [number, number] | null, +): [number, number][] | null { + if (!path || path.length < 2 || !startStitch) return path; + const idx = path.findIndex((point) => samePoint(point, startStitch)); + if (idx < 0 || idx >= path.length - 1) return path; + const trimmed = path.slice(idx); + return trimmed.length >= 2 ? trimmed : null; +} + +function trimPathBetweenStitches( + path: [number, number][] | null, + startStitch: [number, number] | null, + endPath: [number, number][] | null, +): [number, number][] | null { + return trimRedToPurpleStitch(trimPathToStartStitch(path, startStitch), endPath); +} + +function segmentizePath(path: [number, number][] | null): Array<[[number, number], [number, number]]> { + if (!path || path.length < 2) return []; + const segments: Array<[[number, number], [number, number]]> = []; + for (let i = 0; i < path.length - 1; i++) { + const a = path[i]; + const b = path[i + 1]; + if (!a || !b) continue; + segments.push([a, b]); + } + return segments; +} + +function edgeMetricConfidence(fromId: string, toId: string, linkMetrics: Map): number { + const meta = linkMetrics.get(linkKey(fromId, toId)); + if (!meta) return 0; + const observed = meta.observed_count ?? 0; + const pathLoss = meta.itm_path_loss_db; + let base: number; + if (pathLoss == null) base = observed >= 60 ? 0.72 : observed >= 30 ? 0.62 : 0.45; + else if (observed >= 120 && pathLoss <= 125) base = 0.86; + else if (observed >= 70 && pathLoss <= 130) base = 0.80; + else if (observed >= 35 && pathLoss <= 133) base = 0.74; + else if (pathLoss <= 135) base = Math.min(0.72, 0.48 + Math.log10(1 + observed) * 0.10); + else base = Math.min(0.58, 0.40 + Math.log10(1 + observed) * 0.08); + + if (observed >= 120) return Math.max(base, 0.82); + if (observed >= 70) return Math.max(base, 0.74); + if (observed >= 35) return Math.max(base, 0.62); + if (observed >= 20) return Math.max(base, 0.56); + return base; +} + +function splitResolvedAndAlternatives( + result: { path: [number, number][]; segmentConfidence: number[]; nodeIds: string[] }, + threshold: number, + linkMetrics: Map, +): { purplePath: [number, number][] | null; redPath: [number, number][] | null; remainingHops: number } { + const seg = result.segmentConfidence.map((v, i) => { + const fromId = result.nodeIds[i]; + const toId = result.nodeIds[i + 1]; + if (!fromId || !toId) return v; + return Math.max(v, edgeMetricConfidence(fromId, toId, linkMetrics)); + }); + + let splitIdx = -1; + for (let i = seg.length - 1; i >= 0; i--) { + if (seg[i]! < threshold) { + splitIdx = i; + break; + } + } + + if (splitIdx < 0) { + return { purplePath: result.path, redPath: null, remainingHops: 0 }; + } + + const purpleSlice = result.path.slice(splitIdx + 1); + const redSlice = result.path.slice(0, splitIdx + 2); + const purplePath = purpleSlice.length >= 2 ? purpleSlice : null; + const redPath = redSlice.length >= 2 ? redSlice : null; + return { purplePath, redPath, remainingHops: splitIdx + 1 }; +} + +function splitResolvedFromSource( + result: { path: [number, number][]; segmentConfidence: number[]; nodeIds: string[] }, + threshold: number, + linkMetrics: Map, +): { purplePath: [number, number][] | null; remainingHops: number } { + const seg = result.segmentConfidence.map((v, i) => { + const fromId = result.nodeIds[i]; + const toId = result.nodeIds[i + 1]; + if (!fromId || !toId) return v; + return Math.max(v, edgeMetricConfidence(fromId, toId, linkMetrics)); + }); + let keepEdges = 0; + for (let i = 0; i < seg.length; i++) { + if (seg[i]! < threshold) break; + keepEdges += 1; + } + if (keepEdges < 1) return { purplePath: null, remainingHops: result.path.length - 1 }; + const purplePath = result.path.slice(0, keepEdges + 1); + return { + purplePath: purplePath.length >= 2 ? purplePath : null, + remainingHops: Math.max(0, seg.length - keepEdges), + }; +} + +function buildFallbackPrefixPath( + hopHashes: string[], + src: MeshNode | null, + rx: MeshNode, + nodesById: Map, + forceIncludeSource = false, +): { path: [number, number][]; nodeIds: string[] } | null { + const repeaters = Array.from(nodesById.values()).filter( + (n) => hasCoords(n) && (n.role === null || n.role === 2), + ); + + const pickedNearRx: MeshNode[] = []; + const visited = new Set([rx.node_id]); + let prev = rx; + for (const h of [...hopHashes].reverse()) { + const prefix = h.slice(0, 2).toUpperCase(); + const candidates = repeaters + .filter((n) => !visited.has(n.node_id) && n.node_id.slice(0, 2).toUpperCase() === prefix) + .sort((a, b) => { + const score = (c: MeshNode) => { + const distPenalty = distKm(c, prev) / 50; + if (!src) return -distPenalty; + const dLat = src.lat! - prev.lat!; + const dLon = src.lon! - prev.lon!; + const cLat = c.lat! - prev.lat!; + const cLon = c.lon! - prev.lon!; + const dot = dLat * cLat + dLon * cLon; + const mag = Math.hypot(dLat, dLon) * Math.hypot(cLat, cLon); + const align = mag > 0 ? dot / mag : 0; + return align - distPenalty; + }; + return score(b) - score(a); + }); + const chosen = candidates[0]; + if (!chosen) continue; + pickedNearRx.push(chosen); + visited.add(chosen.node_id); + prev = chosen; + } + + const hopsFarToNear = [...pickedNearRx].reverse(); + const pathNodes: MeshNode[] = [...(hasCoords(src) && forceIncludeSource ? [src] : []), ...hopsFarToNear, rx]; + if (!forceIncludeSource && hasCoords(src) && pathNodes.length >= 2 && pathNodes[0]?.node_id === src.node_id) { + pathNodes.shift(); + } + if (pathNodes.length < 2) return null; + return { path: pathNodes.map((n) => [n.lat!, n.lon!]), nodeIds: pathNodes.map((n) => n.node_id) }; +} + +function enumeratePrefixContinuations( + startNodeId: string, + remainingPrefixes: string[], + endNodeId: string, + nodesById: Map, + options?: { dropStartIfNodeId?: string; maxRenderPaths?: number; maxSearchStates?: number; blockedNodeIds?: string[] }, +): { paths: [number, number][][]; totalCount: number; truncated: boolean; longestPrefixDepth: number } { + const maxRenderPaths = Math.max(1, options?.maxRenderPaths ?? MAX_RENDER_PERMUTATIONS); + const maxSearchStates = Math.max(1000, options?.maxSearchStates ?? MAX_PERMUTATION_STATES); + const candidates = Array.from(nodesById.values()).filter( + (n) => hasCoords(n) && (n.role === null || n.role === 2), + ); + const byPrefix = new Map(); + for (const n of candidates) { + const p = n.node_id.slice(0, 2).toUpperCase(); + const arr = byPrefix.get(p); + if (arr) arr.push(n); + else byPrefix.set(p, [n]); + } + + const start = nodesById.get(startNodeId); + const end = nodesById.get(endNodeId); + if (!start || !end || !hasCoords(start) || !hasCoords(end)) { + return { paths: [], totalCount: 0, truncated: false, longestPrefixDepth: 0 }; + } + const blocked = new Set(options?.blockedNodeIds ?? []); + if (blocked.has(start.node_id) || blocked.has(end.node_id)) { + return { paths: [], totalCount: 0, truncated: false, longestPrefixDepth: 0 }; + } + + const discovered: string[][] = []; + const partialDiscovered: string[][] = []; + let totalCount = 0; + let partialCount = 0; + let states = 0; + let truncated = false; + let longestPrefixDepth = 0; + let bestPartialDepth = 0; + + const recordPartial = (depth: number, path: string[]) => { + if (path.length < 2 || depth <= 0) return; + if (depth > bestPartialDepth) { + bestPartialDepth = depth; + partialDiscovered.length = 0; + partialCount = 0; + } + if (depth === bestPartialDepth) { + partialCount += 1; + if (partialDiscovered.length < maxRenderPaths) partialDiscovered.push([...path]); + } + }; + + const dfs = (idx: number, current: MeshNode, path: string[], visited: Set) => { + if (idx > longestPrefixDepth) longestPrefixDepth = idx; + if (states++ >= maxSearchStates) { + truncated = true; + return; + } + if (idx >= remainingPrefixes.length) { + if (current.node_id !== end.node_id) { + if (visited.has(end.node_id)) { + recordPartial(idx, path); + return; + } + path.push(end.node_id); + } + totalCount += 1; + if (discovered.length < maxRenderPaths) discovered.push([...path]); + if (current.node_id !== end.node_id) path.pop(); + return; + } + + const prefix = remainingPrefixes[idx]!.slice(0, 2).toUpperCase(); + const nodesForPrefix = (byPrefix.get(prefix) ?? []) + .filter((n) => !visited.has(n.node_id) && !blocked.has(n.node_id) && n.node_id !== end.node_id && distKm(n, current) <= MAX_PERMUTATION_HOP_KM) + .sort((a, b) => distKm(a, current) - distKm(b, current)); + if (nodesForPrefix.length < 1) { + recordPartial(idx, path); + return; + } + for (const next of nodesForPrefix) { + visited.add(next.node_id); + path.push(next.node_id); + dfs(idx + 1, next, path, visited); + path.pop(); + visited.delete(next.node_id); + if (states >= maxSearchStates) { + truncated = true; + return; + } + } + }; + + const visited = new Set([start.node_id]); + dfs(0, start, [start.node_id], visited); + + const renderSource = totalCount > 0 ? discovered : partialDiscovered; + const paths = renderSource + .map((ids) => { + const renderIds = (options?.dropStartIfNodeId && ids[0] === options.dropStartIfNodeId) ? ids.slice(1) : ids; + const nodes = renderIds.map((id) => nodesById.get(id)).filter((n): n is MeshNode => Boolean(n && hasCoords(n))); + if (new Set(nodes.map((n) => n.node_id)).size !== nodes.length) return null; + if (nodes.length < 2) return null; + return nodes.map((n) => [n.lat!, n.lon!]) as [number, number][]; + }) + .filter((p): p is [number, number][] => Array.isArray(p)); + + return { + paths, + totalCount: totalCount > 0 ? totalCount : partialCount, + truncated, + longestPrefixDepth, + }; +} + +function reverseResolvedPath( + result: { path: [number, number][]; confidence: number; segmentConfidence: number[]; nodeIds: string[] } | null, +): { path: [number, number][]; confidence: number; segmentConfidence: number[]; nodeIds: string[] } | null { + if (!result) return null; + return { + path: [...result.path].reverse(), + confidence: result.confidence, + segmentConfidence: [...result.segmentConfidence].reverse(), + nodeIds: [...result.nodeIds].reverse(), + }; +} + +function resolveBetaPath( + pathHashes: string[], + src: MeshNode | null, + rx: MeshNode, + context: BetaResolveContext, + options?: { forceIncludeSource?: boolean; disableSourcePrepend?: boolean; blockedNodeIds?: string[] }, +): { path: [number, number][]; confidence: number; segmentConfidence: number[]; nodeIds: string[] } | null { + if (!hasCoords(rx) || pathHashes.length === 0) return null; + if (pathHashes.length >= MAX_BETA_HOPS) return null; + const rxLat = rx.lat!; + const rxLon = rx.lon!; + + type HopResult = { node: MeshNode; conf: number }; + const blockedNodeIds = new Set(options?.blockedNodeIds ?? []); + const candidatesPool = Array.from(context.nodesById.values()).filter( + (n) => hasCoords(n) && (n.role === null || n.role === 2) && !blockedNodeIds.has(n.node_id), + ); + + const prefixCounts = new Map(); + const prefixBuckets = new Map(); + for (const n of candidatesPool) { + const p = n.node_id.slice(0, 2).toUpperCase(); + prefixCounts.set(p, (prefixCounts.get(p) ?? 0) + 1); + const existing = prefixBuckets.get(p); + if (existing) existing.push(n); + else prefixBuckets.set(p, [n]); + } + + const totalDist = hasCoords(src) ? distKm(src, rx) : 0; + const corridorMaxKm = Math.max(10, Math.min(80, totalDist * 0.35)); + const receiverRegion = rx.iata ?? 'unknown'; + const bucketHours = context.learningModel.bucketHours ?? 6; + const hourBucket = currentHourBucket(bucketHours); + + function prefixPrior(prefix: string, prevPrefix: string, nodeId: string): number { + const exactKey = `${receiverRegion}|${prefix}|${prevPrefix}|${nodeId}`; + const regionOnlyKey = `unknown|${prefix}|${prevPrefix}|${nodeId}`; + const noPrevKey = `${receiverRegion}|${prefix}||${nodeId}`; + const noPrevFallbackKey = `unknown|${prefix}||${nodeId}`; + return context.learningModel.prefixProbabilities.get(exactKey) + ?? context.learningModel.prefixProbabilities.get(regionOnlyKey) + ?? context.learningModel.prefixProbabilities.get(noPrevKey) + ?? context.learningModel.prefixProbabilities.get(noPrevFallbackKey) + ?? 0; + } + + function transitionPrior(fromId: string, toId: string): number { + const key = `${receiverRegion}|${fromId}|${toId}`; + const fallback = `unknown|${fromId}|${toId}`; + return context.learningModel.transitionProbabilities.get(key) + ?? context.learningModel.transitionProbabilities.get(fallback) + ?? 0; + } + + function edgePrior(fromId: string, toId: string): number { + const exact = edgeKey(receiverRegion, hourBucket, fromId, toId); + const regionFallback = edgeKey(receiverRegion, -1, fromId, toId); + const unknownExact = edgeKey('unknown', hourBucket, fromId, toId); + const unknownFallback = edgeKey('unknown', -1, fromId, toId); + return context.learningModel.edgeScores.get(exact) + ?? context.learningModel.edgeScores.get(regionFallback) + ?? context.learningModel.edgeScores.get(unknownExact) + ?? context.learningModel.edgeScores.get(unknownFallback) + ?? 0; + } + + function motifPrior(nodeIds: string[]): number { + if (nodeIds.length !== 2 && nodeIds.length !== 3) return 0; + const exact = motifKey(receiverRegion, hourBucket, nodeIds); + const regionFallback = motifKey(receiverRegion, -1, nodeIds); + const unknownExact = motifKey('unknown', hourBucket, nodeIds); + const unknownFallback = motifKey('unknown', -1, nodeIds); + return context.learningModel.motifProbabilities.get(exact) + ?? context.learningModel.motifProbabilities.get(regionFallback) + ?? context.learningModel.motifProbabilities.get(unknownExact) + ?? context.learningModel.motifProbabilities.get(unknownFallback) + ?? 0; + } + + function distanceElevationPrior(a: MeshNode, b: MeshNode): number { + const d = distKm(a, b); + const distScore = Math.exp(-d / 22); + const elevA = a.elevation_m ?? 0; + const elevB = b.elevation_m ?? 0; + const elevScore = Math.min(1, Math.max(0, (Math.min(elevA, elevB) + 60) / 320)); + return 0.65 * distScore + 0.35 * elevScore; + } + + const clashAdjacency = buildClashAdjacency(candidatesPool, context.linkPairs, context.linkMetrics); + const hopCache = new Map(); + + function twoHopDistance(aId: string, bId: string): 1 | 2 | null { + if (aId === bId) return null; + const key = aId < bId ? `${aId}:${bId}` : `${bId}:${aId}`; + const cached = hopCache.get(key); + if (cached !== undefined) return cached; + const neighbors = clashAdjacency.get(aId); + if (!neighbors || neighbors.size === 0) { + hopCache.set(key, null); + return null; + } + if (neighbors.has(bId)) { + hopCache.set(key, 1); + return 1; + } + for (const mid of neighbors) { + if (clashAdjacency.get(mid)?.has(bId)) { + hopCache.set(key, 2); + return 2; + } + } + hopCache.set(key, null); + return null; + } + + function localPrefixAmbiguityPenalty(candidate: MeshNode, prevNode: MeshNode): number { + const prefix = candidate.node_id.slice(0, 2).toUpperCase(); + const peers = prefixBuckets.get(prefix) ?? []; + if (peers.length <= 1) return 0; + + const inRangeKm = Math.max(PREFIX_AMBIGUITY_FLOOR_KM, nodeRange(candidate.node_id, context.coverageByNode), nodeRange(prevNode.node_id, context.coverageByNode)); + const candidateDist = distKm(candidate, prevNode); + let localRaw = 0; + let hopRaw = 0; + for (const peer of peers) { + if (peer.node_id === candidate.node_id) continue; + const peerDist = distKm(peer, prevNode); + if (peerDist > inRangeKm) continue; + const distanceSimilarity = clamp(1 - Math.abs(peerDist - candidateDist) / inRangeKm, 0, 1); + const proximity = clamp(1 - peerDist / inRangeKm, 0, 1); + localRaw += distanceSimilarity * proximity; + const hopDistance = twoHopDistance(candidate.node_id, peer.node_id); + if (hopDistance === 1) hopRaw += 1.0 * distanceSimilarity; + else if (hopDistance === 2) hopRaw += 0.5 * distanceSimilarity; + } + const localPenalty = clamp(localRaw * 0.08, 0, 0.14); + const hopPenalty = clamp(hopRaw * 0.07, 0, 0.16); + return clamp(localPenalty + hopPenalty, 0, 0.30); + } + + function clashPressure(candidate: MeshNode): number { + const prefix = candidate.node_id.slice(0, 2).toUpperCase(); + const peers = prefixBuckets.get(prefix) ?? []; + if (peers.length <= 1) return 0; + let raw = 0; + for (const peer of peers) { + if (peer.node_id === candidate.node_id) continue; + const hops = twoHopDistance(candidate.node_id, peer.node_id); + if (hops === 1) raw += 1; + else if (hops === 2) raw += 0.5; + } + return clamp(raw / 3, 0, 1); + } + + function inCorridor(candidate: MeshNode, prevNode: MeshNode): boolean { + if (!hasCoords(src)) return true; + const bx = src.lon! - rxLon; + const by = src.lat! - rxLat; + const segLen2 = bx * bx + by * by; + if (segLen2 < 1e-9) return true; + const px = candidate.lon! - rxLon; + const py = candidate.lat! - rxLat; + const t = (px * bx + py * by) / segLen2; + const pressure = clashPressure(candidate); + const tPadding = 0.15 + (1 - pressure) * 0.15; + if (t < -tPadding || t > 1 + tPadding) return false; + + const projx = rxLon + t * bx; + const projy = rxLat + t * by; + const midLat = ((candidate.lat! + projy) / 2) * (Math.PI / 180); + const kmPerLon = 111 * Math.cos(midLat); + const dxKm = (candidate.lon! - projx) * kmPerLon; + const dyKm = (candidate.lat! - projy) * 111; + const crossTrackKm = Math.hypot(dxKm, dyKm); + const corridorAllowance = corridorMaxKm * (1 + (1 - pressure) * 0.35); + if (crossTrackKm > corridorAllowance) return false; + + return distKm(candidate, src) <= distKm(prevNode, src) + 8; + } + + function getCandidates(prefix: string, prevPrefix: string, prevNode: MeshNode, nextTowardRx: string | null): Array<{ node: MeshNode; conf: number }> { + const all = candidatesPool.filter((n) => n.node_id.toUpperCase().startsWith(prefix)); + if (all.length === 0) return []; + + function align(c: MeshNode): number { + if (!hasCoords(src)) return 0; + const dLat = src.lat! - prevNode.lat!; + const dLon = src.lon! - prevNode.lon!; + const cLat = c.lat! - prevNode.lat!; + const cLon = c.lon! - prevNode.lon!; + const dot = dLat * cLat + dLon * cLon; + const mag = Math.hypot(dLat, dLon) * Math.hypot(cLat, cLon); + return mag > 0 ? dot / mag : 0; + } + + function sortScore(c: MeshNode): number { + const corridorBonus = inCorridor(c, prevNode) ? 0.25 : -0.6; + return align(c) - distKm(c, prevNode) / 50 + corridorBonus; + } + + const usedIds = new Set(); + + const confirmed = all + .filter((c) => { + const key = linkKey(c.node_id, prevNode.node_id); + if (!context.linkPairs.has(key)) return false; + const meta = context.linkMetrics.get(key); + if (!meta || meta.count_a_to_b == null || meta.count_b_to_a == null) return true; + const dir = directionalSupport(meta, c.node_id, prevNode.node_id); + const observed = meta.observed_count ?? 0; + if (observed < 20) return true; + return dir >= minimumDirectionalSupport(observed) * 0.6; + }) + .sort((a, b) => sortScore(b) - sortScore(a)) + .slice(0, 16) + .map((c) => { + usedIds.add(c.node_id); + const meta = context.linkMetrics.get(linkKey(c.node_id, prevNode.node_id)); + const priorBoost = prefixPrior(prefix, prevPrefix, c.node_id) * 0.2; + const transitionBoost = transitionPrior(c.node_id, prevNode.node_id) * 0.24; + const motifBoost = motifPrior([c.node_id, prevNode.node_id]) * 0.2 + + (nextTowardRx ? motifPrior([c.node_id, prevNode.node_id, nextTowardRx]) * 0.25 : 0); + const edgeBoost = edgePrior(c.node_id, prevNode.node_id) * 0.3; + const ambiguityPenalty = localPrefixAmbiguityPenalty(c, prevNode); + const confirmedFloor = strongConfirmedFloor(meta); + const baseConf = confirmedLinkConfidence(meta, c.node_id, prevNode.node_id, { + prefix: priorBoost, + transition: transitionBoost, + motif: motifBoost, + edge: edgeBoost, + ambiguity: -ambiguityPenalty, + }); + return { node: c, conf: Math.max(baseConf, confirmedFloor) }; + }); + + const reachable = all + .filter((c) => { + if (usedIds.has(c.node_id)) return false; + if (!inCorridor(c, prevNode)) return false; + const meta = context.linkMetrics.get(linkKey(c.node_id, prevNode.node_id)); + const reachOk = canReach(c, prevNode, context.coverageByNode); + const losOk = hasLoS(c, prevNode); + return (reachOk && losOk) || (reachOk && isWeakOrBetter(meta)) || (losOk && isWeakOrBetter(meta)); + }) + .sort((a, b) => sortScore(b) - sortScore(a)) + .slice(0, 10) + .map((c) => { + usedIds.add(c.node_id); + const distancePenalty = Math.min(0.12, distKm(c, prevNode) / 120); + const prior = distanceElevationPrior(c, prevNode); + const prefixBoost = prefixPrior(prefix, prevPrefix, c.node_id) * 0.22; + const transitionBoost = transitionPrior(c.node_id, prevNode.node_id) * 0.25; + const motifBoost = motifPrior([c.node_id, prevNode.node_id]) * 0.18 + + (nextTowardRx ? motifPrior([c.node_id, prevNode.node_id, nextTowardRx]) * 0.2 : 0); + const edgeBoost = edgePrior(c.node_id, prevNode.node_id) * 0.28; + const ambiguityPenalty = localPrefixAmbiguityPenalty(c, prevNode); + return { + node: c, + conf: Math.max(0.08, 0.2 + prior * 0.34 + prefixBoost + transitionBoost + motifBoost + edgeBoost - distancePenalty - ambiguityPenalty - (all.length - 1) * 0.01), + }; + }); + + const fallback = all + .filter((c) => { + if (usedIds.has(c.node_id)) return false; + if (!inCorridor(c, prevNode)) return false; + if (distKm(c, prevNode) >= MAX_HOP_KM * 0.5) return false; + const meta = context.linkMetrics.get(linkKey(c.node_id, prevNode.node_id)); + return hasLoS(c, prevNode) || isWeakOrBetter(meta); + }) + .sort((a, b) => sortScore(b) - sortScore(a)) + .slice(0, 6) + .map((c) => { + const prior = distanceElevationPrior(c, prevNode); + const prefixBoost = prefixPrior(prefix, prevPrefix, c.node_id) * 0.16; + const transitionBoost = transitionPrior(c.node_id, prevNode.node_id) * 0.16; + const motifBoost = motifPrior([c.node_id, prevNode.node_id]) * 0.12; + const edgeBoost = edgePrior(c.node_id, prevNode.node_id) * 0.18; + const ambiguityPenalty = localPrefixAmbiguityPenalty(c, prevNode); + return { + node: c, + conf: Math.max(0.03, 0.04 + prior * 0.2 + prefixBoost + transitionBoost + motifBoost + edgeBoost - ambiguityPenalty) / Math.max(1, all.length), + }; + }); + + return [...confirmed, ...reachable, ...fallback]; + } + + const ambiguity = pathHashes.reduce((sum, h) => sum + (prefixCounts.get(h.slice(0, 2).toUpperCase()) ?? 0), 0); + let budget = Math.max(3_000, Math.min(308_232, 1_000 + pathHashes.length * 3_000 + ambiguity * 800)); + + function solve(hopIdx: number, prevNode: MeshNode, nextTowardRx: string | null, visited: Set): HopResult[] | null { + if (hopIdx < 0) return []; + if (--budget <= 0) return null; + + const prefix = pathHashes[hopIdx]!.slice(0, 2).toUpperCase(); + const prevPrefix = hopIdx > 0 ? pathHashes[hopIdx - 1]!.slice(0, 2).toUpperCase() : ''; + const options = getCandidates(prefix, prevPrefix, prevNode, nextTowardRx).filter((o) => !visited.has(o.node.node_id)); + + for (const opt of options) { + const nextVisited = new Set(visited); + nextVisited.add(opt.node.node_id); + const rest = solve(hopIdx - 1, opt.node, prevNode.node_id, nextVisited); + if (rest !== null) return [opt, ...rest]; + } + return null; + } + + const raw = solve(pathHashes.length - 1, rx, null, new Set([rx.node_id])); + if (!raw) return null; + + const hops = [...raw].reverse(); + if (hops.length === 0) return null; + + const totalHops = raw.length; + const meanHopConfidence = hops.reduce((sum, h) => sum + h.conf, 0) / hops.length; + const resolvedRatio = hops.length / totalHops; + const rawConfidence = meanHopConfidence * resolvedRatio; + const calibratedConfidence = rawConfidence * context.learningModel.confidenceScale + context.learningModel.confidenceBias; + const confidence = clamp(calibratedConfidence, 0, 1); + + const srcPrefix = hasCoords(src) ? src.node_id.slice(0, 2).toUpperCase() : null; + const firstHopPrefix = pathHashes[0]?.slice(0, 2).toUpperCase() ?? null; + const prependSource = options?.disableSourcePrepend + ? false + : Boolean(options?.forceIncludeSource + ? hasCoords(src) + : (hasCoords(src) && srcPrefix && srcPrefix !== firstHopPrefix)); + + const pathNodes: MeshNode[] = [ + ...(prependSource && hasCoords(src) ? [src] : []), + ...hops.map((h) => h.node), + rx, + ]; + + if (pathNodes.length < 2) return null; + + const segmentConfidence: number[] = []; + const hasSource = prependSource; + for (let i = 0; i < pathNodes.length - 1; i++) { + if (hasSource && i === 0) { + segmentConfidence.push(hops[0]?.conf ?? confidence); + continue; + } + const hopIdx = hasSource ? i - 1 : i; + segmentConfidence.push(hops[hopIdx]?.conf ?? hops[hops.length - 1]?.conf ?? confidence); + } + + return { + path: pathNodes.map((n) => [n.lat!, n.lon!]), + confidence, + segmentConfidence, + nodeIds: pathNodes.map((n) => n.node_id), + }; +} + +async function buildLearningModel(network: string): 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 + FROM path_prefix_priors + WHERE network = $1 + ORDER BY count DESC + LIMIT $2`, + [network, MODEL_LIMIT], + ), + query<{ + from_node_id: string; + to_node_id: string; + receiver_region: string; + probability: number; + }>( + `SELECT from_node_id, to_node_id, receiver_region, probability + FROM path_transition_priors + WHERE network = $1 + ORDER BY count DESC + LIMIT $2`, + [network, MODEL_LIMIT], + ), + query<{ + from_node_id: string; + to_node_id: string; + receiver_region: string; + hour_bucket: number; + score: number; + }>( + `SELECT from_node_id, to_node_id, receiver_region, hour_bucket, score + FROM path_edge_priors + WHERE network = $1 + ORDER BY score DESC, observed_count DESC + LIMIT $2`, + [network, MODEL_LIMIT], + ), + query<{ + receiver_region: string; + hour_bucket: number; + motif_len: number; + node_ids: string; + probability: number; + }>( + `SELECT receiver_region, hour_bucket, motif_len, node_ids, probability + FROM path_motif_priors + WHERE network = $1 + ORDER BY count DESC + LIMIT $2`, + [network, MODEL_LIMIT], + ), + query<{ + confidence_scale: number; + confidence_bias: number; + }>( + `SELECT confidence_scale, confidence_bias + FROM path_model_calibration + WHERE network = $1`, + [network], + ), + ]); + + const prefixProbabilities = new Map(); + for (const row of prefixRows.rows) { + const key = `${row.receiver_region}|${row.prefix}|${row.prev_prefix ?? ''}|${row.node_id}`; + prefixProbabilities.set(key, Number(row.probability)); + } + + const transitionProbabilities = new Map(); + for (const row of transitionRows.rows) { + const key = `${row.receiver_region}|${row.from_node_id}|${row.to_node_id}`; + transitionProbabilities.set(key, Number(row.probability)); + } + + const edgeScores = new Map(); + const edgeTotals = new Map(); + for (const row of edgeRows.rows) { + const key = `${row.receiver_region}|${Number(row.hour_bucket)}|${row.from_node_id}|${row.to_node_id}`; + const score = Number(row.score); + edgeScores.set(key, score); + const aggregateKey = `${row.receiver_region}|${row.from_node_id}|${row.to_node_id}`; + const agg = edgeTotals.get(aggregateKey) ?? { sum: 0, count: 0 }; + agg.sum += score; + agg.count += 1; + edgeTotals.set(aggregateKey, agg); + } + for (const [aggregateKey, agg] of edgeTotals) { + if (agg.count <= 0) continue; + const [region, from, to] = aggregateKey.split('|'); + if (!region || !from || !to) continue; + edgeScores.set(`${region}|-1|${from}|${to}`, agg.sum / agg.count); + } + + const motifProbabilities = new Map(); + const motifTotals = new Map(); + for (const row of motifRows.rows) { + const key = `${row.receiver_region}|${Number(row.hour_bucket)}|${Number(row.motif_len)}|${row.node_ids}`; + const probability = Number(row.probability); + motifProbabilities.set(key, probability); + + const aggregateKey = `${row.receiver_region}|${Number(row.motif_len)}|${row.node_ids}`; + const agg = motifTotals.get(aggregateKey) ?? { sum: 0, count: 0 }; + agg.sum += probability; + agg.count += 1; + motifTotals.set(aggregateKey, agg); + } + for (const [aggregateKey, agg] of motifTotals) { + if (agg.count <= 0) continue; + const [region, motifLen, nodeIds] = aggregateKey.split('|'); + if (!region || !motifLen || !nodeIds) continue; + motifProbabilities.set(`${region}|-1|${motifLen}|${nodeIds}`, agg.sum / agg.count); + } + + const calibration = calibrationRows.rows[0]; + return { + prefixProbabilities, + transitionProbabilities, + edgeScores, + motifProbabilities, + confidenceScale: Number(calibration?.confidence_scale ?? 1), + confidenceBias: Number(calibration?.confidence_bias ?? 0), + bucketHours: 6, + }; +} + +async function loadContext(network: string): Promise { + const now = Date.now(); + const cached = contextCache.get(network); + if (cached && now - cached.loadedAt < CONTEXT_TTL_MS) return cached; + + const [nodeRows, coverageRows, linkRows, learningModel] = await Promise.all([ + query( + `SELECT node_id, name, lat, lon, iata, role, elevation_m + FROM nodes + WHERE ($1 = 'all' OR network = $1)`, + [network], + ), + query( + `SELECT nc.node_id, nc.radius_m + FROM node_coverage nc + JOIN nodes n ON n.node_id = nc.node_id + WHERE ($1 = 'all' OR n.network = $1)`, + [network], + ), + query<{ + node_a_id: string; + node_b_id: string; + observed_count: number; + itm_path_loss_db: number | null; + count_a_to_b: number | null; + count_b_to_a: number | null; + }>( + `SELECT nl.node_a_id, nl.node_b_id, nl.observed_count, nl.itm_path_loss_db, nl.count_a_to_b, nl.count_b_to_a + FROM node_links nl + JOIN nodes a ON a.node_id = nl.node_a_id + JOIN nodes b ON b.node_id = nl.node_b_id + WHERE (nl.itm_viable = true OR nl.force_viable = true) + AND nl.observed_count >= $2 + AND ($1 = 'all' OR (a.network = $1 AND b.network = $1))`, + [network, MIN_LINK_OBSERVATIONS], + ), + buildLearningModel(network), + ]); + + const nodesById = new Map(); + for (const row of nodeRows.rows) nodesById.set(row.node_id, row); + + const coverageByNode = new Map(); + for (const row of coverageRows.rows) { + if (row.radius_m != null) coverageByNode.set(row.node_id, Number(row.radius_m)); + } + + const linkPairs = new Set(); + const linkMetrics = new Map(); + for (const row of linkRows.rows) { + const key = linkKey(row.node_a_id, row.node_b_id); + linkPairs.add(key); + linkMetrics.set(key, { + observed_count: Number(row.observed_count ?? 0), + itm_path_loss_db: row.itm_path_loss_db == null ? null : Number(row.itm_path_loss_db), + count_a_to_b: row.count_a_to_b == null ? null : Number(row.count_a_to_b), + count_b_to_a: row.count_b_to_a == null ? null : Number(row.count_b_to_a), + }); + } + + const context: BetaResolveContext = { + loadedAt: now, + nodesById, + coverageByNode, + linkPairs, + linkMetrics, + learningModel, + }; + contextCache.set(network, context); + return context; +} + +export type BetaResolvedPayload = { + ok: boolean; + packetHash: string; + mode: 'resolved' | 'fallback' | 'none'; + confidence: number | null; + permutationCount: number; + remainingHops: number | null; + purplePath: [number, number][] | null; + extraPurplePaths: [number, number][][]; + redPath: [number, number][] | null; + redSegments: Array<[[number, number], [number, number]]>; + completionPaths: [number, number][][]; + threshold: number; + debug: { + hopsRequested: number; + hopsUsed: number; + rxNodeId: string | null; + srcNodeId: string | null; + computedAt: string; + }; +}; + +export async function resolveBetaPathForPacketHash(packetHash: string, network: string): Promise { + const packetResult = await query( + `SELECT packet_hash, rx_node_id, src_node_id, packet_type, hop_count, path_hashes + FROM packets + WHERE packet_hash = $1 + AND ($2 = 'all' OR network = $2) + ORDER BY time DESC + LIMIT 1`, + [packetHash, network], + ); + + const packet = packetResult.rows[0]; + if (!packet) return null; + const context = await loadContext(network); + const logPrefix = `[path-beta] hash=${packetHash} network=${network}`; + + const rx = packet.rx_node_id ? context.nodesById.get(packet.rx_node_id) : undefined; + if (!hasCoords(rx)) { + console.log(`${logPrefix} mode=none reason=missing-rx-coords`); + return { + ok: true, + packetHash, + mode: 'none', + confidence: null, + permutationCount: 0, + remainingHops: null, + purplePath: null, + extraPurplePaths: [], + redPath: null, + redSegments: [], + completionPaths: [], + threshold: BETA_PURPLE_THRESHOLD, + debug: { + hopsRequested: Number((packet.path_hashes ?? []).length), + hopsUsed: 0, + rxNodeId: packet.rx_node_id, + srcNodeId: packet.src_node_id, + computedAt: new Date().toISOString(), + }, + }; + } + + const src = packet.src_node_id ? (context.nodesById.get(packet.src_node_id) ?? null) : null; + const hashes = packet.path_hashes ?? []; + const hops = packet.hop_count != null ? hashes.slice(0, Math.max(0, packet.hop_count)) : hashes; + const forceIncludeSource = packet.packet_type === 4; + + if (hops.length < 1) { + console.log(`${logPrefix} mode=none reason=no-hops rx=${packet.rx_node_id ?? 'unknown'} src=${packet.src_node_id ?? 'unknown'}`); + return { + ok: true, + packetHash, + mode: 'none', + confidence: null, + permutationCount: 0, + remainingHops: 0, + purplePath: null, + extraPurplePaths: [], + redPath: null, + redSegments: [], + completionPaths: [], + threshold: BETA_PURPLE_THRESHOLD, + debug: { + hopsRequested: hashes.length, + hopsUsed: 0, + rxNodeId: packet.rx_node_id, + srcNodeId: packet.src_node_id, + computedAt: new Date().toISOString(), + }, + }; + } + + let result = resolveBetaPath(hops, hasCoords(src) ? src : null, rx, context, { forceIncludeSource }); + let solvedHopCount = hops.length; + let solverMode: 'full' | 'suffix-partial' = 'full'; + if (!result && hops.length > 1) { + // If full solve fails, progressively solve shorter RX-side suffixes so we can + // still render a confident purple segment near the receiver. + for (let suffixLen = hops.length - 1; suffixLen >= 1; suffixLen--) { + const suffix = hops.slice(hops.length - suffixLen); + const partial = resolveBetaPath(suffix, hasCoords(src) ? src : null, rx, context, { + forceIncludeSource: false, + disableSourcePrepend: true, + blockedNodeIds: hasCoords(src) ? [src.node_id] : [], + }); + if (!partial) continue; + result = partial; + solvedHopCount = suffixLen; + solverMode = 'suffix-partial'; + break; + } + } + + if (result) { + const split = splitResolvedAndAlternatives(result, BETA_PURPLE_THRESHOLD, context.linkMetrics); + let purplePath = split.purplePath; + const extraPurplePaths: [number, number][][] = []; + const unresolvedBySolver = Math.max(0, hops.length - solvedHopCount); + let redPath = attachSrcToPath(split.redPath, purplePath, hasCoords(src) ? src : null, forceIncludeSource); + if (unresolvedBySolver > 0) { + const fallbackForUnresolved = buildFallbackPrefixPath(hops, hasCoords(src) ? src : null, rx, context.nodesById, forceIncludeSource); + redPath = trimRedToPurpleStitch( + fallbackForUnresolved?.path ?? redPath, + purplePath, + ); + } + const unresolvedFrontEdges = (split.remainingHops ?? 0) + unresolvedBySolver; + if (packet.packet_type === 4 && hasCoords(src) && unresolvedFrontEdges > 0) { + let sourcePartial: { path: [number, number][]; confidence: number; segmentConfidence: number[]; nodeIds: string[] } | null = null; + const maxSourcePrefixLen = Math.min(Math.max(1, unresolvedFrontEdges), Math.max(1, hops.length - 1)); + for (let prefixLen = maxSourcePrefixLen; prefixLen >= 1; prefixLen--) { + const prefix = hops.slice(0, prefixLen); + const candidate = reverseResolvedPath(resolveBetaPath( + [...prefix].reverse(), + rx, + src, + context, + { + forceIncludeSource: false, + disableSourcePrepend: true, + blockedNodeIds: [ + rx.node_id, + ...(purplePath ? result.nodeIds.slice(-Math.max(1, (purplePath.length - 1))) : []), + ], + }, + )); + if (!candidate || candidate.path.length < 2) continue; + sourcePartial = candidate; + break; + } + if (sourcePartial) { + const sourceSplit = splitResolvedFromSource(sourcePartial, BETA_PURPLE_THRESHOLD, context.linkMetrics); + const sourcePurplePath = sourceSplit.purplePath; + if (sourcePurplePath && sourcePurplePath.length >= 2) { + extraPurplePaths.push(sourcePurplePath); + redPath = trimPathBetweenStitches( + redPath, + sourcePurplePath[sourcePurplePath.length - 1] ?? null, + purplePath, + ); + } + } + } + const purpleEdges = Math.max(0, (purplePath?.length ?? 0) - 1); + const redEdges = Math.max(0, (redPath?.length ?? 0) - 1); + const colorMode = purpleEdges > 0 && redEdges > 0 + ? 'mixed' + : purpleEdges > 0 + ? 'purple-only' + : redEdges > 0 + ? 'full-red' + : 'none'; + const reason = colorMode === 'purple-only' + ? (solverMode === 'suffix-partial' ? 'partial-suffix-all-segments-above-threshold' : 'all-segments-above-threshold') + : colorMode === 'mixed' + ? (solverMode === 'suffix-partial' ? 'partial-suffix-plus-red-continuation' : 'split-at-low-confidence-segment') + : colorMode === 'full-red' + ? (solverMode === 'suffix-partial' ? 'partial-suffix-but-no-purple-after-threshold' : 'first-segment-below-threshold') + : 'no-renderable-segments'; + console.log( + `${logPrefix} mode=resolved color=${colorMode} reason=${reason} conf=${result.confidence.toFixed(3)} threshold=${BETA_PURPLE_THRESHOLD.toFixed(2)} ` + + `hops=${hops.length} solvedHops=${solvedHopCount} unresolvedBySolver=${unresolvedBySolver} ` + + `purpleEdges=${purpleEdges} redEdges=${redEdges} remaining=${(split.remainingHops ?? 0) + unresolvedBySolver} ` + + `rx=${packet.rx_node_id ?? 'unknown'} src=${packet.src_node_id ?? 'unknown'}`, + ); + return { + ok: true, + packetHash, + mode: 'resolved', + confidence: result.confidence, + permutationCount: 0, + remainingHops: (split.remainingHops ?? 0) + unresolvedBySolver, + purplePath, + extraPurplePaths, + redPath, + redSegments: segmentizePath(redPath), + completionPaths: [], + threshold: BETA_PURPLE_THRESHOLD, + debug: { + hopsRequested: hashes.length, + hopsUsed: hops.length, + rxNodeId: packet.rx_node_id, + srcNodeId: packet.src_node_id, + computedAt: new Date().toISOString(), + }, + }; + } + + const fallback = buildFallbackPrefixPath(hops, hasCoords(src) ? src : null, rx, context.nodesById, forceIncludeSource); + if (fallback) { + const redEdges = Math.max(0, fallback.path.length - 1); + let completionPaths: [number, number][][] = []; + let permutationCount = 0; + if (hasCoords(src)) { + const permutations = enumeratePrefixContinuations( + src.node_id, + hops, + rx.node_id, + context.nodesById, + { + dropStartIfNodeId: forceIncludeSource ? undefined : src.node_id, + maxRenderPaths: MAX_RENDER_PERMUTATIONS, + maxSearchStates: MAX_PERMUTATION_STATES, + }, + ); + completionPaths = permutations.paths; + permutationCount = permutations.totalCount; + } + console.log( + `${logPrefix} mode=fallback color=full-red reason=beta-solver-no-solution-prefix-fallback conf=null hops=${hops.length} purpleEdges=0 redEdges=${redEdges} ` + + `permutations=${permutationCount} remaining=unknown rx=${packet.rx_node_id ?? 'unknown'} src=${packet.src_node_id ?? 'unknown'}`, + ); + return { + ok: true, + packetHash, + mode: 'fallback', + confidence: null, + permutationCount, + remainingHops: null, + purplePath: null, + extraPurplePaths: [], + redPath: fallback.path, + redSegments: segmentizePath(fallback.path), + completionPaths, + threshold: BETA_PURPLE_THRESHOLD, + debug: { + hopsRequested: hashes.length, + hopsUsed: hops.length, + rxNodeId: packet.rx_node_id, + srcNodeId: packet.src_node_id, + computedAt: new Date().toISOString(), + }, + }; + } + + console.log( + `${logPrefix} mode=none reason=unresolved hops=${hops.length} rx=${packet.rx_node_id ?? 'unknown'} src=${packet.src_node_id ?? 'unknown'}`, + ); + return { + ok: true, + packetHash, + mode: 'none', + confidence: null, + permutationCount: 0, + remainingHops: null, + purplePath: null, + extraPurplePaths: [], + redPath: null, + redSegments: [], + completionPaths: [], + threshold: BETA_PURPLE_THRESHOLD, + debug: { + hopsRequested: hashes.length, + hopsUsed: hops.length, + rxNodeId: packet.rx_node_id, + srcNodeId: packet.src_node_id, + computedAt: new Date().toISOString(), + }, + }; +} diff --git a/backend/src/workers/path-sim.ts b/backend/src/workers/path-sim.ts new file mode 100644 index 0000000..3f1128d --- /dev/null +++ b/backend/src/workers/path-sim.ts @@ -0,0 +1,1001 @@ +import 'node:process'; +import { initDb, query } from '../db/index.js'; + +const RUN_INTERVAL_MS = Number(process.env['PATH_SIM_INTERVAL_MS'] ?? 6 * 60 * 60 * 1000); +const PERM_BUCKET_CAP = Number(process.env['PATH_SIM_PERM_BUCKET_CAP'] ?? 50); +const NETWORK = (process.env['PATH_SIM_NETWORK'] ?? 'all').trim().toLowerCase(); +const LOG_EVERY = Math.max(1, Number(process.env['PATH_SIM_LOG_EVERY'] ?? 1)); +const POPULATION_SIZE = Math.max(2, Number(process.env['PATH_SIM_POPULATION_SIZE'] ?? 10)); +const WORKER_ID = String(process.env['PATH_SIM_WORKER_ID'] ?? 'solo').trim(); + +const BASE_MAX_SEARCH_STATES = Number(process.env['PATH_SIM_MAX_STATES'] ?? 200_000); +const BASE_MAX_CANDIDATES = Math.max(4, Number(process.env['PATH_SIM_MAX_CANDIDATES'] ?? 24)); +const BASE_HOP_MILES = Number(process.env['PATH_SIM_HOP_MILES'] ?? 75); +const BASE_WEAK_PATHLOSS_DB = Number(process.env['PATH_SIM_WEAK_PATHLOSS_DB'] ?? 135); +const BASE_MODEL_BUCKET_HOURS = Math.max(1, Number(process.env['PATH_SIM_MODEL_BUCKET_HOURS'] ?? 6)); +const BASE_WINDOW_DAYS = Math.max(1, Number(process.env['PATH_SIM_WINDOW_DAYS'] ?? 60)); + +type SimConfig = { + maxSearchStates: number; + maxCandidates: number; + hopMiles: number; + weakPathLossDb: number; + modelBucketHours: number; + windowDays: number; +}; + +type SimNode = { + node_id: string; + lat: number; + lon: number; + iata: string | null; + elevation_m: number | null; +}; + +type PacketRow = { + time: string; + packet_hash: string; + rx_node_id: string | null; + src_node_id: string | null; + packet_type: number | null; + hop_count: number | null; + path_hashes: string[] | null; + network: string | null; +}; + +type LinkRow = { + node_a_id: string; + node_b_id: string; + itm_path_loss_db: number | null; + observed_count: number; + count_a_to_b: number; + count_b_to_a: number; + itm_viable: boolean | null; + force_viable: boolean | null; +}; + +type Strategy = { + name: string; + maxHopKm: number; + requireExplicitEnd: boolean; +}; + +type ContinuationStats = { + fullCount: number; + partialCount: number; + longestPrefixDepth: number; + truncated: boolean; + bestConfidence: number; +}; + +type StrategyStats = { + packetsEligible: number; + packetsFullyResolved: number; + packetsUnresolved: number; + unresolvedWithPermutations: number; + unresolvedWithoutPermutations: number; + truncatedSearches: number; + noProgressPackets: number; + totalPermutations: number; + totalRemainingHops: number; + totalBestConfidence: number; + permutationHistogram: Map; + remainingHopsHistogram: Map; +}; + +type CandidateScoreContext = { + packetNetwork: string; + receiverRegion: string; + hourBucket: number; + current: SimNode; + candidate: SimNode; + prevPrefix: string; + prefix: string; + nextTowardRx: string | null; + linksByPair: Map; + weakAdjacency: Map>; + model: LearningModel; + config: SimConfig; +}; + +type LearningModel = { + prefixProbabilities: Map; + transitionProbabilities: Map; + edgeScores: Map; + edgeScoresAnyHour: Map; + motifProbabilities: Map; + motifProbabilitiesAnyHour: Map; + calibration: Map; +}; + +type RunContext = { + byId: Map; + byPrefix: Map; + prefixCounts: Map; + linksByPair: Map; + weakAdjacency: Map>; + model: LearningModel; + config: SimConfig; +}; + +type RunSummary = { + packetsEligible: number; + packetsFullyResolved: number; + packetsUnresolved: number; + avgPermutationsPerEligible: number; + avgRemainingHopsPerEligible: number; + avgBestConfidencePerEligible: number; +}; + +type RunResult = { + summary: RunSummary; + runId: number; +}; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function linkKey(a: string, b: string): string { + return a < b ? `${a}:${b}` : `${b}:${a}`; +} + +function distKm(a: SimNode, b: SimNode): number { + const midLat = ((a.lat + b.lat) / 2) * (Math.PI / 180); + const dlat = (a.lat - b.lat) * 111; + const dlon = (a.lon - b.lon) * 111 * Math.cos(midLat); + return Math.hypot(dlat, dlon); +} + +function bump(map: Map, key: string, amount = 1): void { + map.set(key, (map.get(key) ?? 0) + amount); +} + +function permutationBucket(value: number): string { + if (value >= PERM_BUCKET_CAP) return `${PERM_BUCKET_CAP}+`; + return String(value); +} + +function mapToObject(map: Map): Record { + return Object.fromEntries( + Array.from(map.entries()).sort((a, b) => { + const aNum = Number(a[0].replace('+', '')); + const bNum = Number(b[0].replace('+', '')); + return aNum - bNum; + }), + ); +} + +function createStrategyStats(): StrategyStats { + return { + packetsEligible: 0, + packetsFullyResolved: 0, + packetsUnresolved: 0, + unresolvedWithPermutations: 0, + unresolvedWithoutPermutations: 0, + truncatedSearches: 0, + noProgressPackets: 0, + totalPermutations: 0, + totalRemainingHops: 0, + totalBestConfidence: 0, + permutationHistogram: new Map(), + remainingHopsHistogram: new Map(), + }; +} + +function directionalSupport(link: LinkRow | undefined, fromId: string, toId: string): number { + if (!link) return 0.5; + const a = fromId < toId ? fromId : toId; + const forward = fromId === a ? link.count_a_to_b : link.count_b_to_a; + const reverse = fromId === a ? link.count_b_to_a : link.count_a_to_b; + const total = forward + reverse; + if (total <= 0) return 0.5; + return forward / total; +} + +function currentHourBucket(ts: Date, config: SimConfig): number { + return Math.floor(ts.getUTCHours() / Math.max(1, config.modelBucketHours)); +} + +function buildStrategies(config: SimConfig): Strategy[] { + const hopKm = config.hopMiles * 1.609344; + return [ + { name: 'ml_75mi', maxHopKm: hopKm, requireExplicitEnd: false }, + { name: 'ml_100mi', maxHopKm: 100 * 1.609344, requireExplicitEnd: false }, + { name: 'ml_75mi_require_end', maxHopKm: hopKm, requireExplicitEnd: true }, + { name: 'ml_100mi_require_end', maxHopKm: 100 * 1.609344, requireExplicitEnd: true }, + ]; +} + +function lookupPrefixPrior(model: LearningModel, network: string, region: string, prefix: string, prevPrefix: string, nodeId: string): number { + const networks = network === 'all' ? ['teesside', 'ukmesh'] : [network, 'teesside', 'ukmesh']; + const regions = [region, 'unknown']; + for (const net of networks) { + for (const reg of regions) { + const exact = `${net}|${reg}|${prefix}|${prevPrefix}|${nodeId}`; + const noPrev = `${net}|${reg}|${prefix}||${nodeId}`; + const found = model.prefixProbabilities.get(exact) ?? model.prefixProbabilities.get(noPrev); + if (found != null) return found; + } + } + return 0; +} + +function lookupTransitionPrior(model: LearningModel, network: string, region: string, fromNodeId: string, toNodeId: string): number { + const networks = network === 'all' ? ['teesside', 'ukmesh'] : [network, 'teesside', 'ukmesh']; + const regions = [region, 'unknown']; + for (const net of networks) { + for (const reg of regions) { + const key = `${net}|${reg}|${fromNodeId}|${toNodeId}`; + const found = model.transitionProbabilities.get(key); + if (found != null) return found; + } + } + return 0; +} + +function lookupEdgePrior(model: LearningModel, network: string, region: string, bucket: number, fromNodeId: string, toNodeId: string): number { + const networks = network === 'all' ? ['teesside', 'ukmesh'] : [network, 'teesside', 'ukmesh']; + const regions = [region, 'unknown']; + for (const net of networks) { + for (const reg of regions) { + const exact = `${net}|${reg}|${bucket}|${fromNodeId}|${toNodeId}`; + const fallback = `${net}|${reg}|${fromNodeId}|${toNodeId}`; + const found = model.edgeScores.get(exact) ?? model.edgeScoresAnyHour.get(fallback); + if (found != null) return found; + } + } + return 0; +} + +function lookupMotifPrior(model: LearningModel, network: string, region: string, bucket: number, nodeIds: string[]): number { + if (nodeIds.length < 2 || nodeIds.length > 3) return 0; + const path = nodeIds.join('>'); + const networks = network === 'all' ? ['teesside', 'ukmesh'] : [network, 'teesside', 'ukmesh']; + const regions = [region, 'unknown']; + for (const net of networks) { + for (const reg of regions) { + const exact = `${net}|${reg}|${bucket}|${path}`; + const fallback = `${net}|${reg}|${path}`; + const found = model.motifProbabilities.get(exact) ?? model.motifProbabilitiesAnyHour.get(fallback); + if (found != null) return found; + } + } + return 0; +} + +function lookupCalibration(model: LearningModel, network: string): { scale: number; bias: number } { + return model.calibration.get(network) + ?? model.calibration.get('teesside') + ?? model.calibration.get('ukmesh') + ?? { scale: 1, bias: 0 }; +} + +function localHexClashPenalty( + candidate: SimNode, + current: SimNode, + prefixCounts: Map, + byPrefix: Map, + weakAdjacency: Map>, +): number { + const prefix = candidate.node_id.slice(0, 2).toUpperCase(); + const total = prefixCounts.get(prefix) ?? 0; + if (total <= 1) return 0; + const peers = byPrefix.get(prefix) ?? []; + let raw = 0; + const inRangeKm = 75; + for (const peer of peers) { + if (peer.node_id === candidate.node_id) continue; + const d = distKm(peer, candidate); + if (d > inRangeKm) continue; + const nearCurrent = distKm(peer, current) <= inRangeKm; + const weakLinked = weakAdjacency.get(candidate.node_id)?.has(peer.node_id) ?? false; + if (!nearCurrent && !weakLinked) continue; + raw += weakLinked ? 1 : 0.45; + } + return clamp(raw * 0.07, 0, 0.24); +} + +function scoreCandidate(ctx: CandidateScoreContext, prefixCounts: Map, byPrefix: Map): number { + const { candidate, current, linksByPair, weakAdjacency, model, config } = ctx; + const pair = linksByPair.get(linkKey(candidate.node_id, current.node_id)); + const d = distKm(candidate, current); + const distanceScore = Math.exp(-d / 26); + const pathLoss = pair?.itm_path_loss_db; + const pathLossScore = pathLoss == null ? 0.45 : clamp((145 - pathLoss) / 22, 0, 1); + const observedScore = clamp(Math.log1p(pair?.observed_count ?? 0) / 5, 0, 1); + const directionScore = directionalSupport(pair, candidate.node_id, current.node_id); + const viableEdge = Boolean(pathLoss != null && pathLoss <= config.weakPathLossDb); + const elevationScore = clamp((((candidate.elevation_m ?? 0) + (current.elevation_m ?? 0)) / 2 + 60) / 320, 0, 1); + + const prefixPrior = lookupPrefixPrior(model, ctx.packetNetwork, ctx.receiverRegion, ctx.prefix, ctx.prevPrefix, candidate.node_id); + const transitionPrior = lookupTransitionPrior(model, ctx.packetNetwork, ctx.receiverRegion, candidate.node_id, current.node_id); + const edgePrior = lookupEdgePrior(model, ctx.packetNetwork, ctx.receiverRegion, ctx.hourBucket, candidate.node_id, current.node_id); + const motif2 = lookupMotifPrior(model, ctx.packetNetwork, ctx.receiverRegion, ctx.hourBucket, [candidate.node_id, current.node_id]); + const motif3 = ctx.nextTowardRx + ? lookupMotifPrior(model, ctx.packetNetwork, ctx.receiverRegion, ctx.hourBucket, [candidate.node_id, current.node_id, ctx.nextTowardRx]) + : 0; + + const ambiguityPenalty = localHexClashPenalty(candidate, current, prefixCounts, byPrefix, weakAdjacency); + const weakAdjacencyBoost = weakAdjacency.get(candidate.node_id)?.has(current.node_id) ? 0.04 : 0; + const veryHighLossPenalty = pathLoss != null && pathLoss > 145 ? 0.12 : 0; + + const raw = 0.2 * distanceScore + + 0.12 * pathLossScore + + 0.08 * observedScore + + 0.06 * directionScore + + 0.06 * elevationScore + + 0.16 * prefixPrior + + 0.14 * transitionPrior + + 0.14 * edgePrior + + 0.07 * motif2 + + 0.05 * motif3 + + weakAdjacencyBoost + + (viableEdge ? 0.03 : 0) + - ambiguityPenalty + - veryHighLossPenalty; + return clamp(raw, 0.01, 0.99); +} + +function getCandidatesForPrefix( + prefix: string, + prevPrefix: string, + current: SimNode, + visited: Set, + srcNodeId: string, + nextTowardRx: string | null, + strategy: Strategy, + context: RunContext, + packetNetwork: string, + receiverRegion: string, + hourBucket: number, +): Array<{ node: SimNode; conf: number }> { + const pool = context.byPrefix.get(prefix) ?? []; + const scored: Array<{ node: SimNode; conf: number }> = []; + for (const node of pool) { + if (visited.has(node.node_id)) continue; + if (node.node_id === srcNodeId && prefix !== srcNodeId.slice(0, 2).toUpperCase()) continue; + const d = distKm(node, current); + if (d > strategy.maxHopKm) continue; + const conf = scoreCandidate({ + packetNetwork, + receiverRegion, + hourBucket, + current, + candidate: node, + prevPrefix, + prefix, + nextTowardRx, + linksByPair: context.linksByPair, + weakAdjacency: context.weakAdjacency, + model: context.model, + config: context.config, + }, context.prefixCounts, context.byPrefix); + scored.push({ node, conf }); + } + scored.sort((a, b) => b.conf - a.conf); + const limit = Math.max(6, Math.min(context.config.maxCandidates, Math.floor(6 + Math.sqrt(scored.length) * 4))); + return scored.slice(0, limit); +} + +function enumerateContinuationsMl( + srcNodeId: string, + pathHashes: string[], + rxNodeId: string, + packetTime: Date, + packetNetwork: string, + strategy: Strategy, + context: RunContext, +): ContinuationStats { + if (pathHashes.length === 0 || srcNodeId === rxNodeId) { + return { fullCount: 0, partialCount: 0, longestPrefixDepth: 0, truncated: false, bestConfidence: 0 }; + } + const rx = context.byId.get(rxNodeId); + const src = context.byId.get(srcNodeId); + if (!rx || !src) { + return { fullCount: 0, partialCount: 0, longestPrefixDepth: 0, truncated: false, bestConfidence: 0 }; + } + + const prefixes = pathHashes.map((h) => h.slice(0, 2).toUpperCase()); + const receiverRegion = rx.iata ?? 'unknown'; + const hourBucket = currentHourBucket(packetTime, context.config); + const calibration = lookupCalibration(context.model, packetNetwork); + + let fullCount = 0; + let partialCount = 0; + let longestPrefixDepth = 0; + let bestPartialDepth = 0; + let bestConfidence = 0; + let states = 0; + let truncated = false; + + const updatePartial = (depth: number) => { + if (depth > bestPartialDepth) { + bestPartialDepth = depth; + partialCount = 0; + } + if (depth === bestPartialDepth) partialCount += 1; + }; + + const dfs = ( + idx: number, + current: SimNode, + visited: Set, + confSum: number, + nextTowardRx: string | null, + ) => { + const consumed = prefixes.length - Math.max(0, idx + 1); + if (consumed > longestPrefixDepth) longestPrefixDepth = consumed; + if (states++ >= context.config.maxSearchStates) { + truncated = true; + return; + } + + if (idx < 0) { + const atSrc = current.node_id === src.node_id; + const appendSrcAllowed = !strategy.requireExplicitEnd && !visited.has(src.node_id); + if (atSrc || appendSrcAllowed) { + fullCount += 1; + const raw = confSum / Math.max(1, prefixes.length); + const calibrated = clamp(raw * calibration.scale + calibration.bias, 0, 1); + if (calibrated > bestConfidence) bestConfidence = calibrated; + } else { + updatePartial(prefixes.length); + } + return; + } + + const prefix = prefixes[idx]!; + const prevPrefix = idx > 0 ? prefixes[idx - 1]! : ''; + const options = getCandidatesForPrefix( + prefix, + prevPrefix, + current, + visited, + src.node_id, + nextTowardRx, + strategy, + context, + packetNetwork, + receiverRegion, + hourBucket, + ); + if (options.length === 0) { + updatePartial(consumed); + return; + } + + for (const option of options) { + visited.add(option.node.node_id); + dfs(idx - 1, option.node, visited, confSum + option.conf, current.node_id); + visited.delete(option.node.node_id); + if (truncated) return; + } + }; + + const visited = new Set([rx.node_id]); + dfs(prefixes.length - 1, rx, visited, 0, null); + return { fullCount, partialCount, longestPrefixDepth, truncated, bestConfidence }; +} + +async function loadNodes(): Promise<{ byId: Map; byPrefix: Map; prefixCounts: Map }> { + const hasNetwork = NETWORK !== 'all'; + const networkFilter = hasNetwork ? 'AND n.network = $1' : ''; + const packetNetworkFilter = hasNetwork ? 'AND p.network = $1' : ''; + const params: unknown[] = NETWORK === 'all' ? [] : [NETWORK]; + const nodeRows = await query( + `SELECT n.node_id, n.lat, n.lon, n.iata, n.elevation_m + FROM nodes n + WHERE n.lat IS NOT NULL + AND n.lon IS NOT NULL + AND (n.role IS NULL OR n.role = 2) + ${networkFilter} + AND EXISTS ( + SELECT 1 + FROM packets p + WHERE (p.src_node_id = n.node_id OR p.rx_node_id = n.node_id) + ${packetNetworkFilter} + )`, + params, + ); + + const byId = new Map(); + const byPrefix = new Map(); + const prefixCounts = new Map(); + for (const row of nodeRows.rows) { + byId.set(row.node_id, row); + const prefix = row.node_id.slice(0, 2).toUpperCase(); + const arr = byPrefix.get(prefix); + if (arr) arr.push(row); + else byPrefix.set(prefix, [row]); + prefixCounts.set(prefix, (prefixCounts.get(prefix) ?? 0) + 1); + } + return { byId, byPrefix, prefixCounts }; +} + +async function loadLinks(config: SimConfig): Promise<{ linksByPair: Map; weakAdjacency: Map> }> { + const hasNetwork = NETWORK !== 'all'; + const networkFilter = hasNetwork ? 'AND a.network = $1 AND b.network = $1' : ''; + const params: unknown[] = hasNetwork ? [NETWORK] : []; + const result = await query( + `SELECT nl.node_a_id, nl.node_b_id, nl.itm_path_loss_db, nl.observed_count, + nl.count_a_to_b, nl.count_b_to_a, nl.itm_viable, nl.force_viable + FROM node_links nl + JOIN nodes a ON a.node_id = nl.node_a_id + JOIN nodes b ON b.node_id = nl.node_b_id + WHERE 1 = 1 + ${networkFilter}`, + params, + ); + + const linksByPair = new Map(); + const weakAdjacency = new Map>(); + for (const row of result.rows) { + const key = linkKey(row.node_a_id, row.node_b_id); + linksByPair.set(key, row); + const pathLoss = row.itm_path_loss_db; + const weakOrBetter = pathLoss != null && pathLoss <= config.weakPathLossDb; + if (!weakOrBetter) continue; + if (!weakAdjacency.has(row.node_a_id)) weakAdjacency.set(row.node_a_id, new Set()); + if (!weakAdjacency.has(row.node_b_id)) weakAdjacency.set(row.node_b_id, new Set()); + weakAdjacency.get(row.node_a_id)!.add(row.node_b_id); + weakAdjacency.get(row.node_b_id)!.add(row.node_a_id); + } + return { linksByPair, weakAdjacency }; +} + +async function loadLearningModel(): Promise { + const hasNetwork = NETWORK !== 'all'; + const networkFilter = hasNetwork ? 'WHERE network = $1' : ''; + const params: unknown[] = hasNetwork ? [NETWORK] : []; + const [prefixRows, transitionRows, edgeRows, motifRows, calibrationRows] = await Promise.all([ + query<{ network: string; receiver_region: string; prefix: string; prev_prefix: string | null; node_id: string; probability: number }>( + `SELECT network, receiver_region, prefix, prev_prefix, node_id, probability FROM path_prefix_priors ${networkFilter}`, + params, + ), + query<{ network: string; receiver_region: string; from_node_id: string; to_node_id: string; probability: number }>( + `SELECT network, receiver_region, from_node_id, to_node_id, probability FROM path_transition_priors ${networkFilter}`, + params, + ), + query<{ network: string; receiver_region: string; hour_bucket: number; from_node_id: string; to_node_id: string; score: number }>( + `SELECT network, receiver_region, hour_bucket, from_node_id, to_node_id, score FROM path_edge_priors ${networkFilter}`, + params, + ), + query<{ network: string; receiver_region: string; hour_bucket: number; node_ids: string; probability: number }>( + `SELECT network, receiver_region, hour_bucket, node_ids, probability FROM path_motif_priors ${networkFilter}`, + params, + ), + query<{ network: string; confidence_scale: number; confidence_bias: number }>( + `SELECT network, confidence_scale, confidence_bias FROM path_model_calibration ${networkFilter}`, + params, + ), + ]); + + const model: LearningModel = { + prefixProbabilities: new Map(), + transitionProbabilities: new Map(), + edgeScores: new Map(), + edgeScoresAnyHour: new Map(), + motifProbabilities: new Map(), + motifProbabilitiesAnyHour: new Map(), + calibration: new Map(), + }; + + for (const row of prefixRows.rows) model.prefixProbabilities.set(`${row.network}|${row.receiver_region}|${row.prefix}|${row.prev_prefix ?? ''}|${row.node_id}`, Number(row.probability)); + for (const row of transitionRows.rows) model.transitionProbabilities.set(`${row.network}|${row.receiver_region}|${row.from_node_id}|${row.to_node_id}`, Number(row.probability)); + for (const row of edgeRows.rows) { + const score = Number(row.score); + model.edgeScores.set(`${row.network}|${row.receiver_region}|${row.hour_bucket}|${row.from_node_id}|${row.to_node_id}`, score); + const anyKey = `${row.network}|${row.receiver_region}|${row.from_node_id}|${row.to_node_id}`; + const existing = model.edgeScoresAnyHour.get(anyKey); + if (existing == null || score > existing) model.edgeScoresAnyHour.set(anyKey, score); + } + for (const row of motifRows.rows) { + const prob = Number(row.probability); + model.motifProbabilities.set(`${row.network}|${row.receiver_region}|${row.hour_bucket}|${row.node_ids}`, prob); + const anyKey = `${row.network}|${row.receiver_region}|${row.node_ids}`; + const existing = model.motifProbabilitiesAnyHour.get(anyKey); + if (existing == null || prob > existing) model.motifProbabilitiesAnyHour.set(anyKey, prob); + } + for (const row of calibrationRows.rows) model.calibration.set(row.network, { scale: Number(row.confidence_scale), bias: Number(row.confidence_bias) }); + return model; +} + +async function loadPackets(windowDays: number): Promise { + const hasNetwork = NETWORK !== 'all'; + const params: unknown[] = hasNetwork ? [NETWORK, windowDays] : [windowDays]; + const networkFilter = hasNetwork ? 'AND network = $1' : ''; + const windowPos = hasNetwork ? '$2' : '$1'; + const result = await query( + `SELECT DISTINCT ON (packet_hash) + time::text AS time, packet_hash, rx_node_id, src_node_id, packet_type, hop_count, path_hashes, network + FROM packets + WHERE packet_hash IS NOT NULL + AND packet_hash <> '' + AND time > NOW() - (${windowPos}::int * INTERVAL '1 day') + ${networkFilter} + ORDER BY packet_hash, time DESC`, + params, + ); + return result.rows; +} + +function normalizeConfig(config: Partial): SimConfig { + return { + maxSearchStates: Math.max(30_000, Math.round(config.maxSearchStates ?? BASE_MAX_SEARCH_STATES)), + maxCandidates: Math.max(4, Math.min(64, Math.round(config.maxCandidates ?? BASE_MAX_CANDIDATES))), + hopMiles: clamp(Number(config.hopMiles ?? BASE_HOP_MILES), 45, 140), + weakPathLossDb: clamp(Number(config.weakPathLossDb ?? BASE_WEAK_PATHLOSS_DB), 125, 145), + modelBucketHours: Math.max(1, Math.min(24, Math.round(config.modelBucketHours ?? BASE_MODEL_BUCKET_HOURS))), + windowDays: Math.max(1, Math.min(365, Math.round(config.windowDays ?? BASE_WINDOW_DAYS))), + }; +} + +function seededNoise(seed: number): number { + let x = Math.sin(seed * 12.9898) * 43758.5453; + x = x - Math.floor(x); + return x * 2 - 1; +} + +function mutateConfig(base: SimConfig, generation: number, slotIndex: number): SimConfig { + if (slotIndex <= 1) return { ...base }; + const n1 = seededNoise(generation * 100 + slotIndex * 11); + const n2 = seededNoise(generation * 100 + slotIndex * 13); + const n3 = seededNoise(generation * 100 + slotIndex * 17); + const n4 = seededNoise(generation * 100 + slotIndex * 19); + const n5 = seededNoise(generation * 100 + slotIndex * 23); + return normalizeConfig({ + hopMiles: base.hopMiles * (1 + n1 * 0.14), + maxCandidates: Math.round(base.maxCandidates * (1 + n2 * 0.20)), + maxSearchStates: Math.round(base.maxSearchStates * (1 + n3 * 0.25)), + weakPathLossDb: base.weakPathLossDb + n4 * 2.5, + modelBucketHours: Math.round(base.modelBucketHours + n5 * 2), + windowDays: base.windowDays, + }); +} + +function runFitness(summary: RunSummary): number { + const eligible = Math.max(1, summary.packetsEligible); + const resolvedRate = summary.packetsFullyResolved / eligible; + const remainingPenalty = clamp(summary.avgRemainingHopsPerEligible / 10, 0, 1); + const unresolvedPenalty = summary.packetsUnresolved / eligible; + return resolvedRate + summary.avgBestConfidencePerEligible * 0.12 - remainingPenalty * 0.25 - unresolvedPenalty * 0.08; +} + +async function ensureEvolutionSeed(): Promise { + await query( + `INSERT INTO path_sim_evolution_state (id, current_generation, evolved_generation, updated_at) + VALUES (1, 1, 0, NOW()) + ON CONFLICT (id) DO NOTHING`, + ); + const seeded = await query<{ c: number }>(`SELECT COUNT(*)::int AS c FROM path_sim_population WHERE generation = 1`); + if (Number(seeded.rows[0]?.c ?? 0) >= POPULATION_SIZE) return; + for (let i = 1; i <= POPULATION_SIZE; i++) { + const variantId = `v${String(i).padStart(2, '0')}`; + const seedCfg = mutateConfig( + normalizeConfig({ + maxSearchStates: BASE_MAX_SEARCH_STATES, + maxCandidates: BASE_MAX_CANDIDATES, + hopMiles: BASE_HOP_MILES, + weakPathLossDb: BASE_WEAK_PATHLOSS_DB, + modelBucketHours: BASE_MODEL_BUCKET_HOURS, + windowDays: BASE_WINDOW_DAYS, + }), + 1, + i, + ); + await query( + `INSERT INTO path_sim_population (generation, variant_id, params, updated_at) + VALUES (1, $1, $2::jsonb, NOW()) + ON CONFLICT (generation, variant_id) DO NOTHING`, + [variantId, JSON.stringify(seedCfg)], + ); + } +} + +async function loadCurrentGeneration(): Promise { + const state = await query<{ current_generation: number }>(`SELECT current_generation FROM path_sim_evolution_state WHERE id = 1`); + return Number(state.rows[0]?.current_generation ?? 1); +} + +async function loadVariantConfig(generation: number, variantId: string): Promise { + const row = await query<{ params: Record }>( + `SELECT params FROM path_sim_population WHERE generation = $1 AND variant_id = $2`, + [generation, variantId], + ); + if (row.rows.length > 0) return normalizeConfig(row.rows[0]?.params as Partial); + const fallback = normalizeConfig({ + maxSearchStates: BASE_MAX_SEARCH_STATES, + maxCandidates: BASE_MAX_CANDIDATES, + hopMiles: BASE_HOP_MILES, + weakPathLossDb: BASE_WEAK_PATHLOSS_DB, + modelBucketHours: BASE_MODEL_BUCKET_HOURS, + windowDays: BASE_WINDOW_DAYS, + }); + await query( + `INSERT INTO path_sim_population (generation, variant_id, params, updated_at) + VALUES ($1, $2, $3::jsonb, NOW()) + ON CONFLICT (generation, variant_id) DO NOTHING`, + [generation, variantId, JSON.stringify(fallback)], + ); + return fallback; +} + +async function tryEvolveGeneration(currentGeneration: number): Promise { + const state = await query<{ current_generation: number; evolved_generation: number }>( + `SELECT current_generation, evolved_generation FROM path_sim_evolution_state WHERE id = 1`, + ); + const row = state.rows[0]; + if (!row) return; + const cur = Number(row.current_generation); + const evolved = Number(row.evolved_generation); + if (cur !== currentGeneration || evolved >= currentGeneration) return; + + const done = await query<{ c: number }>( + `SELECT COUNT(*)::int AS c + FROM path_sim_population + WHERE generation = $1 + AND fitness IS NOT NULL`, + [currentGeneration], + ); + const doneCount = Number(done.rows[0]?.c ?? 0); + if (doneCount < POPULATION_SIZE) return; + + const best = await query<{ variant_id: string; params: Record; fitness: number }>( + `SELECT variant_id, params, fitness + FROM path_sim_population + WHERE generation = $1 + ORDER BY fitness DESC NULLS LAST, updated_at DESC + LIMIT 1`, + [currentGeneration], + ); + const bestRow = best.rows[0]; + if (!bestRow) return; + const nextGeneration = currentGeneration + 1; + const bestParams = normalizeConfig(bestRow.params as Partial); + + for (let i = 1; i <= POPULATION_SIZE; i++) { + const variantId = `v${String(i).padStart(2, '0')}`; + const candidate = mutateConfig(bestParams, nextGeneration, i); + await query( + `INSERT INTO path_sim_population (generation, variant_id, params, fitness, run_id, updated_at) + VALUES ($1, $2, $3::jsonb, NULL, NULL, NOW()) + ON CONFLICT (generation, variant_id) DO UPDATE SET + params = EXCLUDED.params, + fitness = NULL, + run_id = NULL, + updated_at = NOW()`, + [nextGeneration, variantId, JSON.stringify(candidate)], + ); + } + + await query( + `UPDATE path_sim_evolution_state + SET current_generation = $1, + evolved_generation = $2, + best_variant_id = $3, + best_fitness = $4, + updated_at = NOW() + WHERE id = 1`, + [nextGeneration, currentGeneration, bestRow.variant_id, Number(bestRow.fitness ?? 0)], + ); + + console.log(`[path-sim] evolved generation ${currentGeneration} -> ${nextGeneration} winner=${bestRow.variant_id} fitness=${Number(bestRow.fitness ?? 0).toFixed(5)}`); +} + +async function runOnce( + tag: 'initial' | 'scheduled', + generation: number, + config: SimConfig, + variantId: string, +): Promise { + const startedAt = new Date(); + console.log(`[path-sim] ${tag} run started worker=${WORKER_ID} variant=${variantId} gen=${generation} at ${startedAt.toISOString()}`); + + const [nodes, links, model, packets] = await Promise.all([ + loadNodes(), + loadLinks(config), + loadLearningModel(), + loadPackets(config.windowDays), + ]); + const context: RunContext = { + byId: nodes.byId, + byPrefix: nodes.byPrefix, + prefixCounts: nodes.prefixCounts, + linksByPair: links.linksByPair, + weakAdjacency: links.weakAdjacency, + model, + config, + }; + const strategies = buildStrategies(config); + + const skipReasons = new Map(); + const byStrategy = new Map(); + const packetTypeTotals = new Map(); + const eligiblePacketTypes = new Map(); + let directNoPathResolved = 0; + for (const strategy of strategies) byStrategy.set(strategy.name, createStrategyStats()); + + for (let idx = 0; idx < packets.length; idx++) { + const row = packets[idx]!; + if ((idx + 1) % LOG_EVERY === 0 || idx === 0 || idx === packets.length - 1) { + console.log(`[path-sim] packet ${idx + 1}/${packets.length} hash=${row.packet_hash}`); + } + bump(packetTypeTotals, String(row.packet_type ?? -1)); + const rx = row.rx_node_id ? context.byId.get(row.rx_node_id) : undefined; + const src = row.src_node_id ? context.byId.get(row.src_node_id) : undefined; + const hashes = row.path_hashes ?? []; + const hops = row.hop_count != null ? hashes.slice(0, Math.max(0, row.hop_count)) : hashes; + const isDirectNoPath = hops.length === 0 && row.hop_count === 0; + const packetNetwork = (row.network ?? NETWORK ?? 'teesside').trim().toLowerCase() || 'teesside'; + const packetTime = new Date(row.time); + + if (!src || !rx) { + bump(skipReasons, 'missing_src_or_rx_node'); + continue; + } + if (hops.length < 1 && !isDirectNoPath) { + bump(skipReasons, 'no_path_hashes_for_multihop_or_unknown_hops'); + continue; + } + if (hops.length > 0 && hops.some((h) => !context.byPrefix.has(h.slice(0, 2).toUpperCase()))) { + bump(skipReasons, 'unmapped_prefix_no_logged_repeater'); + continue; + } + bump(eligiblePacketTypes, String(row.packet_type ?? -1)); + + for (const strategy of strategies) { + const s = byStrategy.get(strategy.name)!; + s.packetsEligible += 1; + if (isDirectNoPath) { + s.totalPermutations += 1; + s.totalBestConfidence += 1; + bump(s.permutationHistogram, permutationBucket(1)); + bump(s.remainingHopsHistogram, '0'); + s.packetsFullyResolved += 1; + continue; + } + const stats = enumerateContinuationsMl(src.node_id, hops, rx.node_id, packetTime, packetNetwork, strategy, context); + if (stats.truncated) s.truncatedSearches += 1; + const permutations = stats.fullCount > 0 ? stats.fullCount : stats.partialCount; + const remainingHops = Math.max(0, hops.length - stats.longestPrefixDepth); + s.totalPermutations += permutations; + s.totalRemainingHops += remainingHops; + s.totalBestConfidence += stats.bestConfidence; + bump(s.permutationHistogram, permutationBucket(permutations)); + bump(s.remainingHopsHistogram, String(remainingHops)); + if (stats.longestPrefixDepth === 0) s.noProgressPackets += 1; + if (stats.fullCount > 0 && remainingHops === 0) s.packetsFullyResolved += 1; + else { + s.packetsUnresolved += 1; + if (permutations > 0) s.unresolvedWithPermutations += 1; + else s.unresolvedWithoutPermutations += 1; + } + } + if (isDirectNoPath) directNoPathResolved += 1; + } + + const strategyResults = Object.fromEntries(Array.from(byStrategy.entries()).map(([name, s]) => [ + name, + { + packetsEligible: s.packetsEligible, + packetsFullyResolved: s.packetsFullyResolved, + packetsUnresolved: s.packetsUnresolved, + unresolvedWithPermutations: s.unresolvedWithPermutations, + unresolvedWithoutPermutations: s.unresolvedWithoutPermutations, + truncatedSearches: s.truncatedSearches, + noProgressPackets: s.noProgressPackets, + avgPermutationsPerEligible: s.packetsEligible > 0 ? Number((s.totalPermutations / s.packetsEligible).toFixed(3)) : 0, + avgRemainingHopsPerEligible: s.packetsEligible > 0 ? Number((s.totalRemainingHops / s.packetsEligible).toFixed(3)) : 0, + avgBestConfidencePerEligible: s.packetsEligible > 0 ? Number((s.totalBestConfidence / s.packetsEligible).toFixed(3)) : 0, + permutationHistogram: mapToObject(s.permutationHistogram), + remainingHopsHistogram: mapToObject(s.remainingHopsHistogram), + }, + ])); + + const baseline = byStrategy.get('ml_75mi') ?? createStrategyStats(); + const completedAt = new Date(); + const durationMs = completedAt.getTime() - startedAt.getTime(); + const summary = { + variantId, + generation, + workerId: WORKER_ID, + network: NETWORK, + config, + startedAt: startedAt.toISOString(), + completedAt: completedAt.toISOString(), + durationMs, + packetsTotal: packets.length, + packetsEligible: baseline.packetsEligible, + packetsFullyResolved: baseline.packetsFullyResolved, + packetsUnresolved: baseline.packetsUnresolved, + unresolvedWithPermutations: baseline.unresolvedWithPermutations, + unresolvedWithoutPermutations: baseline.unresolvedWithoutPermutations, + truncatedSearches: baseline.truncatedSearches, + noProgressPackets: baseline.noProgressPackets, + avgPermutationsPerEligible: baseline.packetsEligible > 0 ? Number((baseline.totalPermutations / baseline.packetsEligible).toFixed(3)) : 0, + avgRemainingHopsPerEligible: baseline.packetsEligible > 0 ? Number((baseline.totalRemainingHops / baseline.packetsEligible).toFixed(3)) : 0, + avgBestConfidencePerEligible: baseline.packetsEligible > 0 ? Number((baseline.totalBestConfidence / baseline.packetsEligible).toFixed(3)) : 0, + permutationHistogram: mapToObject(baseline.permutationHistogram), + remainingHopsHistogram: mapToObject(baseline.remainingHopsHistogram), + packetTypeTotals: mapToObject(packetTypeTotals), + eligiblePacketTypes: mapToObject(eligiblePacketTypes), + directNoPathResolved, + skipReasons: mapToObject(skipReasons), + strategyResults, + }; + + const insert = await query<{ id: number }>( + `INSERT INTO path_simulation_runs + (started_at, completed_at, network, packets_total, packets_eligible, packets_fully_resolved, + packets_unresolved, truncated_searches, permutation_histogram, remaining_hops_histogram, summary) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11::jsonb) + RETURNING id`, + [ + startedAt.toISOString(), + completedAt.toISOString(), + NETWORK, + packets.length, + baseline.packetsEligible, + baseline.packetsFullyResolved, + baseline.packetsUnresolved, + baseline.truncatedSearches, + JSON.stringify(summary.permutationHistogram), + JSON.stringify(summary.remainingHopsHistogram), + JSON.stringify(summary), + ], + ); + const runId = Number(insert.rows[0]?.id ?? 0); + summary.variantId = variantId; + console.log(`[path-sim] ${tag} summary worker=${WORKER_ID} variant=${variantId} gen=${generation}:`, JSON.stringify(summary)); + return { + summary: { + packetsEligible: summary.packetsEligible, + packetsFullyResolved: summary.packetsFullyResolved, + packetsUnresolved: summary.packetsUnresolved, + avgPermutationsPerEligible: summary.avgPermutationsPerEligible, + avgRemainingHopsPerEligible: summary.avgRemainingHopsPerEligible, + avgBestConfidencePerEligible: summary.avgBestConfidencePerEligible, + }, + runId, + }; +} + +async function runCycle(tag: 'initial' | 'scheduled'): Promise { + await ensureEvolutionSeed(); + const generation = await loadCurrentGeneration(); + for (let i = 1; i <= POPULATION_SIZE; i++) { + const variantId = `v${String(i).padStart(2, '0')}`; + const config = await loadVariantConfig(generation, variantId); + console.log( + `[path-sim] configuration worker=${WORKER_ID} variant=${variantId} gen=${generation} network=${NETWORK} ` + + `windowDays=${config.windowDays} hopMiles=${config.hopMiles} maxStates=${config.maxSearchStates} ` + + `maxCandidates=${config.maxCandidates} weakPathLoss=${config.weakPathLossDb} bucketHours=${config.modelBucketHours}`, + ); + const result = await runOnce(tag, generation, config, variantId); + const fitness = runFitness(result.summary); + await query( + `UPDATE path_sim_population + SET fitness = $3, run_id = $4, params = $5::jsonb, updated_at = NOW() + WHERE generation = $1 AND variant_id = $2`, + [generation, variantId, fitness, result.runId || null, JSON.stringify(config)], + ); + console.log(`[path-sim] fitness worker=${WORKER_ID} variant=${variantId} gen=${generation} value=${fitness.toFixed(5)}`); + } + await tryEvolveGeneration(generation); +} + +async function main() { + await initDb(); + await runCycle('initial'); + setInterval(() => { + void runCycle('scheduled').catch((err) => { + console.error('[path-sim] scheduled run failed:', (err as Error).message); + }); + }, RUN_INTERVAL_MS); +} + +main().catch((err) => { + console.error('[path-sim] fatal startup error:', err); + process.exit(1); +}); diff --git a/docker-compose.yml b/docker-compose.yml index d41d940..3ab7cd0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,6 +98,316 @@ services: redis: condition: service_healthy + path-sim-worker-01: + build: + context: . + dockerfile: Dockerfile.backend + restart: unless-stopped + command: ["node", "dist/workers/path-sim.js"] + cpus: "0.5" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} + NODE_ENV: production + UV_THREADPOOL_SIZE: "1" + PATH_SIM_VARIANT_ID: "v01" + PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000} + PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all} + PATH_SIM_WINDOW_DAYS: "60" + PATH_SIM_ENABLE_EARLY_STOP: "false" + PATH_SIM_MAX_STATES: "200000" + PATH_SIM_MAX_CANDIDATES: "24" + PATH_SIM_HOP_MILES: "75" + PATH_SIM_WEAK_PATHLOSS_DB: "135" + PATH_SIM_MODEL_BUCKET_HOURS: "6" + PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50} + PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1} + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + + path-sim-worker-02: + build: + context: . + dockerfile: Dockerfile.backend + restart: unless-stopped + command: ["node", "dist/workers/path-sim.js"] + cpus: "0.5" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} + NODE_ENV: production + UV_THREADPOOL_SIZE: "1" + PATH_SIM_VARIANT_ID: "v02" + PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000} + PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all} + PATH_SIM_WINDOW_DAYS: "60" + PATH_SIM_ENABLE_EARLY_STOP: "false" + PATH_SIM_MAX_STATES: "180000" + PATH_SIM_MAX_CANDIDATES: "20" + PATH_SIM_HOP_MILES: "70" + PATH_SIM_WEAK_PATHLOSS_DB: "134" + PATH_SIM_MODEL_BUCKET_HOURS: "6" + PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50} + PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1} + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + + path-sim-worker-03: + build: + context: . + dockerfile: Dockerfile.backend + restart: unless-stopped + command: ["node", "dist/workers/path-sim.js"] + cpus: "0.5" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} + NODE_ENV: production + UV_THREADPOOL_SIZE: "1" + PATH_SIM_VARIANT_ID: "v03" + PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000} + PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all} + PATH_SIM_WINDOW_DAYS: "60" + PATH_SIM_ENABLE_EARLY_STOP: "false" + PATH_SIM_MAX_STATES: "220000" + PATH_SIM_MAX_CANDIDATES: "28" + PATH_SIM_HOP_MILES: "80" + PATH_SIM_WEAK_PATHLOSS_DB: "136" + PATH_SIM_MODEL_BUCKET_HOURS: "6" + PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50} + PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1} + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + + path-sim-worker-04: + build: + context: . + dockerfile: Dockerfile.backend + restart: unless-stopped + command: ["node", "dist/workers/path-sim.js"] + cpus: "0.5" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} + NODE_ENV: production + UV_THREADPOOL_SIZE: "1" + PATH_SIM_VARIANT_ID: "v04" + PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000} + PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all} + PATH_SIM_WINDOW_DAYS: "60" + PATH_SIM_ENABLE_EARLY_STOP: "false" + PATH_SIM_MAX_STATES: "240000" + PATH_SIM_MAX_CANDIDATES: "24" + PATH_SIM_HOP_MILES: "85" + PATH_SIM_WEAK_PATHLOSS_DB: "135" + PATH_SIM_MODEL_BUCKET_HOURS: "4" + PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50} + PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1} + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + + path-sim-worker-05: + build: + context: . + dockerfile: Dockerfile.backend + restart: unless-stopped + command: ["node", "dist/workers/path-sim.js"] + cpus: "0.5" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} + NODE_ENV: production + UV_THREADPOOL_SIZE: "1" + PATH_SIM_VARIANT_ID: "v05" + PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000} + PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all} + PATH_SIM_WINDOW_DAYS: "60" + PATH_SIM_ENABLE_EARLY_STOP: "false" + PATH_SIM_MAX_STATES: "260000" + PATH_SIM_MAX_CANDIDATES: "30" + PATH_SIM_HOP_MILES: "90" + PATH_SIM_WEAK_PATHLOSS_DB: "136" + PATH_SIM_MODEL_BUCKET_HOURS: "8" + PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50} + PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1} + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + + path-sim-worker-06: + build: + context: . + dockerfile: Dockerfile.backend + restart: unless-stopped + command: ["node", "dist/workers/path-sim.js"] + cpus: "0.5" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} + NODE_ENV: production + UV_THREADPOOL_SIZE: "1" + PATH_SIM_VARIANT_ID: "v06" + PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000} + PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all} + PATH_SIM_WINDOW_DAYS: "60" + PATH_SIM_ENABLE_EARLY_STOP: "false" + PATH_SIM_MAX_STATES: "160000" + PATH_SIM_MAX_CANDIDATES: "18" + PATH_SIM_HOP_MILES: "65" + PATH_SIM_WEAK_PATHLOSS_DB: "133" + PATH_SIM_MODEL_BUCKET_HOURS: "6" + PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50} + PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1} + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + + path-sim-worker-07: + build: + context: . + dockerfile: Dockerfile.backend + restart: unless-stopped + command: ["node", "dist/workers/path-sim.js"] + cpus: "0.5" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} + NODE_ENV: production + UV_THREADPOOL_SIZE: "1" + PATH_SIM_VARIANT_ID: "v07" + PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000} + PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all} + PATH_SIM_WINDOW_DAYS: "60" + PATH_SIM_ENABLE_EARLY_STOP: "false" + PATH_SIM_MAX_STATES: "280000" + PATH_SIM_MAX_CANDIDATES: "32" + PATH_SIM_HOP_MILES: "95" + PATH_SIM_WEAK_PATHLOSS_DB: "137" + PATH_SIM_MODEL_BUCKET_HOURS: "4" + PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50} + PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1} + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + + path-sim-worker-08: + build: + context: . + dockerfile: Dockerfile.backend + restart: unless-stopped + command: ["node", "dist/workers/path-sim.js"] + cpus: "0.5" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} + NODE_ENV: production + UV_THREADPOOL_SIZE: "1" + PATH_SIM_VARIANT_ID: "v08" + PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000} + PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all} + PATH_SIM_WINDOW_DAYS: "60" + PATH_SIM_ENABLE_EARLY_STOP: "false" + PATH_SIM_MAX_STATES: "300000" + PATH_SIM_MAX_CANDIDATES: "36" + PATH_SIM_HOP_MILES: "100" + PATH_SIM_WEAK_PATHLOSS_DB: "138" + PATH_SIM_MODEL_BUCKET_HOURS: "8" + PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50} + PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1} + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + + path-sim-worker-09: + build: + context: . + dockerfile: Dockerfile.backend + restart: unless-stopped + command: ["node", "dist/workers/path-sim.js"] + cpus: "0.5" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} + NODE_ENV: production + UV_THREADPOOL_SIZE: "1" + PATH_SIM_VARIANT_ID: "v09" + PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000} + PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all} + PATH_SIM_WINDOW_DAYS: "60" + PATH_SIM_ENABLE_EARLY_STOP: "false" + PATH_SIM_MAX_STATES: "210000" + PATH_SIM_MAX_CANDIDATES: "26" + PATH_SIM_HOP_MILES: "75" + PATH_SIM_WEAK_PATHLOSS_DB: "134" + PATH_SIM_MODEL_BUCKET_HOURS: "3" + PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50} + PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1} + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + + path-sim-worker-10: + build: + context: . + dockerfile: Dockerfile.backend + restart: unless-stopped + command: ["node", "dist/workers/path-sim.js"] + cpus: "0.5" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} + NODE_ENV: production + UV_THREADPOOL_SIZE: "1" + PATH_SIM_VARIANT_ID: "v10" + PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000} + PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all} + PATH_SIM_WINDOW_DAYS: "60" + PATH_SIM_ENABLE_EARLY_STOP: "false" + PATH_SIM_MAX_STATES: "230000" + PATH_SIM_MAX_CANDIDATES: "22" + PATH_SIM_HOP_MILES: "82" + PATH_SIM_WEAK_PATHLOSS_DB: "135" + PATH_SIM_MODEL_BUCKET_HOURS: "12" + PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50} + PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1} + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + link-backfill-worker: build: context: . diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8e55dce..ca42874 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,7 +13,6 @@ import { useDashboardStats } from './hooks/useDashboardStats.js'; import { useLinkState } from './hooks/useLinkState.js'; import { usePacketPathOverlay } from './hooks/usePacketPathOverlay.js'; import { useAppMessageHandler } from './hooks/useAppMessageHandler.js'; -import { usePathLearningModel } from './hooks/usePathLearningModel.js'; import { getCurrentSite } from './config/site.js'; const DEFAULT_FILTERS: Filters = { @@ -22,7 +21,7 @@ const DEFAULT_FILTERS: Filters = { clientNodes: false, packetPaths: false, betaPaths: false, - betaPathThreshold: 0.5, + betaPathThreshold: 0.45, links: false, hexClashes: false, hexClashMaxHops: 3, @@ -38,7 +37,7 @@ export const App: React.FC = () => { const raw = localStorage.getItem(FILTERS_KEY); if (!raw) return DEFAULT_FILTERS; const parsed = JSON.parse(raw) as Partial; - return { ...DEFAULT_FILTERS, ...parsed }; + return { ...DEFAULT_FILTERS, ...parsed, betaPathThreshold: 0.45 }; } catch { return DEFAULT_FILTERS; } @@ -64,20 +63,7 @@ export const App: React.FC = () => { const { coverage, handleCoverageUpdate } = useCoverage(networkFilter); const stats = useDashboardStats(networkFilter); - const learningModelNetwork = import.meta.env['VITE_NETWORK'] === 'ukmesh' ? 'all' : site.network; - const learningModel = usePathLearningModel(learningModelNetwork); - - useEffect(() => { - if (!learningModel) return; - setFilters((current) => { - const tuned = Math.min(0.9, Math.max(0.25, learningModel.recommendedThreshold)); - if (Math.abs(current.betaPathThreshold - tuned) < 0.01) return current; - return { ...current, betaPathThreshold: tuned }; - }); - }, [learningModel]); - const { - linkPairs, linkMetrics, viablePairsArr, applyInitialViablePairs, @@ -88,21 +74,21 @@ export const App: React.FC = () => { const { packetPath, betaPacketPath, + betaExtraPurplePaths, betaLowConfidencePath, + betaLowConfidenceSegments, betaCompletionPaths, betaPathConfidence, betaPermutationCount, + betaRemainingHops, pathOpacity, pinnedPacketId, handlePacketPin, } = usePacketPathOverlay({ packets, nodes, - coverage, - linkPairs, - linkMetrics, - learningModel, filters, + network: networkFilter, }); useEffect(() => { @@ -225,7 +211,9 @@ export const App: React.FC = () => { linkMetrics={linkMetrics} packetPath={packetPath} betaPath={betaPacketPath} + betaExtraPurplePaths={betaExtraPurplePaths} betaLowPath={betaLowConfidencePath} + betaLowSegments={betaLowConfidenceSegments} betaCompletionPaths={betaCompletionPaths} showBetaPaths={filters.betaPaths || pinnedPacketId !== null} pathOpacity={pathOpacity} @@ -237,6 +225,7 @@ export const App: React.FC = () => { onChange={setFilters} betaPathConfidence={betaPathConfidence} betaPermutationCount={betaPermutationCount} + betaRemainingHops={betaRemainingHops} /> {filters.livePackets && ( diff --git a/frontend/src/components/FilterPanel/FilterPanel.tsx b/frontend/src/components/FilterPanel/FilterPanel.tsx index 938357b..814e972 100644 --- a/frontend/src/components/FilterPanel/FilterPanel.tsx +++ b/frontend/src/components/FilterPanel/FilterPanel.tsx @@ -17,15 +17,18 @@ interface FilterPanelProps { onChange: (f: Filters) => void; betaPathConfidence?: number | null; betaPermutationCount?: number | null; + betaRemainingHops?: number | null; } export const LinksLegend: React.FC<{ compact?: boolean; muted?: boolean }> = ({ compact = false, muted = false }) => (
Links Legend
-
Good (≤120 dB)
-
Marginal (121-135 dB)
-
Weak (>135 dB)
-
Unknown (no dB yet)
+
+
Good (≤120 dB)
+
Marginal (121-135 dB)
+
Weak (>135 dB)
+
Unknown (no dB yet)
+
); @@ -39,7 +42,7 @@ export const FILTER_ROWS: Array<{ key: keyof Filters; label: string; color: stri { key: 'clientNodes', label: 'Companion / Room', color: '#ff9800' }, ]; -export const FilterPanel: React.FC = ({ filters, onChange, betaPathConfidence, betaPermutationCount }) => { +export const FilterPanel: React.FC = ({ filters, onChange, betaPathConfidence, betaPermutationCount, betaRemainingHops }) => { const toggle = (key: keyof Filters) => { onChange({ ...filters, [key]: !filters[key] }); }; @@ -52,6 +55,8 @@ export const FilterPanel: React.FC = ({ filters, onChange, bet Beta Confidence: {betaPathConfidence == null ? 'N/A' : `${Math.round(betaPathConfidence * 100)}%`}
Permutations: {betaPermutationCount == null ? 'N/A' : betaPermutationCount} +
+ Remaining Hops: {betaRemainingHops == null ? 'N/A' : betaRemainingHops} )} {FILTER_ROWS.map(({ key, label, color, hollow }) => ( @@ -80,22 +85,6 @@ export const FilterPanel: React.FC = ({ filters, onChange, bet style={filters[key] ? { background: `${color}22`, borderColor: color } : {}} /> - {key === 'betaPaths' && filters.betaPaths && ( -
e.stopPropagation()}> - - Confidence: {Math.round(filters.betaPathThreshold * 100)}% - - onChange({ ...filters, betaPathThreshold: Number(e.target.value) / 100 })} - /> -
- )} {key === 'hexClashes' && filters.hexClashes && (
e.stopPropagation()}> diff --git a/frontend/src/components/Map/MapView.tsx b/frontend/src/components/Map/MapView.tsx index ed3fdc2..53bf2d7 100644 --- a/frontend/src/components/Map/MapView.tsx +++ b/frontend/src/components/Map/MapView.tsx @@ -57,6 +57,10 @@ function hasCoords(node: MeshNode | null | undefined): node is MeshNode & { lat: return typeof node?.lat === 'number' && typeof node?.lon === 'number'; } +function isHiddenMapNode(node: MeshNode | null | undefined): boolean { + return Boolean(node?.name?.includes('🚫')); +} + // Raw outer rings from each coverage polygon — used for the green coverage display. // Using raw rings (not a union) with fillRule:'nonzero' means: // - overlapping viewsheds: winding numbers add (+1 per CCW ring) → always filled ✓ @@ -94,7 +98,9 @@ interface MapViewProps { linkMetrics: Map; packetPath: [number, number][] | null; betaPath: [number, number][] | null; + betaExtraPurplePaths: [number, number][][]; betaLowPath: [number, number][] | null; + betaLowSegments: [[number, number], [number, number]][]; betaCompletionPaths: [number, number][][]; showBetaPaths: boolean; pathOpacity: number; @@ -107,7 +113,7 @@ const DEFAULT_ZOOM = 11; export const MapView: React.FC = ({ nodes, arcs, activeNodes, coverage, showPackets, showCoverage, showClientNodes, - showLinks, showHexClashes, maxHexClashHops, viablePairsArr, linkMetrics, packetPath, betaPath, betaLowPath, betaCompletionPaths, showBetaPaths, pathOpacity, onMapReady, + showLinks, showHexClashes, maxHexClashHops, viablePairsArr, linkMetrics, packetPath, betaPath, betaExtraPurplePaths, betaLowPath, betaCompletionPaths, showBetaPaths, pathOpacity, onMapReady, }) => { const [map, setMap] = useState(null); const [focusedPrefix, setFocusedPrefix] = useState(null); @@ -160,6 +166,8 @@ export const MapView: React.FC = ({ // Refs to Leaflet Polyline instances for direct SVG attribute animation const regularPathRef = useRef(null); + const betaLowPathRef = useRef(null); + const betaPathRef = useRef(null); const aniFrameRef = useRef(null); // Animate marching dashes by incrementing stroke-dashoffset directly on the @@ -167,9 +175,10 @@ export const MapView: React.FC = ({ // calls _updateStyle (setAttribute) on every prop change, which can interrupt // CSS keyframe animations. Direct DOM manipulation in an rAF loop is stable. const hasRegular = !!packetPath; + const hasBeta = Boolean(showBetaPaths && (betaLowPath || betaPath || betaExtraPurplePaths.length > 0)); useEffect(() => { - if (!hasRegular) { + if (!hasRegular && !hasBeta) { if (aniFrameRef.current !== null) { cancelAnimationFrame(aniFrameRef.current); aniFrameRef.current = null; @@ -184,7 +193,13 @@ export const MapView: React.FC = ({ const val = String(-offset); // eslint-disable-next-line @typescript-eslint/no-explicit-any const rp = (regularPathRef.current as any)?._path as SVGPathElement | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bl = (betaLowPathRef.current as any)?._path as SVGPathElement | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bp = (betaPathRef.current as any)?._path as SVGPathElement | null; if (hasRegular && rp) rp.setAttribute('stroke-dashoffset', val); + if (hasBeta && bl) bl.setAttribute('stroke-dashoffset', val); + if (hasBeta && bp) bp.setAttribute('stroke-dashoffset', val); aniFrameRef.current = requestAnimationFrame(tick); }; @@ -195,13 +210,12 @@ export const MapView: React.FC = ({ aniFrameRef.current = null; } }; - }, [hasRegular]); // eslint-disable-line react-hooks/exhaustive-deps + }, [hasRegular, hasBeta]); // eslint-disable-line react-hooks/exhaustive-deps const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000; const allNodesWithPos = useMemo(() => Array.from(nodes.values()).filter( (n) => hasCoords(n) && (Date.now() - new Date(n.last_seen).getTime()) < FOURTEEN_DAYS_MS - && !n.name?.includes('🚫') ), [nodes]); // eslint-disable-line react-hooks/exhaustive-deps const nodesWithPos = useMemo(() => allNodesWithPos.filter((n) => n.role === undefined || n.role === 2), [allNodesWithPos]); const clientNodesArr = useMemo(() => allNodesWithPos.filter((n) => n.role === 1 || n.role === 3), [allNodesWithPos]); @@ -437,8 +451,6 @@ export const MapView: React.FC = ({ && hasCoords(b) && (Date.now() - new Date(a.last_seen).getTime()) < FOURTEEN_DAYS_MS && (Date.now() - new Date(b.last_seen).getTime()) < FOURTEEN_DAYS_MS - && !a.name?.includes('🚫') - && !b.name?.includes('🚫') && (a.role === undefined || a.role === 2) && (b.role === undefined || b.role === 2) ) { @@ -560,6 +572,7 @@ export const MapView: React.FC = ({ {/* Repeater markers — Leaflet default marker pane at zIndex 600 */} {nodesWithPos.map((node) => { + if (isHiddenMapNode(node)) return null; if (showHexClashes && !clashVisibleNodeIds.has(node.node_id)) return null; const isFocusVisible = clashVisibleNodeIds.has(node.node_id) || (focusedPrefixNodeIds?.has(node.node_id) ?? false); if (focusedPrefixNodeIds && focusHidePhase === 'hide' && !isFocusVisible) return null; @@ -582,6 +595,7 @@ export const MapView: React.FC = ({ {/* Companion radio + room server markers (toggled via filter) */} {showClientNodes && !showHexClashes && clientNodesArr.map((node) => { + if (isHiddenMapNode(node)) return null; const isFocusVisible = clashVisibleNodeIds.has(node.node_id); if (focusedPrefixNodeIds && focusHidePhase === 'hide' && !isFocusVisible) return null; return ( @@ -610,25 +624,14 @@ export const MapView: React.FC = ({ /> )} - {/* Beta path — coverage-validated, unambiguous hop resolution */} - {showBetaPaths && betaPath && ( - - )} - + {/* Beta path — uncertain (red) drawn first so confident (purple) always renders on top */} {showBetaPaths && betaLowPath && ( = ({ /> )} + {/* Purple confident portion rendered last — highest z-order so it is never obscured by the red uncertain portion */} + {showBetaPaths && betaPath && ( + + )} + + {showBetaPaths && betaExtraPurplePaths.map((path, idx) => ( + + ))} + {showBetaPaths && betaCompletionPaths.length > 0 && ( {betaCompletionPaths.map((path, idx) => ( @@ -644,9 +675,9 @@ export const MapView: React.FC = ({ positions={path} pathOptions={{ color: '#ef4444', - weight: 1.2, - dashArray: '3 9', - opacity: Math.min(0.45, pathOpacity * 0.6), + weight: 1.8, + dashArray: '4 7', + opacity: Math.min(0.78, pathOpacity * 0.95), }} interactive={false} /> diff --git a/frontend/src/components/app/MobileControls.tsx b/frontend/src/components/app/MobileControls.tsx index 2b129db..12450ed 100644 --- a/frontend/src/components/app/MobileControls.tsx +++ b/frontend/src/components/app/MobileControls.tsx @@ -17,50 +17,62 @@ export const MobileControls: React.FC = ({ filters, onFiltersChange, }) => { + const [showFilters, setShowFilters] = useState(false); const [showLegend, setShowLegend] = useState(false); return (
-
- {FILTER_ROWS.map(({ key, label, color, hollow }) => ( -
onFiltersChange({ ...filters, [key]: !filters[key] })} - role="button" - aria-pressed={!!filters[key]} - > - - {hollow ? ( - - ) : ( - - )} - {label} + +
+
+ {FILTER_ROWS.map(({ key, label, color, hollow }) => ( +
onFiltersChange({ ...filters, [key]: !filters[key] })} + role="button" + aria-pressed={!!filters[key]} + > + + {hollow ? ( + + ) : ( + + )} + {label} + + +
+ ))} +
+ {filters.hexClashes && ( +
+ + Hex clash hops: {Math.round(filters.hexClashMaxHops)} - onFiltersChange({ ...filters, hexClashMaxHops: Number(e.target.value) })} />
- ))} + )}
- {filters.hexClashes && ( -
- - Hex clash hops: {Math.round(filters.hexClashMaxHops)} - - onFiltersChange({ ...filters, hexClashMaxHops: Number(e.target.value) })} - /> -
- )}