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

Class Method Details

.assess(inputs) ⇒ Composite

Parameters:

Returns:



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?

Parameters:

Returns:



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?

Parameters:

Returns:



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?

Parameters:

Returns:



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?

Parameters:

Returns:



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?

Parameters:

Returns:



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.

Parameters:

  • score (Float)

    composite in [0, 1]

Returns:

  • (String)

    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.

Parameters:

  • badness (Float)

    in [0, 1] (clamped); higher = worse

  • floor (Float) (defaults to: 0.0)

    lowest the sub-score may reach

Returns:

  • (Float)

    rounded to 2 decimals



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.

Parameters:

  • name (String)

    component name

  • present_names (Array<String>)

    names of the components that contributed

Returns:

  • (Float)


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