Skip to main content

zagens_runtime_adapters/scratchpad/
coverage.rs

1//! Phase C1: coverage ratios, area quality gates, and P2 admission.
2
3use crate::scratchpad::config::ScratchpadConfig;
4use crate::scratchpad::schema::{AreaStatus, Inventory, NoteLine};
5use crate::scratchpad::summary::compute_superseded_ids;
6
7/// Coverage metrics for inventory + notes (§6.12.4).
8#[derive(Debug, Clone)]
9pub struct CoverageStats {
10    pub areas_total: usize,
11    pub areas_accounted: usize,
12    pub areas_reviewed: usize,
13    pub accounted_ratio: f64,
14    pub reviewed_ratio: f64,
15    pub pending_area_ids: Vec<String>,
16    pub deferred_areas: Vec<DeferredAreaSummary>,
17    pub verified_findings: usize,
18}
19
20#[derive(Debug, Clone)]
21pub struct DeferredAreaSummary {
22    pub id: String,
23    pub reason_excerpt: String,
24}
25
26/// One inventory row that is `done`/`deferred` but fails C1 quality gates.
27#[derive(Debug, Clone)]
28pub struct AreaQualityGap {
29    pub id: String,
30    pub status: String,
31    pub fix: String,
32}
33
34#[derive(Debug, Clone)]
35pub enum CoverageGateOutcome {
36    Allow {
37        stats: CoverageStats,
38    },
39    Warn {
40        stats: CoverageStats,
41        warning_text: String,
42    },
43    Block {
44        stats: CoverageStats,
45        reason: String,
46    },
47}
48
49/// `done` area counts as reviewed/accounted when it has ≥1 finding or cleared note.
50#[must_use]
51pub fn area_meets_done_quality(area_id: &str, notes: &[NoteLine]) -> bool {
52    notes
53        .iter()
54        .any(|n| n.area_id == area_id && (n.kind == "finding" || n.kind == "cleared"))
55}
56
57/// `deferred` area counts as accounted when it has ≥1 meta note with non-empty claim.
58#[must_use]
59pub fn area_meets_deferred_quality(area_id: &str, notes: &[NoteLine]) -> bool {
60    notes.iter().any(|n| {
61        n.area_id == area_id
62            && n.kind == "meta"
63            && n.claim.as_ref().is_some_and(|c| !c.trim().is_empty())
64    })
65}
66
67#[must_use]
68pub fn compute_coverage_stats(
69    inventory: &Inventory,
70    notes: &[NoteLine],
71    config: &ScratchpadConfig,
72) -> CoverageStats {
73    let superseded = compute_superseded_ids(notes);
74    let verified_findings = notes
75        .iter()
76        .filter(|n| {
77            n.kind == "finding"
78                && n.status.eq_ignore_ascii_case("verified")
79                && !superseded.contains(&n.id)
80        })
81        .count();
82
83    let areas_total = inventory.areas.len();
84    let mut areas_accounted = 0usize;
85    let mut areas_reviewed = 0usize;
86    let mut pending_area_ids = Vec::new();
87    let mut deferred_areas = Vec::new();
88
89    for area in &inventory.areas {
90        match area.status {
91            AreaStatus::Pending | AreaStatus::InProgress => {
92                pending_area_ids.push(area.id.clone());
93            }
94            AreaStatus::Done => {
95                if area_meets_done_quality(&area.id, notes) {
96                    areas_accounted += 1;
97                    areas_reviewed += 1;
98                }
99            }
100            AreaStatus::Deferred => {
101                if config.coverage_count_deferred_as_accounted
102                    && area_meets_deferred_quality(&area.id, notes)
103                {
104                    areas_accounted += 1;
105                    if let Some(reason) = deferred_reason_excerpt(&area.id, notes) {
106                        deferred_areas.push(DeferredAreaSummary {
107                            id: area.id.clone(),
108                            reason_excerpt: reason,
109                        });
110                    }
111                }
112            }
113        }
114    }
115
116    let accounted_ratio = ratio(areas_accounted, areas_total);
117    let reviewed_ratio = ratio(areas_reviewed, areas_total);
118
119    CoverageStats {
120        areas_total,
121        areas_accounted,
122        areas_reviewed,
123        accounted_ratio,
124        reviewed_ratio,
125        pending_area_ids,
126        deferred_areas,
127        verified_findings,
128    }
129}
130
131fn ratio(num: usize, den: usize) -> f64 {
132    if den == 0 {
133        1.0
134    } else {
135        num as f64 / den as f64
136    }
137}
138
139fn deferred_reason_excerpt(area_id: &str, notes: &[NoteLine]) -> Option<String> {
140    notes
141        .iter()
142        .filter(|n| n.area_id == area_id && n.kind == "meta")
143        .find_map(|n| n.claim.as_ref().filter(|c| !c.trim().is_empty()))
144        .map(|c| {
145            let t = c.trim();
146            if t.chars().count() > 120 {
147                let head: String = t.chars().take(120).collect();
148                format!("{head}…")
149            } else {
150                t.to_string()
151            }
152        })
153}
154
155/// L0-only line for compaction handoff (§6.12.3) — not full layered summary.
156#[must_use]
157pub fn build_l0_status_line(run_id: &str, stats: &CoverageStats, resume_area_id: &str) -> String {
158    let accounted_pct = (stats.accounted_ratio * 100.0).round() as u32;
159    format!(
160        "run_id={run_id} areas {}/{} accounted ({}%), {} reviewed; resume_area_id={}; verified_findings={}",
161        stats.areas_accounted,
162        stats.areas_total,
163        accounted_pct,
164        stats.areas_reviewed,
165        resume_area_id,
166        stats.verified_findings,
167    )
168}
169
170#[must_use]
171pub fn resume_area_id_from_inventory(inventory: &Inventory) -> String {
172    inventory
173        .areas
174        .iter()
175        .find(|a| matches!(a.status, AreaStatus::Pending | AreaStatus::InProgress))
176        .map(|a| a.id.as_str())
177        .unwrap_or("none")
178        .to_string()
179}
180
181/// Inventory rows marked closed (`done`/`deferred`) that do not count toward `areas_accounted`.
182#[must_use]
183pub fn areas_failing_quality_gate(
184    inventory: &Inventory,
185    notes: &[NoteLine],
186    config: &ScratchpadConfig,
187) -> Vec<AreaQualityGap> {
188    let mut gaps = Vec::new();
189    for area in &inventory.areas {
190        match area.status {
191            AreaStatus::Done if !area_meets_done_quality(&area.id, notes) => {
192                gaps.push(AreaQualityGap {
193                    id: area.id.clone(),
194                    status: "done".into(),
195                    fix: "scratchpad_append kind=finding or kind=cleared (meta-only notes do not count for done)".into(),
196                });
197            }
198            AreaStatus::Deferred
199                if config.coverage_count_deferred_as_accounted
200                    && !area_meets_deferred_quality(&area.id, notes) =>
201            {
202                gaps.push(AreaQualityGap {
203                    id: area.id.clone(),
204                    status: "deferred".into(),
205                    fix: "scratchpad_append kind=meta with non-empty claim (defer reason)".into(),
206                });
207            }
208            _ => {}
209        }
210    }
211    gaps
212}
213
214#[must_use]
215pub fn format_quality_gate_block_reason(
216    stats: &CoverageStats,
217    gaps: &[AreaQualityGap],
218    config: &ScratchpadConfig,
219) -> String {
220    let mut reason = format!(
221        "accounted_ratio {:.0}% is below hard threshold {:.0}% ({} of {} areas meet quality gates)",
222        stats.accounted_ratio * 100.0,
223        config.coverage_hard_ratio * 100.0,
224        stats.areas_accounted,
225        stats.areas_total,
226    );
227    if !gaps.is_empty() {
228        reason.push_str("; areas failing quality gates:");
229        for gap in gaps.iter().take(12) {
230            reason.push_str(&format!("\n- {} ({}) — {}", gap.id, gap.status, gap.fix));
231        }
232        if gaps.len() > 12 {
233            reason.push_str(&format!("\n- … and {} more", gaps.len() - 12));
234        }
235    } else if !stats.pending_area_ids.is_empty() {
236        reason.push_str("; finish pending areas or mark deferred with kind=meta reason");
237    } else {
238        reason.push_str(
239            "; for each done area without findings use kind=cleared, not meta-only summaries",
240        );
241    }
242    reason
243}
244
245#[must_use]
246pub fn coverage_gate(
247    inventory: &Inventory,
248    notes: &[NoteLine],
249    config: &ScratchpadConfig,
250) -> CoverageGateOutcome {
251    let stats = compute_coverage_stats(inventory, notes, config);
252
253    if stats.areas_total == 0 {
254        return CoverageGateOutcome::Allow { stats };
255    }
256
257    if stats.accounted_ratio < config.coverage_hard_ratio && config.coverage_hard_block_enabled {
258        let gaps = areas_failing_quality_gate(inventory, notes, config);
259        let reason = format_quality_gate_block_reason(&stats, &gaps, config);
260        return CoverageGateOutcome::Block { stats, reason };
261    }
262
263    if stats.accounted_ratio < config.coverage_soft_ratio {
264        let pending = stats.pending_area_ids.join(", ");
265        let warning = format!(
266            "WARNING: {} area(s) pending — continue review or scratchpad_set_area(deferred) with kind=meta reason.\n\
267             pending_area_ids: [{pending}]",
268            stats.pending_area_ids.len(),
269        );
270        return CoverageGateOutcome::Warn {
271            stats,
272            warning_text: warning,
273        };
274    }
275
276    CoverageGateOutcome::Allow { stats }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::scratchpad::schema::{InventoryArea, parse_note_line};
283    use serde_json::json;
284
285    fn inv_with_areas(areas: Vec<InventoryArea>) -> Inventory {
286        Inventory {
287            run_id: "r".into(),
288            created_at: String::new(),
289            completed_at: None,
290            scope: None,
291            areas,
292        }
293    }
294
295    #[test]
296    fn empty_deferred_without_meta_not_accounted() {
297        let inv = inv_with_areas(vec![
298            InventoryArea {
299                id: "a1".into(),
300                path: "p".into(),
301                status: AreaStatus::Deferred,
302                notes: String::new(),
303            },
304            InventoryArea {
305                id: "a2".into(),
306                path: "p".into(),
307                status: AreaStatus::Done,
308                notes: String::new(),
309            },
310        ]);
311        let notes = vec![parse_note_line(
312            &json!({"id":"n1","area_id":"a2","kind":"finding","status":"verified"}),
313            1,
314        )];
315        let stats = compute_coverage_stats(&inv, &notes, &ScratchpadConfig::default());
316        assert_eq!(stats.areas_accounted, 1);
317        assert!((stats.accounted_ratio - 0.5).abs() < f64::EPSILON);
318    }
319
320    #[test]
321    fn coverage_gate_blocks_low_accounted() {
322        let inv = inv_with_areas(vec![
323            InventoryArea {
324                id: "a1".into(),
325                path: "p".into(),
326                status: AreaStatus::Pending,
327                notes: String::new(),
328            },
329            InventoryArea {
330                id: "a2".into(),
331                path: "p".into(),
332                status: AreaStatus::Pending,
333                notes: String::new(),
334            },
335        ]);
336        let cfg = ScratchpadConfig {
337            coverage_hard_ratio: 0.6,
338            coverage_soft_ratio: 0.85,
339            ..Default::default()
340        };
341        let outcome = coverage_gate(&inv, &[], &cfg);
342        assert!(matches!(outcome, CoverageGateOutcome::Block { .. }));
343    }
344
345    #[test]
346    fn deferred_with_meta_counts_accounted() {
347        let inv = inv_with_areas(vec![InventoryArea {
348            id: "a1".into(),
349            path: "p".into(),
350            status: AreaStatus::Deferred,
351            notes: String::new(),
352        }]);
353        let notes = vec![parse_note_line(
354            &json!({"id":"n1","area_id":"a1","kind":"meta","claim":"out of scope for this sprint"}),
355            1,
356        )];
357        let stats = compute_coverage_stats(&inv, &notes, &ScratchpadConfig::default());
358        assert_eq!(stats.areas_accounted, 1);
359        assert!((stats.accounted_ratio - 1.0).abs() < f64::EPSILON);
360    }
361
362    #[test]
363    fn done_with_meta_only_not_accounted() {
364        let inv = inv_with_areas(vec![InventoryArea {
365            id: "area-types".into(),
366            path: "frontend/src/types".into(),
367            status: AreaStatus::Done,
368            notes: String::new(),
369        }]);
370        let notes = vec![parse_note_line(
371            &json!({"id":"n1","area_id":"area-types","kind":"meta","claim":"audit complete summary"}),
372            1,
373        )];
374        let cfg = ScratchpadConfig::default();
375        let stats = compute_coverage_stats(&inv, &notes, &cfg);
376        assert_eq!(stats.areas_accounted, 0);
377        let gaps = areas_failing_quality_gate(&inv, &notes, &cfg);
378        assert_eq!(gaps.len(), 1);
379        assert_eq!(gaps[0].id, "area-types");
380        let outcome = coverage_gate(&inv, &notes, &cfg);
381        if let CoverageGateOutcome::Block { reason, .. } = outcome {
382            assert!(reason.contains("area-types"));
383            assert!(reason.contains("kind=cleared"));
384        } else {
385            panic!("expected block for meta-only done area");
386        }
387    }
388}