fix(#1229): GREEN — source-diversity confidence weighting in tier-1 resolver

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
This commit is contained in:
bot
2026-05-16 19:45:50 +00:00
parent 235b65b4e6
commit 841fc5def7
2 changed files with 28 additions and 8 deletions
+17 -7
View File
@@ -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.
+11 -1
View File
@@ -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