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