Skip to main content

rac_engine/
portfolio.rs

1//! Repository intelligence summary (`decided.services.portfolio`), the byte-derived
2//! core `decided review` composes. Walk -> per-artifact validation/completeness ->
3//! relationship summary + gate -> attention items + health score.
4
5use crate::classify::missing_sections;
6use crate::pycompat::py_round;
7use crate::relationships::{
8    summary_from_rows, validation_from_rows, validation_row, CorpusItem, RelationshipSummary,
9    ValidationRow, ISSUE_SELF_REFERENCE, ISSUE_TARGET_AMBIGUOUS, ISSUE_TARGET_NOT_FOUND,
10};
11use crate::validate::{apply_overrides, has_errors, load_overrides, py_title, validate, SeverityOverrides};
12
13// Stable attention codes (JSON contract, ADR-007).
14pub const ATTENTION_INVALID: &str = "invalid-artifact";
15pub const ATTENTION_MISSING_RECOMMENDED: &str = "missing-recommended-sections";
16pub const ATTENTION_BROKEN_RELATIONSHIP: &str = "broken-relationship";
17
18/// `by_type` insertion order: the five specs then unknown.
19const BY_TYPE_ORDER: [&str; 6] = [
20    "requirement",
21    "decision",
22    "roadmap",
23    "prompt",
24    "design",
25    "unknown",
26];
27
28#[derive(Debug, Clone)]
29pub struct AttentionItem {
30    pub path: String,
31    pub identifier: String,
32    pub severity: String,
33    pub code: String,
34    pub message: String,
35}
36
37#[derive(Debug)]
38pub struct PortfolioSummary {
39    pub directory: String,
40    pub recursive: bool,
41    /// Ordered `{type: count}` including unknown.
42    pub by_type: Vec<(String, usize)>,
43    pub valid_artifacts: usize,
44    pub invalid_artifacts: usize,
45    pub recommended_slots: usize,
46    pub filled_slots: usize,
47    pub relationships: RelationshipSummary,
48    pub attention: Vec<AttentionItem>,
49    pub unknown_paths: Vec<String>,
50    pub relationships_ok: bool,
51}
52
53impl PortfolioSummary {
54    pub fn total_artifacts(&self) -> usize {
55        self.by_type.iter().map(|(_, c)| c).sum()
56    }
57
58    pub fn completeness(&self) -> f64 {
59        if self.recommended_slots == 0 {
60            return 1.0;
61        }
62        py_round(self.filled_slots as f64 / self.recommended_slots as f64, 4)
63    }
64
65    pub fn health_score(&self) -> i64 {
66        let total = self.total_artifacts();
67        let validity = if total != 0 {
68            self.valid_artifacts as f64 / total as f64
69        } else {
70            1.0
71        };
72        let completeness = self.completeness();
73        let checked = self.relationships.total;
74        let rel_integrity = if checked != 0 {
75            (checked - self.relationships.broken) as f64 / checked as f64
76        } else {
77            1.0
78        };
79        let raw = 0.5 * validity + 0.25 * completeness + 0.25 * rel_integrity;
80        py_round(100.0 * raw, 0) as i64
81    }
82}
83
84/// Per-artifact projection matching `PortfolioRow`.
85#[derive(Clone)]
86pub struct PortfolioRow {
87    path: String,
88    artifact_type: String,
89    identifier: String,
90    validation: ValidationRow,
91    validate_issues: Vec<crate::parse::Issue>,
92    recommended_slots: usize,
93    missing_recommended: Vec<String>,
94}
95
96pub fn portfolio_row(item: &CorpusItem) -> PortfolioRow {
97    let path = item.path.clone();
98    let artifact_type = item
99        .spec
100        .map(|s| s.name.clone())
101        .unwrap_or_else(|| "unknown".to_string());
102    let vrow = validation_row(&path, &item.artifact, item.spec);
103    match item.spec {
104        None => PortfolioRow {
105            path,
106            artifact_type,
107            identifier: vrow.canonical_id.clone(),
108            validation: vrow,
109            validate_issues: Vec::new(),
110            recommended_slots: 0,
111            missing_recommended: Vec::new(),
112        },
113        Some(spec) => {
114            let (_, missing_rec) = missing_sections(&item.artifact, spec);
115            let identifier =
116                crate::identity::artifact_identifier(&item.artifact, item.spec, &path);
117            PortfolioRow {
118                path,
119                artifact_type: artifact_type.clone(),
120                identifier,
121                validation: vrow,
122                validate_issues: validate(&item.artifact, None, Some(&artifact_type)),
123                recommended_slots: spec.recommended.len(),
124                missing_recommended: missing_rec,
125            }
126        }
127    }
128}
129
130fn rel_issue_phrase(code: &str) -> &'static str {
131    match code {
132        ISSUE_TARGET_NOT_FOUND => "references missing artifact",
133        ISSUE_TARGET_AMBIGUOUS => "has an ambiguous reference to",
134        ISSUE_SELF_REFERENCE => "references itself via",
135        _ => "has an unresolved reference",
136    }
137}
138
139/// `portfolio_from_corpus(directory, entries, recursive)`.
140pub fn portfolio_from_corpus(
141    directory: &str,
142    items: &[CorpusItem],
143    recursive: bool,
144) -> PortfolioSummary {
145    let rows: Vec<PortfolioRow> = items.iter().map(portfolio_row).collect();
146    portfolio_from_rows(directory, &rows, recursive)
147}
148
149pub fn portfolio_from_rows(
150    directory: &str,
151    rows: &[PortfolioRow],
152    recursive: bool,
153) -> PortfolioSummary {
154    let validation_rows: Vec<ValidationRow> = rows.iter().map(|r| r.validation.clone()).collect();
155    let overrides: SeverityOverrides = load_overrides(directory);
156
157    let mut by_type: Vec<(String, usize)> =
158        BY_TYPE_ORDER.iter().map(|t| (t.to_string(), 0)).collect();
159    let bump = |by_type: &mut Vec<(String, usize)>, t: &str| {
160        if let Some(entry) = by_type.iter_mut().find(|(k, _)| k == t) {
161            entry.1 += 1;
162        } else {
163            by_type.push((t.to_string(), 1));
164        }
165    };
166
167    let mut valid_count = 0usize;
168    let mut invalid_count = 0usize;
169    let mut recommended_slots = 0usize;
170    let mut filled_slots = 0usize;
171    let mut attention: Vec<AttentionItem> = Vec::new();
172    let mut unknown_paths: Vec<String> = Vec::new();
173    let mut path_to_identifier: std::collections::HashMap<String, String> =
174        std::collections::HashMap::new();
175
176    for row in rows {
177        bump(&mut by_type, &row.artifact_type);
178        if row.validation.spec_name.is_none() {
179            unknown_paths.push(row.path.clone());
180            continue;
181        }
182        path_to_identifier.insert(row.path.clone(), row.identifier.clone());
183
184        let issues = apply_overrides(row.validate_issues.clone(), &row.artifact_type, &overrides);
185        if has_errors(&issues) {
186            invalid_count += 1;
187            let error_codes: Vec<String> = issues
188                .iter()
189                .filter(|i| i.severity == "error")
190                .map(|i| i.code.clone())
191                .collect();
192            attention.push(AttentionItem {
193                path: row.path.clone(),
194                identifier: row.identifier.clone(),
195                severity: "error".to_string(),
196                code: ATTENTION_INVALID.to_string(),
197                message: format!("Validation errors: {}", error_codes.join(", ")),
198            });
199        } else {
200            valid_count += 1;
201        }
202
203        let slots = row.recommended_slots;
204        recommended_slots += slots;
205        let missing_rec = &row.missing_recommended;
206        filled_slots += slots - missing_rec.len();
207        if !missing_rec.is_empty() {
208            let names: Vec<String> = missing_rec.iter().map(|s| py_title(s)).collect();
209            attention.push(AttentionItem {
210                path: row.path.clone(),
211                identifier: row.identifier.clone(),
212                severity: "warning".to_string(),
213                code: ATTENTION_MISSING_RECOMMENDED.to_string(),
214                message: format!("Missing recommended sections: {}", names.join(", ")),
215            });
216        }
217    }
218
219    let rel_summary = summary_from_rows(&validation_rows);
220    let relationships_ok =
221        validation_from_rows(directory, &validation_rows, recursive).ok();
222
223    for issue in &rel_summary.issues {
224        let source = issue.source_path.clone().unwrap_or_default();
225        let label = py_title(&issue.relationship.clone().unwrap_or_default().replace('_', " "));
226        let phrase = rel_issue_phrase(&issue.code);
227        let identifier = path_to_identifier
228            .get(&source)
229            .cloned()
230            .unwrap_or_else(|| source.clone());
231        attention.push(AttentionItem {
232            path: source,
233            identifier,
234            severity: "warning".to_string(),
235            code: ATTENTION_BROKEN_RELATIONSHIP.to_string(),
236            message: format!(
237                "{label} {phrase}: {}",
238                issue.target.clone().unwrap_or_default()
239            ),
240        });
241    }
242
243    // Sort: errors before warnings, then path, then code.
244    let sev_order = |s: &str| match s {
245        "error" => 0,
246        "warning" => 1,
247        _ => 2,
248    };
249    attention.sort_by(|a, b| {
250        sev_order(&a.severity)
251            .cmp(&sev_order(&b.severity))
252            .then(a.path.cmp(&b.path))
253            .then(a.code.cmp(&b.code))
254    });
255
256    PortfolioSummary {
257        directory: directory.to_string(),
258        recursive,
259        by_type,
260        valid_artifacts: valid_count,
261        invalid_artifacts: invalid_count,
262        recommended_slots,
263        filled_slots,
264        relationships: rel_summary,
265        attention,
266        unknown_paths,
267        relationships_ok,
268    }
269}