From 841fc5def713e9507ff6b80c4d42a704a74fd89a Mon Sep 17 00:00:00 2001 From: bot Date: Sat, 16 May 2026 19:45:50 +0000 Subject: [PATCH] =?UTF-8?q?fix(#1229):=20GREEN=20=E2=80=94=20source-divers?= =?UTF-8?q?ity=20confidence=20weighting=20in=20tier-1=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option C from issue #1229: weight neighbor-graph edges by the number of distinct observers that contributed to them, so the disambiguator prefers corroborated edges over single-source ones at the same raw score. Stacks with the geo-rejection filter merged for #1228 to give two independent defenses against cross-region prefix-collision pollution. Formula (NeighborEdge.Confidence): c = min(1.0, max(1, |Observers|) / 3.0) - 1 observer -> 1/3 weight (suspect) - 2 observers -> 2/3 weight - >=3 -> 1.0 (saturated, full historical weight) Saturation at 3 is conservative: high enough that a single chatty observer cannot dominate, low enough that 3-observer corroboration in a normally-staffed region already counts as full confidence. Tier-1 score in resolveWithContext becomes Score(now) * Confidence(). The downstream ratio guard (best >= 3x runner-up) is unchanged — a 6-observer edge with 30 obs now beats a 1-observer edge with 25 obs by 3.6x (vs. 1.2x before), enough to trigger tier 1 and skip the geo fallback that was misresolving in the cross-region case. The Observers map[string]bool field already existed on NeighborEdge and was populated on every upsert; this PR is the first to consume it in the resolver. Backward compatibility (persistence): neighbor_edges schema is unchanged. The Observers set is rebuilt by BuildFromStoreWithOptions from live observations on every graph refresh (5-min TTL), so persisted edges only carry a stale empty set during the warm-up window after a restart. Confidence() defaults n to 1 when |Observers|==0, so legacy rows resolve as single-observer (degraded but non-zero) confidence rather than disappearing — defensive. Fixes #1229 --- cmd/server/neighbor_graph.go | 24 +++++++++++++++++------- cmd/server/store.go | 12 +++++++++++- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/cmd/server/neighbor_graph.go b/cmd/server/neighbor_graph.go index 55b40d86..452fe7d9 100644 --- a/cmd/server/neighbor_graph.go +++ b/cmd/server/neighbor_graph.go @@ -24,6 +24,10 @@ const ( affinityConfidenceRatio = 3.0 // Minimum observation count to auto-resolve. affinityMinObservations = 3 + // Source-diversity saturation: edges contributed by this many distinct + // observers (or more) earn full confidence weight (multiplier 1.0). + // Fewer observers earn a proportional fraction. Issue #1229 (Option C). + affinityObserverSaturation = 3.0 ) // affinityLambda = ln(2) / half-life-hours, precomputed. @@ -78,14 +82,20 @@ func (e *NeighborEdge) Score(now time.Time) float64 { // // Formula: min(1.0, max(1, |Observers|) / affinityObserverSaturation). // With saturation=3, a single observer yields 1/3, two observers 2/3, and -// three-or-more observers saturate at 1.0 — full historical weight. -// -// STUB: real implementation lands in the GREEN commit. Returning 1.0 here -// keeps the resolver behavior identical so the RED test fails on the -// behavioral assertion (resolver picks the wrong candidate), not on a -// missing method. +// three-or-more observers saturate at 1.0 — full historical weight. Edges +// with an empty observer set (legacy persisted rows lacking the column; +// see neighbor_persist.go backward-compat) default to a count of 1 so they +// behave like single-observer edges rather than disappearing — defensive. func (e *NeighborEdge) Confidence() float64 { - return 1.0 + n := float64(len(e.Observers)) + if n < 1 { + n = 1 + } + c := n / affinityObserverSaturation + if c > 1.0 { + c = 1.0 + } + return c } // AvgSNR returns the average SNR, or 0 if no samples. diff --git a/cmd/server/store.go b/cmd/server/store.go index fab11484..2c7da84e 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -5606,6 +5606,16 @@ func (pm *prefixMap) resolveWithContext(hop string, contextPubkeys []string, gra // highest-affinity candidate among them. Raw score is appropriate because // it reflects both observation frequency and recency, which are the right // signals for "which candidate is this hop most likely referring to." + // + // Issue #1229 (Option C): the raw score is further multiplied by + // e.Confidence() — a source-diversity factor in (0,1] derived from the + // number of distinct observers that contributed to the edge. Edges seen + // by a single observer are discounted to 1/3 weight; edges seen by ≥3 + // observers saturate at full weight. This stacks with the geo-rejection + // filter merged for #1228 to give two independent lines of defense + // against cross-region prefix-collision pollution. Backward-compatible + // with the persistence format: legacy edges with empty Observers sets + // fall back to single-observer weight. if graph != nil && len(contextPubkeys) > 0 { type scored struct { idx int @@ -5629,7 +5639,7 @@ func (pm *prefixMap) resolveWithContext(hop string, contextPubkeys []string, gra otherPK = e.NodeB } if strings.EqualFold(otherPK, candPK) { - s := e.Score(now) + s := e.Score(now) * e.Confidence() if s > bestScore { bestScore = s bestCount = e.Count