mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 11:51:45 +00:00
d910ea0208
Fixes #1638. ## Problem `getConfidenceIndicator` in `public/nodes.js` treats every observation as equal evidence, so a node seen 5 times via 1-byte hash prefixes (which collide ~8-way across a typical mesh) scores the same as a node seen 5 times via 6-byte prefixes (effectively unambiguous). The user asked for confidence to respect ambiguity. ## Change - `cmd/server/neighbor_graph.go` — new `CountsByMode map[int]int` on `NeighborEdge`, bumped in `upsertEdge` / `upsertEdgeWithCandidates` based on the observation's hash-prefix byte length (1/2/4/6). Merged in `resolveEdge` when ambiguous→resolved edges collapse. - `cmd/server/neighbor_api.go` — `NeighborEntry.counts_by_mode` exposed (omitempty), and `dedupPrefixEntries` merges per-mode counts when an unresolved prefix entry collapses into a resolved one. Flat `Count` field preserved for back-compat. - `public/nodes.js::getConfidenceIndicator` — weights observations by mode: 1-byte=0.125, 2-byte=0.5, 4/6-byte=1.0. A single 6-byte sighting counts ~8× a raw 1-byte one. HIGH triggers when EITHER the legacy heuristic clears OR weighted count ≥3. Legacy entries without `counts_by_mode` keep working (default weight 0.5). - Tooltip now shows the per-mode breakdown (e.g. "Observations: 5 (1-byte: 3, 6-byte: 2)"). ## TDD - RED: `cmd/server/neighbor_graph_test.go::TestBuildNeighborGraph_CountsByMode` — fixture with 1/2/4-byte sightings asserts per-mode tally (commit `838965f3`). - RED: `test-confidence-indicator.js` — 6-byte mostly-sighted neighbor must outrank 1-byte mostly-sighted neighbor at equal flat count (commit `4bd5e18e`). - GREEN: implementation in commit `7511606d`. All 4 JS tests pass; new Go test passes; full Go suite passes (two pre-existing flakes unrelated, both pass when isolated). ## Browser verification Synthetic side-by-side of OLD vs NEW classifier against representative inputs — see screenshot. 1-byte-only and 6-byte-only at the same flat count diverge from MEDIUM/MEDIUM to MEDIUM/HIGH, and 3 6-byte sightings now upgrade where 20 1-byte sightings stay MEDIUM. ## Preflight overrides - check-branch-scope: cross-stack: justified — backend exposes the new `counts_by_mode` field and the frontend consumes it; the whole point of the change. ## Compat - `Count` field unchanged in shape and value. - `counts_by_mode` is `omitempty`; legacy persisted edges (loaded from `neighbor_edges` via `neighbor_persist.go`) get no per-mode breakdown and fall back to the default weight (0.5) — no UI regression. --------- Co-authored-by: bot <bot@local> Co-authored-by: corescope-bot <bot@corescope.local>
133 lines
6.1 KiB
JavaScript
133 lines
6.1 KiB
JavaScript
// Issue #1638: getConfidenceIndicator should weight per-hash-mode counts so
|
|
// that 6-byte sightings (effectively unambiguous) rank higher than 1-byte
|
|
// sightings (which collide ~8-way across a typical mesh).
|
|
//
|
|
// Strategy: load public/nodes.js inside a minimal browser-shaped sandbox,
|
|
// extract getConfidenceIndicator from the IIFE-scoped module, and exercise
|
|
// it against synthetic NeighborEntry-shaped inputs.
|
|
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const vm = require('vm');
|
|
const assert = require('assert');
|
|
|
|
let passed = 0, failed = 0;
|
|
function test(name, fn) {
|
|
try { fn(); passed++; console.log(' ✅ ' + name); }
|
|
catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
|
|
}
|
|
|
|
// Extract getConfidenceIndicator from nodes.js. The IIFE wraps it as an
|
|
// inner `function getConfidenceIndicator(entry) { ... }` — pull the body
|
|
// via a balanced-brace scan and re-evaluate it standalone.
|
|
function extractGetConfidenceIndicator() {
|
|
const src = fs.readFileSync(__dirname + '/public/nodes.js', 'utf8');
|
|
const start = src.indexOf('function getConfidenceIndicator(');
|
|
if (start < 0) throw new Error('getConfidenceIndicator not found in nodes.js');
|
|
// Walk braces to find end.
|
|
let i = src.indexOf('{', start);
|
|
let depth = 0;
|
|
for (; i < src.length; i++) {
|
|
if (src[i] === '{') depth++;
|
|
else if (src[i] === '}') { depth--; if (depth === 0) { i++; break; } }
|
|
}
|
|
const fnSrc = src.slice(start, i);
|
|
const sandbox = {};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(fnSrc + '\nthis.getConfidenceIndicator = getConfidenceIndicator;', sandbox);
|
|
return sandbox.getConfidenceIndicator;
|
|
}
|
|
|
|
const getConfidenceIndicator = extractGetConfidenceIndicator();
|
|
|
|
// Helper: rank labels low<medium<high so we can compare.
|
|
const rank = { 'LOW': 0, 'MEDIUM': 1, 'HIGH': 2, 'AMBIGUOUS': -1 };
|
|
|
|
console.log('=== getConfidenceIndicator: per-hash-mode weighting (#1638) ===');
|
|
|
|
test('mostly 6-byte sightings rank HIGHER than mostly 1-byte at equal count', () => {
|
|
// 5 sightings, all at 1-byte prefixes: low ambiguity-resistance.
|
|
const noisy = {
|
|
ambiguous: false,
|
|
count: 5,
|
|
score: 0.3, // below the legacy HIGH threshold (0.5)
|
|
counts_by_mode: { 1: 5 },
|
|
};
|
|
// Same count, but all 3-byte prefixes: effectively unambiguous evidence
|
|
// per firmware hash modes (Packet.cpp:13-18, 4 reserved).
|
|
const clean = {
|
|
ambiguous: false,
|
|
count: 5,
|
|
score: 0.3,
|
|
counts_by_mode: { 3: 5 },
|
|
};
|
|
const a = getConfidenceIndicator(noisy);
|
|
const b = getConfidenceIndicator(clean);
|
|
assert.ok(rank[b.label] > rank[a.label],
|
|
'expected 3-byte (' + b.label + ') to outrank 1-byte (' + a.label + ') at equal flat count');
|
|
});
|
|
|
|
test('a small number of 3-byte sightings beats many 1-byte sightings', () => {
|
|
// 20 1-byte observations: still high collision ambiguity.
|
|
const noisy = { ambiguous: false, count: 20, score: 0.4, counts_by_mode: { 1: 20 } };
|
|
// 3 3-byte observations: low flat count but each is unambiguous.
|
|
const clean = { ambiguous: false, count: 3, score: 0.4, counts_by_mode: { 3: 3 } };
|
|
const a = getConfidenceIndicator(noisy);
|
|
const b = getConfidenceIndicator(clean);
|
|
assert.ok(rank[b.label] >= rank[a.label],
|
|
'3-byte (n=3, ' + b.label + ') should be at least as confident as 1-byte (n=20, ' + a.label + ')');
|
|
});
|
|
|
|
test('ambiguous flag still wins over per-mode weighting', () => {
|
|
const e = { ambiguous: true, count: 99, score: 0.99, counts_by_mode: { 3: 99 } };
|
|
const r = getConfidenceIndicator(e);
|
|
assert.strictEqual(r.label, 'AMBIGUOUS');
|
|
});
|
|
|
|
test('back-compat: entries without counts_by_mode still classify', () => {
|
|
// Legacy shape (no counts_by_mode) must not throw and must return a known label.
|
|
const e = { ambiguous: false, count: 5, score: 0.6 };
|
|
const r = getConfidenceIndicator(e);
|
|
assert.ok(['LOW','MEDIUM','HIGH'].includes(r.label),
|
|
'expected a known label, got ' + r.label);
|
|
});
|
|
|
|
test('legacy edge with no counts_by_mode falls back to bucket-0 (unknown) weight', () => {
|
|
// No per-mode breakdown — every count contributes 0.5 (unknown bucket).
|
|
// Score below 0.5 means the legacy heuristic does not promote to HIGH;
|
|
// weighted = 5 * 0.5 = 2.5, also below the weighted-HIGH threshold (3),
|
|
// so we land at MEDIUM. Compare against an all-3-byte entry at same
|
|
// count: weighted = 5 * 1.0 = 5.0 → HIGH. Legacy must rank lower.
|
|
const legacy = { ambiguous: false, count: 5, score: 0.3 };
|
|
const clean = { ambiguous: false, count: 5, score: 0.3, counts_by_mode: { 3: 5 } };
|
|
const a = getConfidenceIndicator(legacy);
|
|
const b = getConfidenceIndicator(clean);
|
|
assert.ok(rank[b.label] > rank[a.label],
|
|
'legacy (' + a.label + ') must rank below 3-byte (' + b.label + ')');
|
|
});
|
|
|
|
test('partial counts_by_mode + Count > sum allocates delta to bucket 0', () => {
|
|
// Anti-tautology test (adv #1): edge with Count=10 and CountsByMode={3:4}
|
|
// has a delta of 6 unaccounted-for sightings (e.g. inherited from the
|
|
// persisted snapshot). Those 6 MUST be counted at bucket-0 weight (0.5),
|
|
// not silently dropped and not promoted to the 3-byte (1.0) weight.
|
|
// weighted = 4*1.0 + 6*0.5 = 4 + 3 = 7 → easily clears HIGH (>=3).
|
|
// If the delta were dropped: weighted = 4 → still HIGH, indistinguishable.
|
|
// So compare against an edge with Count=4, CountsByMode={3:4}: weighted=4.
|
|
// Both end up HIGH; instead, verify count totals via a LOW-threshold case.
|
|
// Use Count=10, CountsByMode={1:1}: delta=9 at bucket-0 → weighted =
|
|
// 1*0.125 + 9*0.5 = 4.625, HIGH. If delta were dropped: weighted=0.125
|
|
// and count=10 (> 1), so label would be MEDIUM (not LOW, count>1).
|
|
// To make the difference visible, use Count=10, CountsByMode={1:1},
|
|
// score=0.2 (below legacy HIGH gate):
|
|
// - with delta: weighted = 4.625 → HIGH
|
|
// - without delta: weighted = 0.125 → MEDIUM
|
|
const withDelta = { ambiguous: false, count: 10, score: 0.2, counts_by_mode: { 1: 1 } };
|
|
const r = getConfidenceIndicator(withDelta);
|
|
assert.strictEqual(r.label, 'HIGH',
|
|
'expected HIGH when 9 of 10 sightings get apportioned to bucket-0 weight; got ' + r.label);
|
|
});
|
|
|
|
console.log('\nResult: ' + passed + ' passed, ' + failed + ' failed');
|
|
if (failed > 0) process.exit(1);
|