Module: Moult::Health::Score
- Defined in:
- lib/moult/health/score.rb
Overview
The pure model that turns the other analyses' signals into one composite health score. This slice's realisation of Moult's protected confidence API: it answers a deliberately humble question — how healthy does this codebase look, given the signals we have — and it is never a verdict. Every component records the observation behind its sub-score as a Reason, and the composite records which components contributed, so the number is auditable.
Score.assess is a pure function of small numeric inputs (Inputs and the per-
analysis *Input structs) — no IO, no report objects. That keeps it trivially
unit-testable and lets the scoring be pinned against hand-built inputs: drift
is a bug, the same treatment ABC, the coverage Resolver, and the
duplication Confidence model get.
The single inversion to keep in mind: the four input analyses all score badness (higher = worse). Health scores goodness (1.0 = healthy). Every normalisation converts a bounded badness ratio b in [0, 1] to a health sub-score via Score.health_from_badness — the one audited inversion point.
Defined Under Namespace
Classes: BoundariesInput, ComplexityInput, Component, Composite, CoverageInput, DeadCodeInput, DuplicationInput, Inputs, Reason
Constant Summary collapse
- WEIGHTS =
---- pinned weights ----------------------------------------------------- Static weight of each built-in component; they sum to 1.0 and are renormalised over whatever components are actually present. Complexity anchors the composite — it is the only signal that means something with no git history and no coverage. Coverage and dead code tie: both are strong "is this code used" signals but each is conditional. Duplication is the softest health signal (sometimes deliberate), so it gets the smallest share. Boundaries (conditional: only packwerk projects) joins as a structural signal; the original four kept their RELATIVE proportions (each scaled by 0.8) so a repo without boundaries scores and renormalises exactly as before.
{ "complexity" => 0.24, "dead_code" => 0.20, "duplication" => 0.16, "coverage" => 0.20, "boundaries" => 0.20 }.freeze
- GRADE_THRESHOLDS =
---- pinned grade thresholds (inclusive lower bounds on the composite) --- Letter grades on a normalised score follow the conventions of established code-health tools (Code Climate's A–F maintainability grade, SonarQube's A–E maintainability rating, CodeScene's 1–10 Code Health). The density/ratio normalisation below mirrors SonarQube's debt-RATIO approach (debt relative to size) rather than absolute counts. NOTE: the knees and weights here are v1 judgement-based heuristics chosen for sane, monotonic behaviour — they are NOT yet calibrated against a real-world baseline corpus the way CodeScene calibrates its factors; corpus calibration is deliberate future work. They are pinned so the SIGNAL is deterministic and auditable; treat drift as a bug.
[ ["A", 0.90], ["B", 0.80], ["C", 0.70], ["D", 0.60], ["F", 0.0] ].freeze
- COMPLEXITY_CHURN_KNEE =
---- pinned complexity normalisation ------------------------------------ Health falls linearly as the MEAN per-file risk approaches a knee. Averaging over files already dilutes single outliers, so a plain ratio (à la SonarQube's debt ratio) is honest and predictable — no extra log compression, which would double-penalise moderate code.
300.0- COMPLEXITY_ONLY_KNEE =
mean complexity*churn per file at which health hits the floor
150.0- COMPLEXITY_FLOOR =
mean summed-ABC per file at which health hits the floor (no churn signal)
0.30- DEADCODE_DENSITY_KNEE =
---- pinned dead-code normalisation -------------------------------------
0.12- DEADCODE_UNRESOLVED_CAP =
confidence-weighted dead density at which health hits 0
0.95- DUPLICATION_BURDEN_KNEE =
---- pinned duplication normalisation -----------------------------------
40.0- BOUNDARY_BURDEN_KNEE =
---- pinned boundaries normalisation ------------------------------------
4.0
Class Method Summary collapse
- .assess(inputs) ⇒ Composite
- .boundaries_component(input) ⇒ Component?
- .complexity_component(input) ⇒ Component?
- .coverage_component(input) ⇒ Component?
- .dead_code_component(input) ⇒ Component?
- .duplication_component(input) ⇒ Component?
-
.grade_for(score) ⇒ String
Letter grade.
-
.health_from_badness(badness, floor: 0.0) ⇒ Float
Convert a bounded badness ratio to a health sub-score, applying an optional floor so a soft signal never reads as catastrophic 0.0.
-
.healthy_by_absence(name, detail) ⇒ Object
A present component that is vacuously healthy because it had nothing to score — distinct from an absent (nil) component, and it says why.
-
.normalized_weight(name, present_names) ⇒ Float
The renormalised share a present component carried of the composite.
Class Method Details
.assess(inputs) ⇒ Composite
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/moult/health/score.rb', line 112 def assess(inputs) components = [ complexity_component(inputs.complexity), dead_code_component(inputs.dead_code), duplication_component(inputs.duplication), coverage_component(inputs.coverage), boundaries_component(inputs.boundaries) ].compact return Composite.new(score: nil, grade: nil, components: []) if components.empty? total_weight = components.sum { |c| WEIGHTS.fetch(c.name) } weighted = components.sum { |c| c.score * WEIGHTS.fetch(c.name) } overall = (weighted / total_weight).round(2) Composite.new(score: overall, grade: grade_for(overall), components: components) end |
.boundaries_component(input) ⇒ Component?
262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/moult/health/score.rb', line 262 def boundaries_component(input) return nil unless input return healthy_by_absence("boundaries", "no files to score") if input.file_count.to_i.zero? burden = input.weighted_violations / input.file_count.to_f score = health_from_badness(burden / BOUNDARY_BURDEN_KNEE) Component.new( name: "boundaries", category: "architecture_boundary", score: score, stats: { file_count: input.file_count, weighted_violations: input.weighted_violations.round(2), violation_count: input.violation_count }, reasons: [Reason.new(rule: :boundary_burden, value: score, detail: "severity-weighted boundary violations per file #{burden.round(3)} vs knee #{BOUNDARY_BURDEN_KNEE} " \ "(#{input.violation_count} violations)")] ) end |
.complexity_component(input) ⇒ Component?
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/moult/health/score.rb', line 158 def complexity_component(input) return nil unless input return healthy_by_absence("complexity", "no methods with complexity to score") if input.file_count.to_i.zero? if input.churn_present mean_risk = input.total_score / input.file_count.to_f badness = mean_risk / COMPLEXITY_CHURN_KNEE reason = Reason.new(rule: :complexity_churn_density, value: nil, detail: "mean complexity*churn per file #{mean_risk.round(1)} vs knee #{COMPLEXITY_CHURN_KNEE}") else mean_cx = input.total_complexity / input.file_count.to_f badness = mean_cx / COMPLEXITY_ONLY_KNEE reason = Reason.new(rule: :complexity_only_density, value: nil, detail: "no churn signal; mean ABC per file #{mean_cx.round(1)} vs knee #{COMPLEXITY_ONLY_KNEE}") end score = health_from_badness(badness, floor: COMPLEXITY_FLOOR) reason.value = score Component.new( name: "complexity", category: "complexity", score: score, stats: { file_count: input.file_count, mean_complexity: (input.total_complexity / input.file_count.to_f).round(2), churn_present: input.churn_present }, reasons: [reason] ) end |
.coverage_component(input) ⇒ Component?
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/moult/health/score.rb', line 243 def coverage_component(input) return nil unless input tracked = input.hot.to_i + input.cold.to_i # untracked is deliberately NOT in the denominator: it is no signal, so it # must never count as either healthy or unhealthy. return healthy_by_absence("coverage", "no tracked symbols (untracked carries no signal)") if tracked.zero? cold_ratio = input.cold / tracked.to_f score = health_from_badness(cold_ratio) Component.new( name: "coverage", category: "coverage", score: score, stats: {hot: input.hot, cold: input.cold, tracked: tracked}, reasons: [Reason.new(rule: :cold_ratio, value: score, detail: "#{input.cold} cold of #{tracked} tracked symbols (untracked excluded)")] ) end |
.dead_code_component(input) ⇒ Component?
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 |
# File 'lib/moult/health/score.rb', line 189 def dead_code_component(input) return nil unless input return healthy_by_absence("dead_code", "no symbols to score") if input.symbol_count.to_i.zero? density = input.confidence_sum / input.symbol_count.to_f score = health_from_badness(density / DEADCODE_DENSITY_KNEE) reasons = [Reason.new(rule: :dead_density, value: score, detail: "confidence-weighted dead density #{density.round(4)} vs knee #{DEADCODE_DENSITY_KNEE} " \ "(#{input.finding_count} candidates / #{input.symbol_count} symbols)")] unless input.resolved capped = [score, DEADCODE_UNRESOLVED_CAP].min if capped < score score = capped reasons << Reason.new(rule: :index_unresolved, value: score, detail: "index did not fully resolve; capped at #{DEADCODE_UNRESOLVED_CAP}") end end Component.new( name: "dead_code", category: "dead_code", score: score, stats: { symbol_count: input.symbol_count, candidate_count: input.finding_count, confidence_sum: input.confidence_sum.round(2), resolved: input.resolved }, reasons: reasons ) end |
.duplication_component(input) ⇒ Component?
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 |
# File 'lib/moult/health/score.rb', line 222 def duplication_component(input) return nil unless input return healthy_by_absence("duplication", "no files to score") if input.file_count.to_i.zero? burden = input.weighted_dup_mass / input.file_count.to_f score = health_from_badness(burden / DUPLICATION_BURDEN_KNEE) Component.new( name: "duplication", category: "duplication", score: score, stats: { file_count: input.file_count, weighted_dup_mass: input.weighted_dup_mass.round(1), clone_sets: input.set_count }, reasons: [Reason.new(rule: :duplication_burden, value: score, detail: "confidence-weighted duplicated mass per file #{burden.round(2)} vs knee #{DUPLICATION_BURDEN_KNEE} " \ "(#{input.set_count} clone sets)")] ) end |
.grade_for(score) ⇒ String
Returns letter grade.
132 133 134 |
# File 'lib/moult/health/score.rb', line 132 def grade_for(score) GRADE_THRESHOLDS.find { |(_, low)| score >= low }.first end |
.health_from_badness(badness, floor: 0.0) ⇒ Float
Convert a bounded badness ratio to a health sub-score, applying an optional floor so a soft signal never reads as catastrophic 0.0.
151 152 153 154 |
# File 'lib/moult/health/score.rb', line 151 def health_from_badness(badness, floor: 0.0) b = badness.clamp(0.0, 1.0) ((1.0 - b) * (1.0 - floor) + floor).clamp(0.0, 1.0).round(2) end |
.healthy_by_absence(name, detail) ⇒ Object
A present component that is vacuously healthy because it had nothing to score — distinct from an absent (nil) component, and it says why.
283 284 285 286 287 288 |
# File 'lib/moult/health/score.rb', line 283 def healthy_by_absence(name, detail) Component.new( name: name, category: name, score: 1.0, stats: {}, reasons: [Reason.new(rule: :no_signal, value: 1.0, detail: detail)] ) end |
.normalized_weight(name, present_names) ⇒ Float
The renormalised share a present component carried of the composite.
140 141 142 143 144 |
# File 'lib/moult/health/score.rb', line 140 def normalized_weight(name, present_names) total = present_names.sum { |n| WEIGHTS.fetch(n) } return 0.0 if total.zero? (WEIGHTS.fetch(name) / total).round(4) end |