Files
meshcore-analyzer/test-confidence-indicator.js
T
Kpa-clawbot d910ea0208 feat(#1638): confidence rating weighted by hash mode (#1687)
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>
2026-06-12 11:38:43 -07:00

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);