Skip to main content

rac_engine/
classify.rs

1//! Deterministic classification (`decided.core.classification`), per
2//! PORT-CONTRACT.d/04 §2.
3//!
4//! - synonym-aware section mapping is per-spec (`_mapped`), a *set*;
5//! - scoring floats replicate the exact Python arithmetic (`len_req +
6//!   0.5 * len_rec`, then divide);
7//! - the sort is `sort(key=(fit, len(matched_required)), reverse=True)` —
8//!   stable, ties preserve ARTIFACT_SPECS order (reverse flips key order but
9//!   NOT equal-key runs);
10//! - `confidence = round(fit, 2)` — banker's rounding on the true double
11//!   (`pycompat::py_round`).
12
13use crate::parse::Artifact;
14use crate::pycompat::py_round;
15use crate::spec::{specs, ArtifactSpec};
16
17pub const CONFIDENCE_THRESHOLD: f64 = 0.5;
18
19/// How well a document fits one artifact type (`TypeScore`).
20#[derive(Debug, Clone)]
21pub struct TypeScore {
22    pub name: String,
23    pub matched_required: Vec<String>,
24    pub matched_recommended: Vec<String>,
25    pub missing: Vec<String>,
26    pub points: f64,
27    pub ceiling: f64,
28    pub fit: f64,
29}
30
31/// The chosen artifact type for a document (or Unknown).
32#[derive(Debug, Clone)]
33pub struct Classification {
34    /// Artifact name, or `"unknown"`.
35    pub artifact_type: String,
36    /// `round(fit, 2)`.
37    pub confidence: f64,
38    pub present_sections: Vec<String>,
39    pub missing_sections: Vec<String>,
40}
41
42/// `_mapped(product, spec)`: the document's normalized headings with this
43/// spec's synonyms applied — set semantics (duplicates collapse; membership
44/// is all that matters downstream).
45fn mapped<'a>(artifact: &'a Artifact, spec: &'a ArtifactSpec) -> Vec<&'a str> {
46    let mut out: Vec<&str> = Vec::new();
47    for (heading, _) in &artifact.product.sections {
48        let m = spec.synonym(heading).unwrap_or(heading.as_str());
49        if !out.contains(&m) {
50            out.push(m);
51        }
52    }
53    out
54}
55
56/// `missing_sections(product, spec)` -> `(missing_required, missing_recommended)`
57/// in schema declaration order, synonym-aware.
58pub fn missing_sections(artifact: &Artifact, spec: &ArtifactSpec) -> (Vec<String>, Vec<String>) {
59    let m = mapped(artifact, spec);
60    let missing_required = spec
61        .required
62        .iter()
63        .filter(|s| !m.contains(&s.as_str()))
64        .cloned()
65        .collect();
66    let missing_recommended = spec
67        .recommended
68        .iter()
69        .filter(|s| !m.contains(&s.as_str()))
70        .cloned()
71        .collect();
72    (missing_required, missing_recommended)
73}
74
75/// `score_artifacts(product)`: scores best-fit-first with the exact Python
76/// sort semantics.
77pub fn score_artifacts(artifact: &Artifact) -> Vec<TypeScore> {
78    let mut scores: Vec<TypeScore> = Vec::new();
79    for spec in specs() {
80        let m = mapped(artifact, spec);
81        let matched_required: Vec<String> = spec
82            .required
83            .iter()
84            .filter(|s| m.contains(&s.as_str()))
85            .cloned()
86            .collect();
87        let matched_recommended: Vec<String> = spec
88            .recommended
89            .iter()
90            .filter(|s| m.contains(&s.as_str()))
91            .cloned()
92            .collect();
93        // `expected` (Python property) = `required + recommended`, in that order.
94        let missing: Vec<String> = spec
95            .required
96            .iter()
97            .chain(spec.recommended.iter())
98            .filter(|s| !m.contains(&s.as_str()))
99            .cloned()
100            .collect();
101        let points = matched_required.len() as f64 + 0.5 * matched_recommended.len() as f64;
102        let ceiling = spec.required.len() as f64 + 0.5 * spec.recommended.len() as f64;
103        let fit = if ceiling != 0.0 { points / ceiling } else { 0.0 };
104        scores.push(TypeScore {
105            name: spec.name.clone(),
106            matched_required,
107            matched_recommended,
108            missing,
109            points,
110            ceiling,
111            fit,
112        });
113    }
114    // Python: scores.sort(key=lambda t: (t.fit, len(t.matched_required)),
115    // reverse=True) — descending by key, equal keys keep ORIGINAL order.
116    // Implemented as a stable sort on the descending comparison only (equal
117    // keys compare Equal, so stability preserves registry order).
118    scores.sort_by(|a, b| {
119        b.fit
120            .partial_cmp(&a.fit)
121            .unwrap()
122            .then(b.matched_required.len().cmp(&a.matched_required.len()))
123    });
124    scores
125}
126
127/// `classify(product)`.
128pub fn classify(artifact: &Artifact) -> Classification {
129    let scores = score_artifacts(artifact);
130    let best = &scores[0]; // 5 specs -> never empty
131    if best.fit < CONFIDENCE_THRESHOLD || best.matched_required.is_empty() {
132        return Classification {
133            artifact_type: "unknown".to_string(),
134            confidence: py_round(best.fit, 2),
135            present_sections: artifact
136                .product
137                .sections
138                .iter()
139                .map(|(h, _)| h.clone())
140                .collect(),
141            missing_sections: Vec::new(),
142        };
143    }
144    let mut present = best.matched_required.clone();
145    present.extend(best.matched_recommended.iter().cloned());
146    Classification {
147        artifact_type: best.name.clone(),
148        confidence: py_round(best.fit, 2),
149        present_sections: present,
150        missing_sections: best.missing.clone(),
151    }
152}