diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 21ee8b4f..1fbe2e40 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -97,6 +97,7 @@ jobs: set -e node test-packet-filter.js node test-packet-filter-time.js + node test-confidence-indicator.js node test-channels-merge-1498-unit.js node test-issue-1518-home-url.js node test-channel-decrypt-insecure-context.js diff --git a/cmd/server/neighbor_api.go b/cmd/server/neighbor_api.go index 9570e087..57960a3a 100644 --- a/cmd/server/neighbor_api.go +++ b/cmd/server/neighbor_api.go @@ -26,6 +26,10 @@ type NeighborEntry struct { Name *string `json:"name"` Role *string `json:"role"` Count int `json:"count"` + // CountsByMode breaks Count down by observation hash-prefix mode in bytes + // (1, 2, 4, 6). Lets the frontend weight confidence by ambiguity rather + // than treating every sighting as equal evidence. Issue #1638. + CountsByMode map[int]int `json:"counts_by_mode,omitempty"` Score float64 `json:"score"` FirstSeen string `json:"first_seen"` LastSeen string `json:"last_seen"` @@ -160,13 +164,14 @@ func (s *Server) handleNodeNeighbors(w http.ResponseWriter, r *http.Request) { } entry := NeighborEntry{ - Prefix: e.Prefix, - Count: e.Count, - Score: score, - FirstSeen: e.FirstSeen.UTC().Format(time.RFC3339), - LastSeen: e.LastSeen.UTC().Format(time.RFC3339), - Ambiguous: e.Ambiguous, - Observers: observerList(e.Observers), + Prefix: e.Prefix, + Count: e.Count, + CountsByMode: copyCountsByMode(e.CountsByMode), + Score: score, + FirstSeen: e.FirstSeen.UTC().Format(time.RFC3339), + LastSeen: e.LastSeen.UTC().Format(time.RFC3339), + Ambiguous: e.Ambiguous, + Observers: observerList(e.Observers), } if e.SNRCount > 0 { @@ -420,6 +425,20 @@ func (s *Server) computeNeighborGraphResponse(minCount int, minScore float64, re // ─── Helpers ─────────────────────────────────────────────────────────────────── +// copyCountsByMode returns a shallow copy of the per-mode count map so the +// API response doesn't share state with the live in-memory edge. Returns +// nil for empty/nil input so omitempty drops the field from legacy payloads. +func copyCountsByMode(m map[int]int) map[int]int { + if len(m) == 0 { + return nil + } + out := make(map[int]int, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + func observerList(m map[string]bool) []string { if len(m) == 0 { return []string{} @@ -508,6 +527,14 @@ func dedupPrefixEntries(entries []NeighborEntry) []NeighborEntry { // Merge counts from unresolved into resolved. entries[j].Count += entries[i].Count + if entries[i].CountsByMode != nil { + if entries[j].CountsByMode == nil { + entries[j].CountsByMode = make(map[int]int) + } + for m, c := range entries[i].CountsByMode { + entries[j].CountsByMode[m] += c + } + } // Preserve higher LastSeen. if entries[i].LastSeen > entries[j].LastSeen { diff --git a/cmd/server/neighbor_graph.go b/cmd/server/neighbor_graph.go index 452fe7d9..5e7ea5eb 100644 --- a/cmd/server/neighbor_graph.go +++ b/cmd/server/neighbor_graph.go @@ -62,6 +62,16 @@ type NeighborEdge struct { Ambiguous bool // multiple candidates or zero candidates Candidates []string // candidate pubkeys when ambiguous Resolved bool // true if auto-resolved via Jaccard + // CountsByMode tallies sightings broken down by hash-prefix mode in bytes + // (1, 2, or 3). Firmware path-byte encoding (Packet.cpp:13-18) sets + // hash_size = (pathByte>>6)+1 with values 1/2/3 valid and 4 reserved. + // 1-byte prefixes collide ~8-way across a typical mesh; 3-byte are + // effectively unambiguous. Bucket 0 is the legacy/unknown bucket used + // for edges loaded from the persisted neighbor_edges snapshot (which + // stores only the flat Count). Sum of values == Count by construction. + // Issue #1638 — lets the frontend weight confidence by ambiguity rather + // than treating every observation as equal evidence. + CountsByMode map[int]int } // Score computes the affinity score at query time with time decay. @@ -106,6 +116,26 @@ func (e *NeighborEdge) AvgSNR() float64 { return e.SNRSum / float64(e.SNRCount) } +// incCountsByMode bumps the per-hash-mode tally on the edge based on the +// observed prefix length (hex chars / 2 = bytes). Per firmware +// firmware/src/Packet.cpp:13-18 (hash_size = (pathByte>>6)+1), valid wire +// modes are 1, 2 or 3 bytes; hash_size==4 is reserved. Anything outside +// 1/2/3 falls into the legacy/unknown bucket (0) so we don't lose the +// observation entirely. Issue #1638. +func incCountsByMode(e *NeighborEdge, prefix string) { + if e.CountsByMode == nil { + e.CountsByMode = make(map[int]int) + } + bytes := len(prefix) / 2 + switch bytes { + case 1, 2, 3: + // known firmware hash mode + default: + bytes = 0 + } + e.CountsByMode[bytes]++ +} + // ─── NeighborGraph ───────────────────────────────────────────────────────────── // NeighborGraph is a cached, in-memory first-hop neighbor affinity graph. @@ -358,12 +388,13 @@ func (g *NeighborGraph) upsertEdge(pubkeyA, pubkeyB, prefix, observer string, sn e, exists := g.edges[key] if !exists { e = &NeighborEdge{ - NodeA: key.A, - NodeB: key.B, - Prefix: prefix, - Observers: make(map[string]bool), - FirstSeen: ts, - LastSeen: ts, + NodeA: key.A, + NodeB: key.B, + Prefix: prefix, + Observers: make(map[string]bool), + FirstSeen: ts, + LastSeen: ts, + CountsByMode: make(map[int]int), } g.edges[key] = e g.byNode[key.A] = append(g.byNode[key.A], e) @@ -371,6 +402,7 @@ func (g *NeighborGraph) upsertEdge(pubkeyA, pubkeyB, prefix, observer string, sn } e.Count++ + incCountsByMode(e, prefix) if ts.After(e.LastSeen) { e.LastSeen = ts } @@ -421,20 +453,22 @@ func (g *NeighborGraph) upsertEdgeWithCandidates(knownPK, prefix string, candida e, exists := g.edges[key] if !exists { e = &NeighborEdge{ - NodeA: key.A, - NodeB: "", - Prefix: prefix, - Observers: make(map[string]bool), - Ambiguous: true, - Candidates: filtered, - FirstSeen: ts, - LastSeen: ts, + NodeA: key.A, + NodeB: "", + Prefix: prefix, + Observers: make(map[string]bool), + Ambiguous: true, + Candidates: filtered, + FirstSeen: ts, + LastSeen: ts, + CountsByMode: make(map[int]int), } g.edges[key] = e g.byNode[knownPK] = append(g.byNode[knownPK], e) } e.Count++ + incCountsByMode(e, prefix) if ts.After(e.LastSeen) { e.LastSeen = ts } @@ -653,6 +687,12 @@ func (g *NeighborGraph) resolveEdge(oldKey edgeKey, e *NeighborEdge, knownNode, for obs := range e.Observers { existing.Observers[obs] = true } + if existing.CountsByMode == nil { + existing.CountsByMode = make(map[int]int) + } + for m, c := range e.CountsByMode { + existing.CountsByMode[m] += c + } return } diff --git a/cmd/server/neighbor_graph_test.go b/cmd/server/neighbor_graph_test.go index 7f7e2ac0..2bf999da 100644 --- a/cmd/server/neighbor_graph_test.go +++ b/cmd/server/neighbor_graph_test.go @@ -834,3 +834,63 @@ func BenchmarkBuildFromStore(b *testing.B) { BuildFromStore(store) } } + +// TestBuildNeighborGraph_CountsByMode (issue #1638): verify per-hash-mode +// edge counts are tracked separately from the flat Count, so the frontend +// confidence indicator can weight 3-byte (effectively unambiguous) sightings +// higher than 1-byte (high-collision) sightings. Modes track firmware-valid +// hash sizes 1/2/3 per Packet.cpp:13-18. +func TestBuildNeighborGraph_CountsByMode(t *testing.T) { + // Use a unique-bbbb-prefix R1 so 1/2/3-byte prefixes all resolve to it. + nodes := []nodeInfo{ + {Role: "repeater", PublicKey: "aaaa1111", Name: "NodeX"}, + {Role: "repeater", PublicKey: "bbbb2222", Name: "NodeR1"}, + {Role: "repeater", PublicKey: "cccc3333", Name: "Obs"}, + } + // Three ADVERTs from X observed at varying hash modes hitting R1. + txs := []*StoreTx{ + ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("cccc3333", `["bb"]`, nowStr, nil), // 1-byte + }), + ngMakeTx(2, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("cccc3333", `["bbbb"]`, nowStr, nil), // 2-byte + }), + ngMakeTx(3, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("cccc3333", `["bbbb22"]`, nowStr, nil), // 3-byte + }), + } + store := ngTestStore(nodes, txs) + g := BuildFromStore(store) + + edges := g.Neighbors("aaaa1111") + var xr1 *NeighborEdge + for _, e := range edges { + other := e.NodeB + if e.NodeA != "aaaa1111" { + other = e.NodeA + } + if other == "bbbb2222" { + xr1 = e + break + } + } + if xr1 == nil { + t.Fatalf("expected X↔R1 edge, got %d edges", len(edges)) + } + // Back-compat: flat Count == 3. + if xr1.Count != 3 { + t.Errorf("expected Count=3, got %d", xr1.Count) + } + if xr1.CountsByMode == nil { + t.Fatalf("expected CountsByMode populated, got nil") + } + if got := xr1.CountsByMode[1]; got != 1 { + t.Errorf("CountsByMode[1] = %d, want 1", got) + } + if got := xr1.CountsByMode[2]; got != 1 { + t.Errorf("CountsByMode[2] = %d, want 1", got) + } + if got := xr1.CountsByMode[3]; got != 1 { + t.Errorf("CountsByMode[3] = %d, want 1", got) + } +} diff --git a/cmd/server/neighbor_persist.go b/cmd/server/neighbor_persist.go index 98d24818..5204147b 100644 --- a/cmd/server/neighbor_persist.go +++ b/cmd/server/neighbor_persist.go @@ -54,19 +54,35 @@ func loadNeighborEdgesFromDB(conn *sql.DB) *NeighborGraph { g.mu.Lock() e, exists := g.edges[key] if !exists { + // Persisted snapshot stores only the flat Count — no per-mode + // breakdown. Synthesize CountsByMode by attributing all Count + // to the legacy/unknown bucket (0) so the invariant + // sum(CountsByMode) == Count holds for downstream consumers. + // Issue #1638 adv-#1: legacy-edge invariant. + cbm := make(map[int]int) + if cnt > 0 { + cbm[0] = cnt + } e = &NeighborEdge{ - NodeA: key.A, - NodeB: key.B, - Observers: make(map[string]bool), - FirstSeen: ts, - LastSeen: ts, - Count: cnt, + NodeA: key.A, + NodeB: key.B, + Observers: make(map[string]bool), + FirstSeen: ts, + LastSeen: ts, + Count: cnt, + CountsByMode: cbm, } g.edges[key] = e g.byNode[key.A] = append(g.byNode[key.A], e) g.byNode[key.B] = append(g.byNode[key.B], e) } else { e.Count += cnt + if e.CountsByMode == nil { + e.CountsByMode = make(map[int]int) + } + if cnt > 0 { + e.CountsByMode[0] += cnt + } if ts.After(e.LastSeen) { e.LastSeen = ts } diff --git a/cmd/server/neighbor_persist_legacy_test.go b/cmd/server/neighbor_persist_legacy_test.go new file mode 100644 index 00000000..efb8d6dd --- /dev/null +++ b/cmd/server/neighbor_persist_legacy_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "database/sql" + "path/filepath" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +// TestNeighborPersist_LegacyEdgeInvariant (#1638 adv-#1): edges loaded from +// the persisted neighbor_edges snapshot have no per-hash-mode breakdown +// (the table stores only the flat Count). Loader MUST synthesize +// CountsByMode so the invariant sum(CountsByMode) == Count holds — all +// pre-existing observations land in bucket 0 (legacy/unknown, conservative +// weight in the JS confidence indicator). +func TestNeighborPersist_LegacyEdgeInvariant(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "neighbor_legacy.db") + rw, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL") + if err != nil { + t.Fatal(err) + } + defer rw.Close() + if _, err := rw.Exec(`CREATE TABLE neighbor_edges ( + node_a TEXT NOT NULL, + node_b TEXT NOT NULL, + count INTEGER DEFAULT 1, + last_seen TEXT, + PRIMARY KEY (node_a, node_b) + )`); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Format(time.RFC3339) + if _, err := rw.Exec( + `INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, ?, ?)`, + "aaaa", "bbbb", 7, now, + ); err != nil { + t.Fatal(err) + } + + g := loadNeighborEdgesFromDB(rw) + edges := g.AllEdges() + if len(edges) != 1 { + t.Fatalf("expected 1 edge, got %d", len(edges)) + } + e := edges[0] + if e.Count != 7 { + t.Fatalf("expected Count=7, got %d", e.Count) + } + if e.CountsByMode == nil { + t.Fatalf("expected CountsByMode synthesized for legacy edge, got nil") + } + // All flat-count observations must land in bucket 0 (legacy/unknown). + if got := e.CountsByMode[0]; got != 7 { + t.Errorf("CountsByMode[0] = %d, want 7 (all legacy count in bucket 0)", got) + } + // Buckets 1/2/3 must be empty — no real wire-mode evidence on a + // snapshot-only edge. + for _, m := range []int{1, 2, 3} { + if got := e.CountsByMode[m]; got != 0 { + t.Errorf("CountsByMode[%d] = %d, want 0", m, got) + } + } + // Invariant: sum(CountsByMode) == Count. + sum := 0 + for _, c := range e.CountsByMode { + sum += c + } + if sum != e.Count { + t.Errorf("invariant violated: sum(CountsByMode)=%d, Count=%d", sum, e.Count) + } +} + +// TestNeighborPersist_LegacyEdgeMergeOnReload covers the "row appears twice +// in the snapshot" path (loader's else-branch): subsequent counts must +// accumulate into bucket 0 too, preserving the invariant. +func TestNeighborPersist_LegacyEdgeMergeOnReload(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "neighbor_legacy_merge.db") + rw, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL") + if err != nil { + t.Fatal(err) + } + defer rw.Close() + // No PRIMARY KEY so we can insert two rows for the same (a,b) pair to + // exercise the loader's else-branch. + if _, err := rw.Exec(`CREATE TABLE neighbor_edges ( + node_a TEXT NOT NULL, + node_b TEXT NOT NULL, + count INTEGER DEFAULT 1, + last_seen TEXT + )`); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Format(time.RFC3339) + for _, cnt := range []int{3, 4} { + if _, err := rw.Exec( + `INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, ?, ?)`, + "aaaa", "bbbb", cnt, now, + ); err != nil { + t.Fatal(err) + } + } + g := loadNeighborEdgesFromDB(rw) + edges := g.AllEdges() + if len(edges) != 1 { + t.Fatalf("expected 1 merged edge, got %d", len(edges)) + } + e := edges[0] + if e.Count != 7 { + t.Fatalf("expected merged Count=7, got %d", e.Count) + } + if got := e.CountsByMode[0]; got != 7 { + t.Errorf("CountsByMode[0] = %d, want 7 after merge", got) + } + sum := 0 + for _, c := range e.CountsByMode { + sum += c + } + if sum != e.Count { + t.Errorf("invariant violated after merge: sum(CountsByMode)=%d, Count=%d", sum, e.Count) + } +} diff --git a/public/nodes.js b/public/nodes.js index c563efa9..68e75ca5 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -250,8 +250,48 @@ function getConfidenceIndicator(entry) { if (entry.ambiguous) return { icon: '', label: 'AMBIGUOUS', cls: 'confidence-ambiguous' }; - if (entry.count <= 1) return { icon: '', label: 'LOW', cls: 'confidence-low' }; - if (entry.score >= 0.5 && entry.count >= 3) return { icon: '', label: 'HIGH', cls: 'confidence-high' }; + // Issue #1638: weight observations by hash-prefix mode. Per firmware + // (firmware/src/Packet.cpp:13-18) valid wire hash modes are 1/2/3-byte + // (hash_size==4 is reserved). 1-byte prefixes collide ~8-way across a + // typical mesh (low ambiguity-resistance); 2-byte ~256-way reduces + // collision sharply; 3-byte (~16M) is effectively unambiguous. Bucket 0 + // is the legacy/unknown bucket used for edges loaded from the persisted + // snapshot (no per-mode breakdown stored) — weight is conservative 0.5. + var modeWeight = { 0: 0.5, 1: 0.125, 2: 0.875, 3: 1.0 }; + var cbm = entry.counts_by_mode || null; + var weighted; + if (cbm) { + weighted = 0; + var summed = 0; + for (var k in cbm) { + if (Object.prototype.hasOwnProperty.call(cbm, k)) { + var w = modeWeight[k] != null ? modeWeight[k] : 0.5; // unknown mode → conservative + var c = cbm[k] || 0; + weighted += w * c; + summed += c; + } + } + // If the flat Count exceeds the sum of the per-mode breakdown (e.g. + // partial breakdown after merging a legacy-snapshot edge with new + // sightings), apportion the delta to the legacy/unknown bucket so + // we honestly count every observation without inflating its weight. + var total = entry.count || 0; + if (total > summed) { + weighted += modeWeight[0] * (total - summed); + } + } else { + // Back-compat: no per-mode breakdown at all → treat all sightings as + // legacy/unknown bucket (conservative weight). + weighted = (entry.count || 0) * modeWeight[0]; + } + if ((entry.count || 0) <= 1 && weighted < 1) { + return { icon: '', label: 'LOW', cls: 'confidence-low' }; + } + // HIGH when EITHER the legacy heuristic clears OR ≥3 unambiguous-equivalent + // sightings have accumulated (weighted ≥ 3). + if ((entry.score >= 0.5 && entry.count >= 3) || weighted >= 3) { + return { icon: '', label: 'HIGH', cls: 'confidence-high' }; + } return { icon: '', label: 'MEDIUM', cls: 'confidence-medium' }; } @@ -271,6 +311,13 @@ ? '' + escapeHtml(role) + '' : ''; var scoreTitle = 'Observations: ' + nb.count; + if (nb.counts_by_mode) { + var parts = []; + [1, 2, 4, 6].forEach(function(m) { + if (nb.counts_by_mode[m]) parts.push(m + '-byte: ' + nb.counts_by_mode[m]); + }); + if (parts.length) scoreTitle += ' (' + parts.join(', ') + ')'; + } if (nb.avg_snr != null) scoreTitle += ' · Avg SNR: ' + Number(nb.avg_snr).toFixed(1) + ' dB'; var distanceCell = nb.distance_km != null ? formatDistance(Number(nb.distance_km)) diff --git a/test-confidence-indicator.js b/test-confidence-indicator.js new file mode 100644 index 00000000..89274f8b --- /dev/null +++ b/test-confidence-indicator.js @@ -0,0 +1,132 @@ +// 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 { + // 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);