Skip to main content

rac_engine/
stats.rs

1//! Portfolio statistics (`decided.services.stats`), per PORT-CONTRACT.d/09 §2.
2//!
3//! Walks the corpus, classifies each file, and aggregates per family
4//! (requirement features, decisions, roadmaps, prompts, designs, unrecognized)
5//! plus declared relationship-presence counts. Pure and deterministic.
6
7use crate::classify::classify;
8use crate::parse::Artifact;
9use crate::pycompat::first_nonempty_line;
10use crate::relationships::corpus_items;
11use crate::spec::{spec_for, ArtifactSpec, RELATIONSHIP_SECTIONS};
12use crate::validate::validate;
13
14/// Per-file result for a Requirement artifact.
15pub struct FeatureStat {
16    pub path: String,
17    pub name: String,
18    pub valid: bool,
19    pub error_codes: Vec<String>,
20    pub requirements: usize,
21    pub success_metrics: usize,
22    pub risks: usize,
23}
24
25/// Per-file result for a Decision artifact.
26pub struct DecisionStat {
27    pub path: String,
28    pub name: String,
29    pub status: Option<String>,
30    pub category: Option<String>,
31}
32
33/// Lightweight validity stat for roadmap/prompt/design.
34pub struct ValidityStat {
35    pub path: String,
36    pub name: String,
37    pub valid: bool,
38    pub error_codes: Vec<String>,
39}
40
41/// Per-file result for a document that matched no known schema.
42pub struct UnrecognizedStat {
43    pub path: String,
44    pub name: String,
45    pub confidence: f64,
46}
47
48pub struct PortfolioStats {
49    pub directory: String,
50    pub features: Vec<FeatureStat>,
51    pub decisions: Vec<DecisionStat>,
52    pub roadmaps: Vec<ValidityStat>,
53    pub prompts: Vec<ValidityStat>,
54    pub designs: Vec<ValidityStat>,
55    pub unrecognized: Vec<UnrecognizedStat>,
56    /// `{canonical space section -> presence count}`, canonical order.
57    pub relationship_counts: Vec<(String, usize)>,
58}
59
60impl PortfolioStats {
61    pub fn files_found(&self) -> usize {
62        self.features.len()
63    }
64    pub fn valid_features(&self) -> usize {
65        self.features.iter().filter(|f| f.valid).count()
66    }
67    pub fn invalid_features(&self) -> usize {
68        self.features.iter().filter(|f| !f.valid).count()
69    }
70    pub fn total_requirements(&self) -> usize {
71        self.features.iter().map(|f| f.requirements).sum()
72    }
73    pub fn total_metrics(&self) -> usize {
74        self.features.iter().map(|f| f.success_metrics).sum()
75    }
76    pub fn total_risks(&self) -> usize {
77        self.features.iter().map(|f| f.risks).sum()
78    }
79    /// Names of features with zero success metrics, in walk order.
80    pub fn missing_metrics(&self) -> Vec<&str> {
81        self.features
82            .iter()
83            .filter(|f| f.success_metrics == 0)
84            .map(|f| f.name.as_str())
85            .collect()
86    }
87    pub fn missing_risks(&self) -> Vec<&str> {
88        self.features
89            .iter()
90            .filter(|f| f.risks == 0)
91            .map(|f| f.name.as_str())
92            .collect()
93    }
94    pub fn average_requirements(&self) -> f64 {
95        if self.features.is_empty() {
96            return 0.0;
97        }
98        self.total_requirements() as f64 / self.files_found() as f64
99    }
100    /// `max(features, key=(requirements, _neg_name(name)))`.
101    pub fn largest_feature(&self) -> Option<&FeatureStat> {
102        self.features.iter().reduce(|best, f| {
103            // Larger requirements wins; tie -> greater _neg_name (earliest
104            // name at the first differing code point, LONGER name when one
105            // is a prefix of the other — see neg_name_gt).
106            match f.requirements.cmp(&best.requirements) {
107                std::cmp::Ordering::Greater => f,
108                std::cmp::Ordering::Less => best,
109                std::cmp::Ordering::Equal => {
110                    if neg_name_gt(&f.name, &best.name) {
111                        f
112                    } else {
113                        best
114                    }
115                }
116            }
117        })
118    }
119    /// `sorted(features, key=(-requirements, name))`.
120    pub fn requirements_by_feature(&self) -> Vec<&FeatureStat> {
121        let mut out: Vec<&FeatureStat> = self.features.iter().collect();
122        out.sort_by(|a, b| {
123            b.requirements
124                .cmp(&a.requirements)
125                .then_with(|| a.name.cmp(&b.name))
126        });
127        out
128    }
129    pub fn invalid(&self) -> Vec<&FeatureStat> {
130        self.features.iter().filter(|f| !f.valid).collect()
131    }
132    pub fn decision_count(&self) -> usize {
133        self.decisions.len()
134    }
135    pub fn decision_status_counts(&self) -> Vec<(String, usize)> {
136        bucket(&self.decisions, |d| d.status.as_deref(), "status")
137    }
138    pub fn decision_category_counts(&self) -> Vec<(String, usize)> {
139        bucket(&self.decisions, |d| d.category.as_deref(), "category")
140    }
141    pub fn roadmap_count(&self) -> usize {
142        self.roadmaps.len()
143    }
144    pub fn valid_roadmaps(&self) -> usize {
145        self.roadmaps.iter().filter(|r| r.valid).count()
146    }
147    pub fn invalid_roadmaps(&self) -> Vec<&ValidityStat> {
148        self.roadmaps.iter().filter(|r| !r.valid).collect()
149    }
150    pub fn prompt_count(&self) -> usize {
151        self.prompts.len()
152    }
153    pub fn valid_prompts(&self) -> usize {
154        self.prompts.iter().filter(|p| p.valid).count()
155    }
156    pub fn invalid_prompts(&self) -> Vec<&ValidityStat> {
157        self.prompts.iter().filter(|p| !p.valid).collect()
158    }
159    pub fn design_count(&self) -> usize {
160        self.designs.len()
161    }
162    pub fn valid_designs(&self) -> usize {
163        self.designs.iter().filter(|d| d.valid).count()
164    }
165    pub fn invalid_designs(&self) -> Vec<&ValidityStat> {
166        self.designs.iter().filter(|d| !d.valid).collect()
167    }
168    pub fn unrecognized_count(&self) -> usize {
169        self.unrecognized.len()
170    }
171    pub fn total_artifacts(&self) -> usize {
172        self.files_found()
173            + self.decision_count()
174            + self.roadmap_count()
175            + self.prompt_count()
176            + self.design_count()
177    }
178    pub fn is_empty(&self) -> bool {
179        self.total_artifacts() == 0 && self.unrecognized_count() == 0
180    }
181    pub fn has_meaningful_content(&self) -> bool {
182        self.valid_features() > 0
183            || self.decision_count() > 0
184            || self.valid_roadmaps() > 0
185            || self.valid_prompts() > 0
186            || self.valid_designs() > 0
187    }
188}
189
190/// `_neg_name(a) > _neg_name(b)` ⇔ `a` sorts before `b` by code point
191/// (element-wise `-ord`). On a shared prefix Python tuple comparison makes
192/// the SHORTER tuple smaller — so between "Feature" and "Feature With
193/// Broken Ref" the LONGER name has the greater `_neg_name` and wins the
194/// `max()` tie.
195fn neg_name_gt(a: &str, b: &str) -> bool {
196    let mut ai = a.chars();
197    let mut bi = b.chars();
198    loop {
199        match (ai.next(), bi.next()) {
200            (Some(ca), Some(cb)) => {
201                if ca != cb {
202                    // -ord(ca) > -ord(cb)  <=>  ca < cb
203                    return (ca as u32) < (cb as u32);
204                }
205            }
206            // Prefix equal so far: Python compares the (-ord, ...) tuples,
207            // and a tuple that is a strict prefix of the other is SMALLER —
208            // so `a` is greater exactly when it is LONGER.
209            (None, Some(_)) => return false,
210            (Some(_), None) => return true,
211            (None, None) => return false,
212        }
213    }
214}
215
216/// `_bucket(decisions, attr, metadata_key)`: schema order first, then any
217/// out-of-vocabulary values in sorted (code-point) order.
218fn bucket<T>(
219    items: &[T],
220    get: impl Fn(&T) -> Option<&str>,
221    metadata_key: &str,
222) -> Vec<(String, usize)> {
223    let spec = spec_for("decision");
224    let order: &[String] = spec
225        .and_then(|s| s.metadata.iter().find(|(k, _)| k == metadata_key))
226        .map(|(_, v)| v.as_slice())
227        .unwrap_or(&[]);
228    // Count (insertion order = first-seen), like a Python dict.
229    let mut counts: Vec<(String, usize)> = Vec::new();
230    for item in items {
231        if let Some(value) = get(item) {
232            if value.is_empty() {
233                continue;
234            }
235            match counts.iter_mut().find(|(k, _)| k == value) {
236                Some((_, c)) => *c += 1,
237                None => counts.push((value.to_string(), 1)),
238            }
239        }
240    }
241    let mut ordered: Vec<(String, usize)> = Vec::new();
242    for v in order {
243        if let Some((_, c)) = counts.iter().find(|(k, _)| k == v) {
244            ordered.push((v.clone(), *c));
245        }
246    }
247    // Remaining values, sorted by code point.
248    let mut remaining: Vec<&(String, usize)> = counts
249        .iter()
250        .filter(|(k, _)| !ordered.iter().any(|(ok, _)| ok == k))
251        .collect();
252    remaining.sort_by(|a, b| a.0.cmp(&b.0));
253    for (k, c) in remaining {
254        ordered.push((k.clone(), *c));
255    }
256    ordered
257}
258
259/// `product.title or path.stem`.
260fn artifact_name(artifact: &Artifact, path: &str) -> String {
261    match &artifact.product.title {
262        Some(t) if !t.is_empty() => t.clone(),
263        _ => crate::identity::path_stem(path),
264    }
265}
266
267/// `canonical_value(raw, allowed)` — `_first_line(raw)` matched against the
268/// allowed values, casefolded.
269fn canonical_value(raw: &str, allowed: &[String]) -> String {
270    crate::spec::canonical_value(first_nonempty_line(raw), allowed)
271}
272
273/// Error-severity issue codes (`_error_codes`); no ticketing provider,
274/// no overrides (stats validates raw).
275fn error_codes(artifact: &Artifact, artifact_type: &str) -> Vec<String> {
276    validate(artifact, None, Some(artifact_type))
277        .into_iter()
278        .filter(|i| i.severity == "error")
279        .map(|i| i.code)
280        .collect()
281}
282
283/// `present_relationship_sections(product, spec)` (canonical space names in
284/// `spec.optional` order).
285fn present_relationship_sections(artifact: &Artifact, spec: &ArtifactSpec) -> Vec<String> {
286    let mut present = Vec::new();
287    for section in &spec.optional {
288        if !RELATIONSHIP_SECTIONS.iter().any(|(name, _)| name == section) {
289            continue;
290        }
291        if let Some(body) = artifact.section(section) {
292            if !body.is_empty() && !crate::relationships::parse_references(body).is_empty() {
293                present.push(section.clone());
294            }
295        }
296    }
297    present
298}
299
300/// `_attach_decision_metadata` → (status, category).
301fn decision_metadata(
302    artifact: &Artifact,
303    spec: &ArtifactSpec,
304) -> (Option<String>, Option<String>) {
305    let mut status = None;
306    let mut category = None;
307    for (field_name, allowed) in &spec.metadata {
308        if let Some(body) = artifact.section(field_name) {
309            if !body.is_empty() {
310                let value = canonical_value(body, allowed);
311                match field_name.as_str() {
312                    "status" => status = Some(value),
313                    "category" => category = Some(value),
314                    _ => {}
315                }
316            }
317        }
318    }
319    (status, category)
320}
321
322/// `collect_stats(directory)`.
323pub fn collect_stats(directory: &str) -> PortfolioStats {
324    let mut stats = PortfolioStats {
325        directory: directory.to_string(),
326        features: Vec::new(),
327        decisions: Vec::new(),
328        roadmaps: Vec::new(),
329        prompts: Vec::new(),
330        designs: Vec::new(),
331        unrecognized: Vec::new(),
332        relationship_counts: Vec::new(),
333    };
334    // Presence counts accumulated by canonical space section (first-seen order),
335    // re-ordered canonically at the end.
336    let mut rel_counts: Vec<(String, usize)> = Vec::new();
337
338    for item in corpus_items(directory, true) {
339        let artifact = &item.artifact;
340        let path = &item.path;
341        let name = artifact_name(artifact, path);
342        let classification = classify(artifact);
343        let type_name = classification.artifact_type.as_str();
344        let spec = spec_for(type_name);
345
346        if let Some(spec) = spec {
347            for section in present_relationship_sections(artifact, spec) {
348                match rel_counts.iter_mut().find(|(k, _)| *k == section) {
349                    Some((_, c)) => *c += 1,
350                    None => rel_counts.push((section, 1)),
351                }
352            }
353        }
354
355        match type_name {
356            "decision" => {
357                let (status, category) =
358                    decision_metadata(artifact, spec.expect("decision spec"));
359                stats.decisions.push(DecisionStat {
360                    path: path.clone(),
361                    name,
362                    status,
363                    category,
364                });
365            }
366            "roadmap" => {
367                let codes = error_codes(artifact, type_name);
368                stats.roadmaps.push(ValidityStat {
369                    path: path.clone(),
370                    name,
371                    valid: codes.is_empty(),
372                    error_codes: codes,
373                });
374            }
375            "prompt" => {
376                let codes = error_codes(artifact, type_name);
377                stats.prompts.push(ValidityStat {
378                    path: path.clone(),
379                    name,
380                    valid: codes.is_empty(),
381                    error_codes: codes,
382                });
383            }
384            "design" => {
385                let codes = error_codes(artifact, type_name);
386                stats.designs.push(ValidityStat {
387                    path: path.clone(),
388                    name,
389                    valid: codes.is_empty(),
390                    error_codes: codes,
391                });
392            }
393            "unknown" => {
394                stats.unrecognized.push(UnrecognizedStat {
395                    path: path.clone(),
396                    name,
397                    confidence: classification.confidence,
398                });
399            }
400            _ => {
401                let codes = error_codes(artifact, type_name);
402                stats.features.push(FeatureStat {
403                    path: path.clone(),
404                    name,
405                    valid: codes.is_empty(),
406                    error_codes: codes,
407                    requirements: artifact.product.requirements.len(),
408                    success_metrics: artifact.product.success_metrics.len(),
409                    risks: artifact.product.risks.len(),
410                });
411            }
412        }
413    }
414
415    // Canonical relationship-count order.
416    for (space_name, _) in RELATIONSHIP_SECTIONS.iter() {
417        if let Some((_, c)) = rel_counts.iter().find(|(k, _)| k == space_name) {
418            stats.relationship_counts.push((space_name.to_string(), *c));
419        }
420    }
421    stats
422}
423
424#[cfg(test)]
425mod tests {
426    use super::neg_name_gt;
427
428    /// Python compares `tuple(-ord(c) ...)` keys, where a strict-prefix
429    /// tuple is SMALLER — so between tied features the longer
430    /// prefix-sharing name wins `max()`.
431    #[test]
432    fn neg_name_prefix_tie_prefers_longer() {
433        assert!(neg_name_gt("Feature With Broken Ref", "Feature"));
434        assert!(!neg_name_gt("Feature", "Feature With Broken Ref"));
435        // plain code-point ordering still applies on the first difference
436        assert!(neg_name_gt("Alpha", "Beta"));
437        assert!(!neg_name_gt("Beta", "Alpha"));
438        assert!(!neg_name_gt("Same", "Same"));
439    }
440}