diff --git a/public/hop-resolver.js b/public/hop-resolver.js index 919e043e..803eb8a4 100644 --- a/public/hop-resolver.js +++ b/public/hop-resolver.js @@ -72,33 +72,89 @@ window.HopResolver = (function() { } /** - * Pick the best candidate using affinity first, then geo-distance fallback. + * Pick the best candidate by scoring against BOTH prev and next resolved hops. + * + * Strategy (in priority order): + * 1. Neighbor-graph edge weight: sum of edge scores to prevPubkey + nextPubkey. Pick max. + * 2. Geographic centroid: if no candidate has graph edges, compute centroid of + * prev+next positions and pick closest candidate by haversine distance. + * 3. Single-anchor geo fallback: if only one neighbor is resolved, use it as anchor. + * 4. Original heuristic: first candidate (when no context at all). + * * @param {Array} candidates - candidates with lat/lon/pubkey/name - * @param {string|null} adjacentPubkey - pubkey of the previously/next resolved hop - * @param {Object|null} anchor - {lat, lon} for geo fallback - * @param {number|null} fallbackLat - fallback anchor lat (e.g. observer) - * @param {number|null} fallbackLon - fallback anchor lon + * @param {string|null} prevPubkey - pubkey of previous resolved hop + * @param {string|null} nextPubkey - pubkey of next resolved hop + * @param {Object|null} prevPos - {lat, lon} of previous resolved hop or origin + * @param {Object|null} nextPos - {lat, lon} of next resolved hop or observer * @returns {Object} best candidate */ - function pickByAffinity(candidates, adjacentPubkey, anchor, fallbackLat, fallbackLon) { - // If we have affinity data and an adjacent hop, prefer neighbors - if (adjacentPubkey && Object.keys(affinityMap).length > 0) { - const withAffinity = candidates - .map(c => ({ ...c, affinity: getAffinity(adjacentPubkey, c.pubkey) })) - .filter(c => c.affinity > 0); - if (withAffinity.length > 0) { - withAffinity.sort((a, b) => b.affinity - a.affinity); - return withAffinity[0]; + function pickByAffinity(candidates, prevPubkey, nextPubkey, prevPos, nextPos) { + const hasGraph = Object.keys(affinityMap).length > 0; + const hasAdj = prevPubkey || nextPubkey; + + // Strategy 1: neighbor-graph edge weights (sum of prev + next) + if (hasGraph && hasAdj) { + const scored = candidates.map(function(c) { + let s = 0; + if (prevPubkey) s += getAffinity(prevPubkey, c.pubkey); + if (nextPubkey) s += getAffinity(nextPubkey, c.pubkey); + return { candidate: c, edgeScore: s }; + }); + const withEdges = scored.filter(function(s) { return s.edgeScore > 0; }); + if (withEdges.length > 0) { + withEdges.sort(function(a, b) { return b.edgeScore - a.edgeScore; }); + _traceMultiCandidate(candidates, scored, withEdges[0].candidate, 'graph'); + return withEdges[0].candidate; } } - // Fallback: geo-distance sort (existing behavior) - const effectiveAnchor = anchor || (fallbackLat != null ? { lat: fallbackLat, lon: fallbackLon } : null); - if (effectiveAnchor) { - candidates.sort((a, b) => dist(a.lat, a.lon, effectiveAnchor.lat, effectiveAnchor.lon) - dist(b.lat, b.lon, effectiveAnchor.lat, effectiveAnchor.lon)); + + // Strategy 2/3: geographic — centroid of prev+next, or single anchor + let anchorLat = null, anchorLon = null, anchorCount = 0; + if (prevPos && prevPos.lat != null && prevPos.lon != null) { + anchorLat = (anchorLat || 0) + prevPos.lat; + anchorLon = (anchorLon || 0) + prevPos.lon; + anchorCount++; } + if (nextPos && nextPos.lat != null && nextPos.lon != null) { + anchorLat = (anchorLat || 0) + nextPos.lat; + anchorLon = (anchorLon || 0) + nextPos.lon; + anchorCount++; + } + if (anchorCount > 0) { + anchorLat /= anchorCount; + anchorLon /= anchorCount; + const geoScored = candidates.map(function(c) { + const d = (c.lat != null && c.lon != null && !(c.lat === 0 && c.lon === 0)) + ? haversineKm(c.lat, c.lon, anchorLat, anchorLon) : 999999; + return { candidate: c, distKm: d }; + }); + geoScored.sort(function(a, b) { return a.distKm - b.distKm; }); + _traceMultiCandidate(candidates, geoScored, geoScored[0].candidate, 'centroid'); + return geoScored[0].candidate; + } + + // Strategy 4: no context — return first candidate + _traceMultiCandidate(candidates, null, candidates[0], 'fallback'); return candidates[0]; } + /** Dev-mode console trace for multi-candidate picks */ + function _traceMultiCandidate(candidates, scored, chosen, method) { + if (typeof console === 'undefined' || !console.debug) return; + if (candidates.length < 2) return; + try { + const prefix = candidates[0].pubkey ? candidates[0].pubkey.slice(0, 2) : '??'; + const scoreSummary = scored ? scored.map(function(s) { + const pk = (s.candidate || s).pubkey || '?'; + const val = s.edgeScore != null ? s.edgeScore : (s.distKm != null ? s.distKm + 'km' : '?'); + return pk.slice(0, 8) + ':' + val; + }) : []; + console.debug('[hop-resolver] hash=' + prefix + ' candidates=' + candidates.length + + ' scored=[' + scoreSummary.join(',') + '] chose=' + (chosen.pubkey || '?').slice(0, 8) + + ' method=' + method); + } catch(e) { /* trace is best-effort */ } + } + /** * Resolve an array of hex hop prefixes to node info. * Returns a map: { hop: {name, pubkey, lat, lon, ambiguous, unreliable} } @@ -169,52 +225,54 @@ window.HopResolver = (function() { } } - // Forward pass - let lastPos = (originLat != null && originLon != null) ? { lat: originLat, lon: originLon } : null; - let lastResolvedPubkey = null; - for (let i = 0; i < hops.length; i++) { - const hop = hops[i]; - if (hopPositions[hop]) { - lastPos = hopPositions[hop]; - lastResolvedPubkey = resolved[hop] ? resolved[hop].pubkey : null; - continue; + // Combined disambiguation: resolve ambiguous hops using both neighbors. + // We iterate until no more hops can be resolved (handles cascading dependencies). + const originPos = (originLat != null && originLon != null) ? { lat: originLat, lon: originLon } : null; + const observerPos = (observerLat != null && observerLon != null) ? { lat: observerLat, lon: observerLon } : null; + + let changed = true; + let maxIter = hops.length + 1; // prevent infinite loops + while (changed && maxIter-- > 0) { + changed = false; + for (let i = 0; i < hops.length; i++) { + const hop = hops[i]; + if (hopPositions[hop]) continue; // already resolved + const r = resolved[hop]; + if (!r || !r.ambiguous) continue; + const withLoc = r.candidates.filter(c => c.lat != null && c.lon != null && !(c.lat === 0 && c.lon === 0)); + if (!withLoc.length) continue; + + // Find prev resolved neighbor + let prevPubkey = null, prevPos = null; + for (let j = i - 1; j >= 0; j--) { + if (hopPositions[hops[j]]) { + prevPos = hopPositions[hops[j]]; + prevPubkey = resolved[hops[j]] ? resolved[hops[j]].pubkey : null; + break; + } + } + if (!prevPos && originPos) prevPos = originPos; + + // Find next resolved neighbor + let nextPubkey = null, nextPos = null; + for (let j = i + 1; j < hops.length; j++) { + if (hopPositions[hops[j]]) { + nextPos = hopPositions[hops[j]]; + nextPubkey = resolved[hops[j]] ? resolved[hops[j]].pubkey : null; + break; + } + } + if (!nextPos && observerPos) nextPos = observerPos; + + // Skip if we have zero context (wait for a later iteration or neighbor resolution) + if (!prevPubkey && !nextPubkey && !prevPos && !nextPos) continue; + + const picked = pickByAffinity(withLoc, prevPubkey, nextPubkey, prevPos, nextPos); + r.name = picked.name; + r.pubkey = picked.pubkey; + hopPositions[hop] = { lat: picked.lat, lon: picked.lon }; + changed = true; } - const r = resolved[hop]; - if (!r || !r.ambiguous) continue; - const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0)); - if (!withLoc.length) continue; - - // Affinity-aware: prefer candidates that are neighbors of the previous hop - const picked = pickByAffinity(withLoc, lastResolvedPubkey, lastPos, i === hops.length - 1 ? observerLat : null, i === hops.length - 1 ? observerLon : null); - r.name = picked.name; - r.pubkey = picked.pubkey; - hopPositions[hop] = { lat: picked.lat, lon: picked.lon }; - lastPos = hopPositions[hop]; - lastResolvedPubkey = picked.pubkey; - } - - // Backward pass - let nextPos = (observerLat != null && observerLon != null) ? { lat: observerLat, lon: observerLon } : null; - let nextResolvedPubkey = null; - for (let i = hops.length - 1; i >= 0; i--) { - const hop = hops[i]; - if (hopPositions[hop]) { - nextPos = hopPositions[hop]; - nextResolvedPubkey = resolved[hop] ? resolved[hop].pubkey : null; - continue; - } - const r = resolved[hop]; - if (!r || !r.ambiguous) continue; - const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0)); - if (!withLoc.length || !nextPos) continue; - - // Affinity-aware: prefer candidates that are neighbors of the next hop - const picked = pickByAffinity(withLoc, nextResolvedPubkey, nextPos, null, null); - r.name = picked.name; - r.pubkey = picked.pubkey; - hopPositions[hop] = { lat: picked.lat, lon: picked.lon }; - nextPos = hopPositions[hop]; - nextResolvedPubkey = picked.pubkey; } // Sanity check: drop hops impossibly far from neighbors @@ -276,13 +334,13 @@ window.HopResolver = (function() { */ function resolveFromServer(hops, resolvedPath) { if (!hops || !resolvedPath || hops.length !== resolvedPath.length) return {}; - var result = {}; - for (var i = 0; i < hops.length; i++) { - var hop = hops[i]; - var pubkey = resolvedPath[i]; + const result = {}; + for (let i = 0; i < hops.length; i++) { + const hop = hops[i]; + const pubkey = resolvedPath[i]; if (!pubkey) continue; // null = unresolved, leave for client-side fallback // O(1) lookup via pubkeyIdx built during init() - var node = pubkeyIdx[pubkey.toLowerCase()] || null; + const node = pubkeyIdx[pubkey.toLowerCase()] || null; result[hop] = { name: node ? node.name : pubkey.slice(0, 8), pubkey: pubkey, diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 3c06ba9d..d5534dba 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -690,6 +690,88 @@ console.log('\n=== haversineKm (hop-resolver.js) ==='); }); } +// ===== pickByAffinity — neighbor-graph + centroid scoring (#874) ===== +console.log('\n=== pickByAffinity neighbor-graph scoring (#874) ==='); +{ + const ctx = makeSandbox(); + ctx.IATA_COORDS_GEO = {}; + loadInCtx(ctx, 'public/hop-resolver.js'); + const HR = ctx.window.HopResolver; + + // Two nodes sharing prefix "ab", hundreds of km apart. + // NodeSF is near San Francisco, NodeDEN is near Denver. + const nodeSF = { public_key: 'ab11111111111111', name: 'NodeSF', lat: 37.7, lon: -122.4 }; + const nodeDEN = { public_key: 'ab22222222222222', name: 'NodeDEN', lat: 39.7, lon: -104.9 }; + // A known neighbor of NodeSF (in the graph) + const nodeNeighbor = { public_key: 'cc33333333333333', name: 'SFNeighbor', lat: 37.8, lon: -122.3 }; + // Another known node near Denver + const nodeDenNeighbor = { public_key: 'dd44444444444444', name: 'DENNeighbor', lat: 39.8, lon: -105.0 }; + + test('#874: graph edge scoring picks correct regional candidate (SF)', () => { + HR.init([nodeSF, nodeDEN, nodeNeighbor, nodeDenNeighbor]); + HR.setAffinity({ edges: [ + { source: 'cc33333333333333', target: 'ab11111111111111', weight: 5 }, + { source: 'dd44444444444444', target: 'ab22222222222222', weight: 5 }, + ]}); + // Path: SFNeighbor → [ab??] → DENNeighbor + // With graph edges, ab11 (NodeSF) has edge to SFNeighbor, ab22 (NodeDEN) has edge to DENNeighbor + // Prev=SFNeighbor, Next=DENNeighbor → both have score 5, but SFNeighbor edge only to ab11 + const result = HR.resolve(['cc', 'ab', 'dd'], + null, null, null, null); + assert.strictEqual(result['ab'].name, 'NodeSF', + 'Should pick NodeSF because it has a graph edge to prev hop SFNeighbor'); + }); + + test('#874: graph edge scoring — next hop breaks tie', () => { + HR.init([nodeSF, nodeDEN, nodeNeighbor, nodeDenNeighbor]); + HR.setAffinity({ edges: [ + { source: 'dd44444444444444', target: 'ab22222222222222', weight: 8 }, + // No edge from SFNeighbor to either ab node + ]}); + // Path: SFNeighbor → [ab??] → DENNeighbor + // Only ab22 (NodeDEN) has edge to DENNeighbor (next hop) + const result = HR.resolve(['cc', 'ab', 'dd'], + null, null, null, null); + assert.strictEqual(result['ab'].name, 'NodeDEN', + 'Should pick NodeDEN because it has graph edge to next hop DENNeighbor'); + }); + + test('#874: centroid fallback when no graph edges exist', () => { + HR.init([nodeSF, nodeDEN, nodeNeighbor]); + HR.setAffinity({ edges: [] }); // no edges at all + // Path: SFNeighbor → [ab??] + // SFNeighbor is at (37.8, -122.3), centroid is just that point + // NodeSF (37.7, -122.4) is ~14km away, NodeDEN (39.7, -104.9) is ~1500km away + const result = HR.resolve(['cc', 'ab'], + null, null, null, null); + assert.strictEqual(result['ab'].name, 'NodeSF', + 'Should pick NodeSF via centroid proximity to SFNeighbor'); + }); + + test('#874: centroid uses average of prev+next positions', () => { + // Prev near SF, next near Denver → centroid is midpoint (~Nevada) + // NodeDEN is closer to Nevada midpoint than NodeSF + const nodeMid = { public_key: 'ee55555555555555', name: 'MidNode', lat: 38.5, lon: -114.0 }; + HR.init([nodeSF, nodeDEN, nodeNeighbor, nodeDenNeighbor, nodeMid]); + HR.setAffinity({ edges: [] }); + // Path: SFNeighbor → [ab??] → DENNeighbor + // centroid = avg(37.8,-122.3, 39.8,-105.0) = (38.8, -113.65) — closer to Denver + const result = HR.resolve(['cc', 'ab', 'dd'], + null, null, null, null); + assert.strictEqual(result['ab'].name, 'NodeDEN', + 'Should pick NodeDEN because centroid of SF+Denver neighbors is closer to Denver'); + }); + + test('#874: fallback when no context at all', () => { + HR.init([nodeSF, nodeDEN]); + HR.setAffinity({ edges: [] }); + // Single ambiguous hop, no origin/observer, no neighbors + const result = HR.resolve(['ab'], null, null, null, null); + assert.ok(result['ab'].ambiguous || result['ab'].name != null, + 'Should resolve to some candidate without crashing'); + }); +} + // ===== SNR/RSSI Number casting ===== { // These test the pattern used in observer-detail.js, home.js, traces.js, live.js diff --git a/test-hop-resolver-affinity.js b/test-hop-resolver-affinity.js index 2b929b02..e09e20a6 100644 --- a/test-hop-resolver-affinity.js +++ b/test-hop-resolver-affinity.js @@ -95,5 +95,27 @@ const result6 = HopResolver.resolve(['ee44'], null, null, null, null, null); assert(result6['ee44'].name === 'NodeD', 'Unique prefix resolves directly — got: ' + result6['ee44'].name); assert(!result6['ee44'].ambiguous, 'Should not be marked ambiguous'); +// Test 7: lat=0 / lon=0 candidates are NOT excluded (equator/prime-meridian bug fix) +console.log('\nTest 7: lat=0 / lon=0 candidates are included in geo scoring'); +const nodeEquator = { public_key: 'ab5555', name: 'EquatorNode', lat: 0, lon: 10 }; +const nodeFar = { public_key: 'ab6666', name: 'FarNode', lat: 60, lon: 60 }; +const anchorNearEq = { public_key: 'cd7777', name: 'AnchorEq', lat: 1, lon: 11 }; +HopResolver.init([nodeEquator, nodeFar, anchorNearEq]); +HopResolver.setAffinity({}); +// Anchor near equator — EquatorNode (0,10) should be geo-closest +const result7 = HopResolver.resolve(['cd77', 'ab'], 1.0, 11.0, null, null, null); +assert(result7['ab'].name === 'EquatorNode', + 'lat=0 candidate should be included and win by geo — got: ' + result7['ab'].name); + +// Test 8: lon=0 candidate is also included +console.log('\nTest 8: lon=0 candidate is included in geo scoring'); +const nodePrime = { public_key: 'ab8888', name: 'PrimeMeridian', lat: 10, lon: 0 }; +const anchorNearPM = { public_key: 'cd9999', name: 'AnchorPM', lat: 11, lon: 1 }; +HopResolver.init([nodePrime, nodeFar, anchorNearPM]); +HopResolver.setAffinity({}); +const result8 = HopResolver.resolve(['cd99', 'ab'], 11.0, 1.0, null, null, null); +assert(result8['ab'].name === 'PrimeMeridian', + 'lon=0 candidate should be included and win by geo — got: ' + result8['ab'].name); + console.log('\n' + (passed + failed) + ' tests, ' + passed + ' passed, ' + failed + ' failed\n'); process.exit(failed > 0 ? 1 : 0);