Skip to main content

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}