codehelion_core/priority.rs
1//! What a clone group is worth attending to, as separated measures.
2//!
3//! A ranking that collapses to one number cannot be argued with. Three
4//! different questions decide where a finding belongs in a report, and they
5//! have different answers and different evidence:
6//!
7//! - [`Priority::clone_confidence`] — is this duplication real, and worth
8//! calling duplication at all?
9//! - [`Priority::maintenance_risk`] — what does keeping the copies in step
10//! cost?
11//! - [`Priority::refactoring_difficulty`] — what would removing it cost?
12//!
13//! [`Priority::final_priority`] composes them into one order, because a report
14//! has to be printed in some order. It never replaces them: every view carries
15//! all four, and [`Priority::inputs`] carries the values they were read from,
16//! so a reader who disagrees with the ranking can see exactly which input
17//! produced it.
18//!
19//! # Why the composition multiplies rather than adds
20//!
21//! Risk and difficulty are statements about a finding that is real. Added to
22//! confidence they can outvote it, and a lookalike with many copies then
23//! outranks a genuine duplication with two — which is the failure mode the
24//! separation exists to prevent. Multiplying makes confidence the leading
25//! term: the maintenance argument moves a finding within the band its
26//! confidence puts it in, and cannot lift it out of that band.
27//!
28//! Measured over the labelled corpora, an additive composition costs mean
29//! average precision against a multiplicative one at the same weights; see
30//! `precision_at_k` in the evaluation harness, which pins the comparison.
31//!
32//! # Why the values do not depend on the other findings
33//!
34//! Every measure here is computed from one group's own facts. Nothing is
35//! ranked against the rest of the run, and nothing is scaled by the run's
36//! maximum. A rank-based composition reads well on one report and falls apart
37//! across two: adding a single group renumbers every other group's rank, so a
38//! finding's priority would move for reasons that have nothing to do with it,
39//! and `codehelion audit` could not say whether a priority rose because the
40//! duplication got worse or because something else was found. Absolute values
41//! are comparable between runs; ranks are not.
42//!
43//! Counts are mapped onto `0..1` by [`saturating`], which has no cliff to
44//! calibrate and no ceiling to saturate against: a value twice the reference
45//! scores two-thirds, ten times the reference scores ten-elevenths, and
46//! nothing ever reaches 1. That last part is deliberate — none of these
47//! measures is ever certain.
48
49use crate::clone_class::{CloneClass, CloneScope};
50
51/// Version of the ranking recipe, recorded with every run.
52///
53/// The constants below decide where findings land in a report, so two runs
54/// ranked under different constants are not comparable orderings even when
55/// every fingerprint agrees. Increment this whenever a constant or a formula
56/// moves.
57pub const RECIPE_VERSION: &str = "1";
58
59/// Discount applied to a match the normalization had to reshape before it
60/// agreed.
61///
62/// A Type-1 group matched on the source as written. Anything else matched
63/// after identifiers, literals or whole statements were set aside, and two
64/// units that agree only once their names are gone can genuinely do different
65/// things. Over the labelled corpora the renamed class is where the lookalikes
66/// concentrate, which is what this expresses.
67const NORMALIZED_MATCH_DISCOUNT: f64 = 0.8;
68
69/// Multiple of the configured minimum clone length at which a group's size
70/// earns half the available size credit.
71///
72/// Anchored to the floor rather than fixed, because the floor is the length
73/// below which the scan already refuses to report: a clone sitting exactly on
74/// it is the least convincing one the run can produce, whatever the floor was
75/// set to.
76const SIZE_HALF_CREDIT_MULTIPLE: u64 = 2;
77
78/// Instances beyond the first at which the count earns half the risk credit.
79const RISK_HALF_INSTANCES: f64 = 2.0;
80
81/// Token count at which a group's extent earns half the risk credit.
82const RISK_HALF_TOKENS: f64 = 120.0;
83
84/// Directories beyond the first at which spread earns half the risk credit.
85const RISK_HALF_DIRECTORIES: f64 = 1.0;
86
87/// Token count at which a group's extent earns half the difficulty credit.
88///
89/// Larger than [`RISK_HALF_TOKENS`]: a duplicated block becomes expensive to
90/// maintain sooner than it becomes hard to lift out.
91const DIFFICULTY_HALF_TOKENS: f64 = 200.0;
92
93/// Share of a finding's confidence that survives the weakest possible
94/// maintenance argument.
95///
96/// Not zero: a finding whose duplication is cheap to keep and awkward to
97/// remove is still a finding, and dropping it to nothing would order the tail
98/// of a report by rounding error. Half is inside the range the labelled
99/// corpora are indifferent to — anything from roughly a third upwards ranks
100/// them the same.
101const WORTH_FLOOR: f64 = 0.5;
102
103/// How the separated measures are weighted against each other when they are
104/// composed into one order.
105///
106/// Whole numbers rather than fractions: these are shares, they are read from
107/// and written back to a configuration file, and a float round-trip through
108/// TOML is a difference nobody meant to express.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct Weights {
111 /// Weight of what keeping the copies in step costs.
112 pub maintenance_risk: u32,
113 /// Weight of how cheap the duplication would be to remove.
114 pub refactoring_ease: u32,
115}
116
117impl Default for Weights {
118 fn default() -> Self {
119 // Risk leads: what the duplication costs to live with is the reason to
120 // read the report at all, and how easy it would be to remove is a
121 // question that only arises once it is worth removing. The labelled
122 // corpora rank the same for any ratio between 1:1 and 3:1, so this is
123 // the round choice inside a flat range rather than a fitted optimum.
124 Self {
125 maintenance_risk: 2,
126 refactoring_ease: 1,
127 }
128 }
129}
130
131impl Weights {
132 /// The ranking recipe this run applied: the version of the rules together
133 /// with the weights they were composed under.
134 ///
135 /// Recorded with the run, because a report ordered under other weights is
136 /// a different ordering of the same findings and nothing else says so.
137 #[must_use]
138 pub fn recipe(&self) -> String {
139 format!(
140 "{RECIPE_VERSION}-risk{}-ease{}",
141 self.maintenance_risk, self.refactoring_ease
142 )
143 }
144
145 /// Blend of the two arguments a finding makes for itself, on `0..1`.
146 ///
147 /// Weights that are both zero leave no argument to weigh, and the blend is
148 /// the midpoint rather than an error: a reader who turns both off is
149 /// asking to rank on confidence alone, and confidence alone is what they
150 /// then get.
151 fn worth(self, risk: f64, difficulty: f64) -> f64 {
152 let total = f64::from(self.maintenance_risk) + f64::from(self.refactoring_ease);
153 if total <= 0.0 {
154 return 0.5;
155 }
156 f64::from(self.refactoring_ease)
157 .mul_add(1.0 - difficulty, f64::from(self.maintenance_risk) * risk)
158 / total
159 }
160}
161
162/// What the ranking reads about one clone group.
163///
164/// Every field is a fact the scan established, not a judgement: the judgements
165/// are what [`rank`] derives from them. The reserved fields are inputs the
166/// requirements name that no analysis mode can supply yet; they are declared
167/// here and reported as absent rather than defaulted, so that the day a
168/// backend supplies one, nothing has to be told the difference between a
169/// missing value and a zero.
170#[derive(Debug, Clone, Copy, PartialEq)]
171pub struct GroupFacts {
172 /// How closely the members match.
173 pub clone_type: CloneClass,
174 /// Whether the members are whole units or runs inside them.
175 pub scope: CloneScope,
176 /// Occurrences in the group.
177 pub instances: u64,
178 /// Token count of the smallest occurrence.
179 ///
180 /// The smallest rather than the largest: a group is only as convincing as
181 /// its least substantial member, since that is the one that could most
182 /// easily have matched by coincidence.
183 pub smallest_member_tokens: u64,
184 /// Token count of the largest occurrence, which is what a reader would
185 /// have to read, keep in step, or lift out.
186 pub largest_member_tokens: u64,
187 /// Weakest pairwise similarity across the group. Exactly 1 for a group
188 /// matched on identical content.
189 pub min_pairwise: f64,
190 /// Distinct files the occurrences sit in.
191 pub files: u64,
192 /// Distinct directories the occurrences sit in.
193 pub directories: u64,
194 /// Distinct languages the occurrences are written in.
195 ///
196 /// One, in every mode that exists today: content fingerprints are computed
197 /// per language, so no group can span two. The input is read anyway, so
198 /// that a cross-language frontend starts affecting the ranking by being
199 /// implemented rather than by also being wired in here.
200 pub languages: u64,
201 /// The run's minimum clone length, which the sizes above are read against.
202 pub min_clone_tokens: u64,
203 /// Weakest raw identifier-set agreement against the canonical member.
204 ///
205 /// Structural whole-unit analysis supplies this before normalization loses
206 /// the spelling. Other modes leave it absent rather than treating an
207 /// unavailable measurement as disagreement.
208 pub identifier_jaccard: Option<f64>,
209 /// Weakest call-surface agreement the analysis measured.
210 ///
211 /// `None` means neither side offered a call surface to compare, not that
212 /// their call surfaces agree.
213 pub api_similarity: Option<f64>,
214 /// Whether every member has a loop, when Structural mode measured bodies.
215 pub has_loop: Option<bool>,
216 /// Whether every member calls a recognised allocation API.
217 pub has_dynamic_allocation: Option<bool>,
218 /// Fewest call sites in any member, when Structural mode measured bodies.
219 pub call_count: Option<u64>,
220 /// How often the duplicated code changed. Reserved: this needs repository
221 /// history, which no analysis mode reads yet.
222 pub churn: Option<f64>,
223 /// How many people own the copies. Reserved, on the same footing as
224 /// [`Self::churn`].
225 pub ownership_spread: Option<f64>,
226}
227
228/// Where one clone group belongs in a report, and on what grounds.
229#[derive(Debug, Clone, Copy, PartialEq)]
230pub struct Priority {
231 /// How sure the finding is duplication worth reporting, on `0..1`.
232 pub clone_confidence: f64,
233 /// What keeping the copies in step costs, on `0..1`.
234 pub maintenance_risk: f64,
235 /// What removing the duplication would cost, on `0..1`.
236 pub refactoring_difficulty: f64,
237 /// The composed ranking value, on `0..1`.
238 pub final_priority: f64,
239 /// How sure the finding is semantically equivalent. Reserved for the
240 /// compiler backends; absent until one runs.
241 pub semantic_confidence: Option<f64>,
242 /// How sure the source is the source of a given artifact. Reserved for the
243 /// artifact backends.
244 pub source_artifact_confidence: Option<f64>,
245 /// How sure the reported savings are. Reserved: nothing measures savings
246 /// yet, and a number here would be read as a guarantee.
247 pub savings_confidence: Option<f64>,
248 /// The facts every measure above was read from.
249 pub inputs: GroupFacts,
250}
251
252/// A count mapped onto `0..1` by how far past `half` it reaches.
253///
254/// `half` earns exactly one half; nothing earns 1. A negative or zero `half`
255/// has no reference to measure against and scores nothing, rather than
256/// dividing by zero.
257#[must_use]
258pub fn saturating(value: f64, half: f64) -> f64 {
259 if half <= 0.0 || value <= 0.0 {
260 return 0.0;
261 }
262 value / (value + half)
263}
264
265/// `count` as a float, for the ratios above.
266///
267/// Counts this size lose nothing a ranking can see.
268#[allow(clippy::cast_precision_loss)]
269const fn as_f64(count: u64) -> f64 {
270 count as f64
271}
272
273/// How sure the finding is duplication worth reporting.
274///
275/// Three things decide it, and they multiply because each is a way the finding
276/// could fail to be one: the copies might not agree, the agreement might be
277/// too short to mean anything, and the agreement might be an artefact of what
278/// normalization threw away.
279#[must_use]
280pub fn clone_confidence(facts: &GroupFacts) -> f64 {
281 let half = as_f64(
282 facts
283 .min_clone_tokens
284 .saturating_mul(SIZE_HALF_CREDIT_MULTIPLE),
285 );
286 let length = saturating(as_f64(facts.smallest_member_tokens), half);
287 let reshaped = if facts.clone_type == CloneClass::Type1 {
288 1.0
289 } else {
290 NORMALIZED_MATCH_DISCOUNT
291 };
292 facts.min_pairwise.clamp(0.0, 1.0) * length * reshaped
293}
294
295/// What keeping the copies in step costs.
296///
297/// Copies drift. What decides how expensive that is: how many places have to
298/// receive the same edit, how much code each of them is, and how far apart
299/// they sit — copies in one file are read together and tend to be changed
300/// together, copies in different directories are not and do not.
301///
302/// [`GroupFacts::churn`] and [`GroupFacts::ownership_spread`] belong here too
303/// and are not read: nothing supplies them yet, and inventing a value for a
304/// missing input would put findings in an order the evidence does not support.
305/// Structural mode also reports loop, allocation, and call-site evidence.
306/// Those facts measure maintenance surface, not generated code size.
307#[must_use]
308pub fn maintenance_risk(facts: &GroupFacts) -> f64 {
309 let copies = saturating(
310 as_f64(facts.instances.saturating_sub(1)),
311 RISK_HALF_INSTANCES,
312 );
313 let extent = saturating(as_f64(facts.largest_member_tokens), RISK_HALF_TOKENS);
314 let spread = saturating(
315 as_f64(facts.directories.saturating_sub(1)),
316 RISK_HALF_DIRECTORIES,
317 );
318 let baseline = 0.50f64.mul_add(copies, 0.35f64.mul_add(extent, 0.15 * spread));
319 let (materiality, measurements) = [
320 facts.has_loop.map(f64::from),
321 facts.has_dynamic_allocation.map(f64::from),
322 facts.call_count.map(|count| saturating(as_f64(count), 4.0)),
323 ]
324 .into_iter()
325 .flatten()
326 .fold((0.0, 0_u64), |(sum, count), value| (sum + value, count + 1));
327 if measurements == 0 {
328 return baseline;
329 }
330 0.80f64.mul_add(baseline, 0.20 * (materiality / as_f64(measurements)))
331}
332
333/// What removing the duplication would cost.
334///
335/// The extent is the bulk of it, but three other things change the answer: a
336/// run inside a unit has no boundary to lift it out at and has to be given
337/// one, everything the copies do differently becomes a parameter of whatever
338/// replaces them, and copies in different languages cannot share code at all
339/// without an interface between them. When the structural analysis measured
340/// raw identifiers or call surfaces, disagreement there is a separate fact:
341/// it says that a shared body may need a dispatcher rather than one ordinary
342/// abstraction. Missing measurements do not change the answer.
343///
344/// Higher is harder. It lowers a finding's place in the report rather than
345/// raising it — a duplication nobody can act on is worth less attention than
346/// one anybody can — but only within the band its confidence puts it in.
347#[must_use]
348pub fn refactoring_difficulty(facts: &GroupFacts) -> f64 {
349 let extent = saturating(as_f64(facts.largest_member_tokens), DIFFICULTY_HALF_TOKENS);
350 let unbounded = f64::from(facts.scope == CloneScope::Fragment);
351 let divergence = 1.0 - facts.min_pairwise.clamp(0.0, 1.0);
352 let cross_language = f64::from(facts.languages > 1);
353 let baseline = 0.40f64.mul_add(
354 extent,
355 0.25f64.mul_add(
356 unbounded,
357 0.20f64.mul_add(divergence, 0.15 * cross_language),
358 ),
359 );
360 let (surface_divergence, measurements) = [
361 facts
362 .identifier_jaccard
363 .map(|value| 1.0 - value.clamp(0.0, 1.0)),
364 facts
365 .api_similarity
366 .map(|value| 1.0 - value.clamp(0.0, 1.0)),
367 ]
368 .into_iter()
369 .flatten()
370 .fold((0.0, 0_u64), |(sum, count), value| (sum + value, count + 1));
371 if measurements == 0 {
372 return baseline;
373 }
374 // The base measure remains dominant: surface disagreement is evidence
375 // against a simple extraction, not proof that no good abstraction exists.
376 0.85f64.mul_add(baseline, 0.15 * (surface_divergence / as_f64(measurements)))
377}
378
379/// Rank one clone group: every measure, and the facts they came from.
380#[must_use]
381pub fn rank(facts: &GroupFacts, weights: &Weights) -> Priority {
382 let confidence = clone_confidence(facts);
383 let risk = maintenance_risk(facts);
384 let difficulty = refactoring_difficulty(facts);
385 let worth = weights.worth(risk, difficulty);
386 Priority {
387 clone_confidence: confidence,
388 maintenance_risk: risk,
389 refactoring_difficulty: difficulty,
390 final_priority: confidence * (1.0 - WORTH_FLOOR).mul_add(worth, WORTH_FLOOR),
391 // Reserved until a backend supplies them. Absent, never zero: zero is
392 // a measurement, and none of these has been taken.
393 semantic_confidence: None,
394 source_artifact_confidence: None,
395 savings_confidence: None,
396 inputs: *facts,
397 }
398}
399
400#[cfg(test)]
401#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
402mod tests {
403 use super::*;
404
405 /// A plain two-instance verbatim group of comfortably reportable size.
406 fn facts() -> GroupFacts {
407 GroupFacts {
408 clone_type: CloneClass::Type1,
409 scope: CloneScope::Unit,
410 instances: 2,
411 smallest_member_tokens: 80,
412 largest_member_tokens: 80,
413 min_pairwise: 1.0,
414 files: 2,
415 directories: 1,
416 languages: 1,
417 min_clone_tokens: 20,
418 identifier_jaccard: None,
419 api_similarity: None,
420 has_loop: None,
421 has_dynamic_allocation: None,
422 call_count: None,
423 churn: None,
424 ownership_spread: None,
425 }
426 }
427
428 #[test]
429 fn every_measure_stays_inside_the_range_it_claims() {
430 // The extremes a scan can actually produce, at both ends.
431 let mut smallest = facts();
432 smallest.instances = 2;
433 smallest.smallest_member_tokens = 1;
434 smallest.largest_member_tokens = 1;
435 smallest.min_pairwise = 0.0;
436 smallest.clone_type = CloneClass::Type3;
437 smallest.scope = CloneScope::Fragment;
438
439 let mut largest = facts();
440 largest.instances = u64::MAX;
441 largest.smallest_member_tokens = u64::MAX;
442 largest.largest_member_tokens = u64::MAX;
443 largest.directories = u64::MAX;
444 largest.languages = 3;
445 largest.scope = CloneScope::Fragment;
446
447 for probe in [smallest, largest, facts()] {
448 let ranked = rank(&probe, &Weights::default());
449 for (name, value) in [
450 ("clone confidence", ranked.clone_confidence),
451 ("maintenance risk", ranked.maintenance_risk),
452 ("refactoring difficulty", ranked.refactoring_difficulty),
453 ("final priority", ranked.final_priority),
454 ] {
455 assert!(
456 (0.0..=1.0).contains(&value),
457 "{name} left its range at {value}"
458 );
459 }
460 }
461 }
462
463 #[test]
464 fn a_clone_sitting_on_the_length_floor_is_the_least_convincing_one() {
465 // The floor is where the scan stops reporting, so a clone exactly on
466 // it is the weakest evidence the run can produce — and it says so
467 // whatever the floor was set to.
468 for floor in [10, 20, 50] {
469 let mut probe = facts();
470 probe.min_clone_tokens = floor;
471 probe.smallest_member_tokens = floor;
472 probe.largest_member_tokens = floor;
473 let at_floor = clone_confidence(&probe);
474
475 probe.smallest_member_tokens = floor * 8;
476 probe.largest_member_tokens = floor * 8;
477 let well_past = clone_confidence(&probe);
478
479 assert!(at_floor < 0.4, "floor {floor}: {at_floor}");
480 assert!(well_past > 0.7, "floor {floor}: {well_past}");
481 }
482 }
483
484 #[test]
485 fn a_match_the_normalization_had_to_reshape_is_trusted_less() {
486 let mut verbatim = facts();
487 verbatim.clone_type = CloneClass::Type1;
488 let mut renamed = facts();
489 renamed.clone_type = CloneClass::Type2;
490
491 assert!(clone_confidence(&renamed) < clone_confidence(&verbatim));
492 }
493
494 #[test]
495 fn a_gapped_group_is_discounted_twice_over() {
496 // Once for having been reshaped, and again for the members not
497 // agreeing. The two are separate facts and both belong in the answer.
498 let mut renamed = facts();
499 renamed.clone_type = CloneClass::Type2;
500 let mut gapped = facts();
501 gapped.clone_type = CloneClass::Type3;
502 gapped.min_pairwise = 0.8;
503
504 assert!(clone_confidence(&gapped) < clone_confidence(&renamed));
505 }
506
507 #[test]
508 fn more_copies_further_apart_cost_more_to_keep_in_step() {
509 let base = maintenance_risk(&facts());
510
511 let mut many = facts();
512 many.instances = 9;
513 assert!(maintenance_risk(&many) > base);
514
515 let mut scattered = facts();
516 scattered.directories = 4;
517 assert!(maintenance_risk(&scattered) > base);
518 }
519
520 #[test]
521 fn material_bodies_raise_maintenance_risk_without_predicting_binary_size() {
522 let plain = facts();
523 let mut material = plain;
524 material.has_loop = Some(true);
525 material.has_dynamic_allocation = Some(true);
526 material.call_count = Some(8);
527
528 assert!(maintenance_risk(&material) > maintenance_risk(&plain));
529 }
530
531 #[test]
532 fn a_run_inside_a_unit_is_harder_to_lift_out_than_a_whole_unit() {
533 let mut whole = facts();
534 whole.scope = CloneScope::Unit;
535 let mut run = facts();
536 run.scope = CloneScope::Fragment;
537
538 assert!(refactoring_difficulty(&run) > refactoring_difficulty(&whole));
539 }
540
541 #[test]
542 fn copies_in_two_languages_are_harder_to_share_than_copies_in_one() {
543 let mut one = facts();
544 one.languages = 1;
545 let mut two = facts();
546 two.languages = 2;
547
548 assert!(refactoring_difficulty(&two) > refactoring_difficulty(&one));
549 }
550
551 #[test]
552 fn distinct_names_and_call_surfaces_raise_refactoring_difficulty() {
553 let ordinary = facts();
554 let mut divergent = ordinary;
555 divergent.clone_type = CloneClass::Type3;
556 divergent.identifier_jaccard = Some(0.0);
557 divergent.api_similarity = Some(0.0);
558
559 assert!(
560 refactoring_difficulty(&divergent) > refactoring_difficulty(&ordinary),
561 "unshared names and APIs make a single extraction less plausible"
562 );
563 }
564
565 #[test]
566 fn unavailable_surface_evidence_does_not_change_refactoring_difficulty() {
567 let ordinary = facts();
568 let mut unavailable = ordinary;
569 unavailable.identifier_jaccard = None;
570 unavailable.api_similarity = None;
571
572 assert!(
573 (refactoring_difficulty(&unavailable) - refactoring_difficulty(&ordinary)).abs()
574 < f64::EPSILON
575 );
576 }
577
578 #[test]
579 fn the_maintenance_argument_cannot_lift_a_finding_past_its_confidence() {
580 // The whole reason the composition multiplies. A short renamed pair
581 // with every risk input at its maximum still ranks below a long
582 // verbatim group with none of them.
583 let mut lookalike = facts();
584 lookalike.clone_type = CloneClass::Type2;
585 lookalike.smallest_member_tokens = 20;
586 lookalike.largest_member_tokens = 20;
587 lookalike.instances = 40;
588 lookalike.directories = 12;
589
590 let mut genuine = facts();
591 genuine.smallest_member_tokens = 400;
592 genuine.largest_member_tokens = 400;
593 genuine.instances = 2;
594 genuine.directories = 1;
595
596 let weights = Weights::default();
597 assert!(maintenance_risk(&lookalike) > maintenance_risk(&genuine));
598 assert!(
599 rank(&lookalike, &weights).final_priority < rank(&genuine, &weights).final_priority
600 );
601 }
602
603 #[test]
604 fn a_findings_place_does_not_depend_on_what_else_was_found() {
605 // What makes a priority comparable between two runs: it is computed
606 // from the group and nothing else, so it cannot move because the scan
607 // next door found one more group.
608 let weights = Weights::default();
609 let alone = rank(&facts(), &weights);
610 let mut crowd = facts();
611 crowd.instances = 300;
612 let _ = rank(&crowd, &weights);
613 assert_eq!(rank(&facts(), &weights), alone);
614 }
615
616 #[test]
617 fn turning_both_weights_off_ranks_on_confidence_alone() {
618 let off = Weights {
619 maintenance_risk: 0,
620 refactoring_ease: 0,
621 };
622 let mut risky = facts();
623 risky.instances = 20;
624 let calm = facts();
625
626 // Same confidence, wildly different risk: with nothing weighing the
627 // maintenance argument, they land together rather than in an order
628 // taken from an input nobody asked for.
629 let a = rank(&risky, &off);
630 let b = rank(&calm, &off);
631 assert!((a.final_priority - b.final_priority).abs() < 1e-12);
632 assert!(a.maintenance_risk > b.maintenance_risk);
633 }
634
635 #[test]
636 fn the_reserved_measures_are_reported_absent_rather_than_zero() {
637 let ranked = rank(&facts(), &Weights::default());
638 assert_eq!(ranked.semantic_confidence, None);
639 assert_eq!(ranked.source_artifact_confidence, None);
640 assert_eq!(ranked.savings_confidence, None);
641 assert_eq!(ranked.inputs.churn, None);
642 assert_eq!(ranked.inputs.ownership_spread, None);
643 }
644
645 #[test]
646 fn the_recipe_names_the_weights_it_was_composed_under() {
647 // Two runs ranked under different weights order the same findings
648 // differently, and the recorded recipe is what says so.
649 assert_eq!(Weights::default().recipe(), "1-risk2-ease1");
650 assert_ne!(
651 Weights {
652 maintenance_risk: 1,
653 refactoring_ease: 3,
654 }
655 .recipe(),
656 Weights::default().recipe()
657 );
658 }
659}