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