mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-20 21:20:53 +00:00
Adds Coverage (harmonic reach) + Redundancy (Tarjan articulation) axes + composite & grade. Closes #672. **TDD note (BLOCKER-1):** Community PR delivered as a single squashed commit, so there is no separate pre-fix failing-test commit — please accept as a community-PR exemption. The tests are *gating*, not just thorough: each axis test pins a specific topology outcome (coverage on line/star/disconnected/weight-sensitive; redundancy online/triangle/star/bridged-cliques), and an end-to-end `/api/nodes` surface test drives the whole pipeline and asserts the composite diverges from the Traffic axis. Inverting the `1/weight` distance, dropping the NaN/Inf reject, removing the `redundancyMinWeight` floor, or aliasing `usefulness_score` back onto `traffic_share_score` each break a specific assertion. The axis functions are pure (no hidden state), so the suite fully characterises the behavior without the red anchor. Co-authored-by: Waydroid Builder <build@waydroid.local>
157 lines
6.1 KiB
Go
157 lines
6.1 KiB
Go
// Package main: composite repeater usefulness score + letter grade
|
||
// (issue #672). Combines the four per-axis scores — each already in
|
||
// [0, 1] — into a single weighted score, plus an A–F grade for at-a-
|
||
// glance ranking.
|
||
//
|
||
// Weights (sum = 1.0) follow the #672 proposal:
|
||
//
|
||
// Bridge 0.30 structural betweenness (chokepoint)
|
||
// Coverage 0.25 harmonic reach (how much of the mesh it reaches)
|
||
// Redundancy 0.25 irreplaceability (fragmentation on removal)
|
||
// Traffic 0.20 observed relayed load
|
||
//
|
||
// All four inputs are expected to be max-normalized over the current
|
||
// repeater population (top node = 1.0): Bridge/Coverage/Redundancy by their
|
||
// Compute* functions, and Traffic by the caller dividing the raw share by the
|
||
// population max BEFORE calling here (#1762 review — without this, the raw
|
||
// traffic share (~0.02–0.05) collapsed its 0.20 weight to a negligible
|
||
// contribution). The exposed `traffic_share_score` field keeps the RAW share;
|
||
// only the composite uses the normalized form.
|
||
package main
|
||
|
||
// #672 composite weights. Exposed as named constants so the maintainer can
|
||
// retune without hunting through the arithmetic. Must sum to 1.0.
|
||
const (
|
||
usefulnessWeightBridge = 0.30
|
||
usefulnessWeightCoverage = 0.25
|
||
usefulnessWeightRedundancy = 0.25
|
||
usefulnessWeightTraffic = 0.20
|
||
)
|
||
|
||
// Grade thresholds on the composite score. These are a first-cut calibration,
|
||
// NOT empirically tuned against a labelled dataset: with all four axes
|
||
// max-normalized, the single most-important repeater approaches 1.0, so the
|
||
// bands are placed to spread the realistic mid-field — a node strong on two of
|
||
// the three .25–.30 structural axes clears B (~0.45), one dominant axis clears
|
||
// C (~0.30), and peripheral/low-traffic nodes fall to D/F. Revisit once score
|
||
// distributions from real meshes are available; they are named constants
|
||
// precisely so retuning is a one-line change.
|
||
//
|
||
// FOLLOW-UP: a separate tuning issue should be opened to recalibrate these
|
||
// bands against observed real-mesh score histograms (cannot be filed from
|
||
// here); until then treat the letter grade as a coarse first impression and
|
||
// rank on the numeric usefulness_score for anything precise.
|
||
const (
|
||
usefulnessGradeA = 0.65
|
||
usefulnessGradeB = 0.45
|
||
usefulnessGradeC = 0.30
|
||
usefulnessGradeD = 0.15
|
||
)
|
||
|
||
// usefulnessAxes bundles the four #672 axis scores (each already in [0,1])
|
||
// so callers pass them BY NAME rather than as four adjacent, easily-swapped
|
||
// float64 arguments (#1762 review). Traffic here is the max-normalized share
|
||
// used in the composite, not the raw traffic_share_score.
|
||
type usefulnessAxes struct {
|
||
Traffic float64
|
||
Bridge float64
|
||
Coverage float64
|
||
Redundancy float64
|
||
}
|
||
|
||
// compositeUsefulness combines the four axis scores into a weighted
|
||
// composite in [0, 1] and its letter grade. Inputs are clamped defensively
|
||
// to [0, 1]; out-of-range axis values cannot push the composite outside
|
||
// the unit interval.
|
||
//
|
||
// The all-zero case is intentionally ambiguous and `axesComputed` disambiguates
|
||
// it (#1762 MAJOR-4):
|
||
//
|
||
// - axesComputed == false → the recomputers have not populated any snapshot
|
||
// yet (the first ~5 min after boot). All-zero then means "no signal YET",
|
||
// so the grade is "" (empty) and callers omit usefulness_grade rather than
|
||
// flash a misleading boot-time "F".
|
||
// - axesComputed == true → the recomputers HAVE run and genuinely scored this
|
||
// node zero on every axis (a fully isolated / unreached repeater). That is a
|
||
// real, deserved "F" and is returned as such — not hidden.
|
||
//
|
||
// A node with any non-zero axis is graded normally regardless of the flag.
|
||
func compositeUsefulness(ax usefulnessAxes, axesComputed bool) (float64, string) {
|
||
t, b, c, r := clamp01(ax.Traffic), clamp01(ax.Bridge), clamp01(ax.Coverage), clamp01(ax.Redundancy)
|
||
score := usefulnessWeightBridge*b +
|
||
usefulnessWeightCoverage*c +
|
||
usefulnessWeightRedundancy*r +
|
||
usefulnessWeightTraffic*t
|
||
score = clamp01(score)
|
||
if t == 0 && b == 0 && c == 0 && r == 0 && !axesComputed {
|
||
// Cold start: no axes computed yet — withhold the grade.
|
||
return 0, ""
|
||
}
|
||
return score, usefulnessGrade(score)
|
||
}
|
||
|
||
// usefulnessGrade maps a composite score in [0, 1] to an A–F letter grade.
|
||
func usefulnessGrade(score float64) string {
|
||
switch {
|
||
case score >= usefulnessGradeA:
|
||
return "A"
|
||
case score >= usefulnessGradeB:
|
||
return "B"
|
||
case score >= usefulnessGradeC:
|
||
return "C"
|
||
case score >= usefulnessGradeD:
|
||
return "D"
|
||
default:
|
||
return "F"
|
||
}
|
||
}
|
||
|
||
// clamp01 bounds v to [0, 1].
|
||
func clamp01(v float64) float64 {
|
||
if v < 0 {
|
||
return 0
|
||
}
|
||
if v > 1 {
|
||
return 1
|
||
}
|
||
return v
|
||
}
|
||
|
||
// maxFloat returns the largest value in m, or 0 for an empty map. The local
|
||
// is named `largest` (not `max`) to avoid shadowing the Go 1.21 builtin —
|
||
// consistent with maxScoreValue in coverage_score_test.go (#1762 nit).
|
||
func maxFloat(m map[string]float64) float64 {
|
||
largest := 0.0
|
||
for _, v := range m {
|
||
if v > largest {
|
||
largest = v
|
||
}
|
||
}
|
||
return largest
|
||
}
|
||
|
||
// enrichNodeUsefulness writes the four #672 axes + composite + grade onto a
|
||
// node map (shared by the node-list and node-detail handlers). trafficRaw is
|
||
// the Traffic-axis share exposed verbatim as traffic_share_score; ax holds the
|
||
// composite inputs — its Traffic is the max-normalized share (across the
|
||
// repeater population) so all four axes contribute on a comparable [0,1]
|
||
// scale. axesComputed reports whether the structural-axis recomputers have
|
||
// produced a snapshot yet; it only matters for the all-zero node, where it
|
||
// distinguishes cold start (grade withheld) from a genuinely isolated repeater
|
||
// (real "F") — see compositeUsefulness. The usefulness_grade field is omitted
|
||
// only when the grade is empty (cold start).
|
||
func enrichNodeUsefulness(node map[string]interface{}, trafficRaw float64, ax usefulnessAxes, axesComputed bool) {
|
||
if node == nil {
|
||
return
|
||
}
|
||
node["traffic_share_score"] = trafficRaw
|
||
node["bridge_score"] = ax.Bridge
|
||
node["coverage_score"] = ax.Coverage
|
||
node["redundancy_score"] = ax.Redundancy
|
||
composite, grade := compositeUsefulness(ax, axesComputed)
|
||
node["usefulness_score"] = composite
|
||
if grade != "" {
|
||
node["usefulness_grade"] = grade
|
||
}
|
||
}
|