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/// User-approved partial close-out (`_global` meta) bypasses the reviewed-ratio hard gate.
246#[must_use]
247pub fn partial_closeout_approved(notes: &[NoteLine]) -> bool {
248    notes.iter().any(|n| {
249        n.area_id == "_global"
250            && n.kind == "meta"
251            && n.claim.as_ref().is_some_and(|c| {
252                let lower = c.to_lowercase();
253                lower.contains("partial_closeout")
254                    || lower.contains("部分收口")
255                    || lower.contains("partial audit close")
256            })
257    })
258}
259
260#[must_use]
261pub fn format_reviewed_gate_block_reason(
262    stats: &CoverageStats,
263    config: &ScratchpadConfig,
264) -> String {
265    format!(
266        "reviewed_ratio {:.0}% is below hard threshold {:.0}% ({} of {} areas actually examined with finding/cleared; deferred-only does not count). \
267         Continue P1 on more areas, or append `_global` meta with `partial_closeout` / `部分收口` if the user explicitly approved a partial report.",
268        stats.reviewed_ratio * 100.0,
269        config.coverage_reviewed_hard_ratio * 100.0,
270        stats.areas_reviewed,
271        stats.areas_total,
272    )
273}
274
275#[must_use]
276pub fn coverage_gate(
277    inventory: &Inventory,
278    notes: &[NoteLine],
279    config: &ScratchpadConfig,
280) -> CoverageGateOutcome {
281    let stats = compute_coverage_stats(inventory, notes, config);
282
283    if stats.areas_total == 0 {
284        return CoverageGateOutcome::Allow { stats };
285    }
286
287    if stats.accounted_ratio < config.coverage_hard_ratio && config.coverage_hard_block_enabled {
288        let gaps = areas_failing_quality_gate(inventory, notes, config);
289        let reason = format_quality_gate_block_reason(&stats, &gaps, config);
290        return CoverageGateOutcome::Block { stats, reason };
291    }
292
293    if config.coverage_reviewed_hard_block_enabled
294        && stats.reviewed_ratio < config.coverage_reviewed_hard_ratio
295        && !partial_closeout_approved(notes)
296    {
297        let reason = format_reviewed_gate_block_reason(&stats, config);
298        return CoverageGateOutcome::Block { stats, reason };
299    }
300
301    if stats.accounted_ratio < config.coverage_soft_ratio {
302        let pending = stats.pending_area_ids.join(", ");
303        let warning = format!(
304            "WARNING: {} area(s) pending — continue review or scratchpad_set_area(deferred) with kind=meta reason.\n\
305             pending_area_ids: [{pending}]",
306            stats.pending_area_ids.len(),
307        );
308        return CoverageGateOutcome::Warn {
309            stats,
310            warning_text: warning,
311        };
312    }
313
314    CoverageGateOutcome::Allow { stats }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::scratchpad::schema::{InventoryArea, parse_note_line};
321    use serde_json::json;
322
323    fn inv_with_areas(areas: Vec<InventoryArea>) -> Inventory {
324        Inventory {
325            run_id: "r".into(),
326            created_at: String::new(),
327            completed_at: None,
328            scope: None,
329            areas,
330        }
331    }
332
333    #[test]
334    fn empty_deferred_without_meta_not_accounted() {
335        let inv = inv_with_areas(vec![
336            InventoryArea {
337                id: "a1".into(),
338                path: "p".into(),
339                status: AreaStatus::Deferred,
340                notes: String::new(),
341            },
342            InventoryArea {
343                id: "a2".into(),
344                path: "p".into(),
345                status: AreaStatus::Done,
346                notes: String::new(),
347            },
348        ]);
349        let notes = vec![parse_note_line(
350            &json!({"id":"n1","area_id":"a2","kind":"finding","status":"verified"}),
351            1,
352        )];
353        let stats = compute_coverage_stats(&inv, &notes, &ScratchpadConfig::default());
354        assert_eq!(stats.areas_accounted, 1);
355        assert!((stats.accounted_ratio - 0.5).abs() < f64::EPSILON);
356    }
357
358    #[test]
359    fn coverage_gate_blocks_low_accounted() {
360        let inv = inv_with_areas(vec![
361            InventoryArea {
362                id: "a1".into(),
363                path: "p".into(),
364                status: AreaStatus::Pending,
365                notes: String::new(),
366            },
367            InventoryArea {
368                id: "a2".into(),
369                path: "p".into(),
370                status: AreaStatus::Pending,
371                notes: String::new(),
372            },
373        ]);
374        let cfg = ScratchpadConfig {
375            coverage_hard_ratio: 0.6,
376            coverage_soft_ratio: 0.85,
377            ..Default::default()
378        };
379        let outcome = coverage_gate(&inv, &[], &cfg);
380        assert!(matches!(outcome, CoverageGateOutcome::Block { .. }));
381    }
382
383    #[test]
384    fn deferred_with_meta_counts_accounted() {
385        let inv = inv_with_areas(vec![InventoryArea {
386            id: "a1".into(),
387            path: "p".into(),
388            status: AreaStatus::Deferred,
389            notes: String::new(),
390        }]);
391        let notes = vec![parse_note_line(
392            &json!({"id":"n1","area_id":"a1","kind":"meta","claim":"out of scope for this sprint"}),
393            1,
394        )];
395        let stats = compute_coverage_stats(&inv, &notes, &ScratchpadConfig::default());
396        assert_eq!(stats.areas_accounted, 1);
397        assert!((stats.accounted_ratio - 1.0).abs() < f64::EPSILON);
398    }
399
400    #[test]
401    fn done_with_meta_only_not_accounted() {
402        let inv = inv_with_areas(vec![InventoryArea {
403            id: "area-types".into(),
404            path: "frontend/src/types".into(),
405            status: AreaStatus::Done,
406            notes: String::new(),
407        }]);
408        let notes = vec![parse_note_line(
409            &json!({"id":"n1","area_id":"area-types","kind":"meta","claim":"audit complete summary"}),
410            1,
411        )];
412        let cfg = ScratchpadConfig::default();
413        let stats = compute_coverage_stats(&inv, &notes, &cfg);
414        assert_eq!(stats.areas_accounted, 0);
415        let gaps = areas_failing_quality_gate(&inv, &notes, &cfg);
416        assert_eq!(gaps.len(), 1);
417        assert_eq!(gaps[0].id, "area-types");
418        let outcome = coverage_gate(&inv, &notes, &cfg);
419        if let CoverageGateOutcome::Block { reason, .. } = outcome {
420            assert!(reason.contains("area-types"));
421            assert!(reason.contains("kind=cleared"));
422        } else {
423            panic!("expected block for meta-only done area");
424        }
425    }
426
427    #[test]
428    fn coverage_gate_blocks_mass_defer_despite_full_accounted() {
429        let mut areas = Vec::new();
430        for i in 0..9 {
431            areas.push(InventoryArea {
432                id: format!("done-{i}"),
433                path: "p".into(),
434                status: AreaStatus::Done,
435                notes: String::new(),
436            });
437        }
438        for i in 0..28 {
439            areas.push(InventoryArea {
440                id: format!("def-{i}"),
441                path: "p".into(),
442                status: AreaStatus::Deferred,
443                notes: String::new(),
444            });
445        }
446        let inv = inv_with_areas(areas);
447        let mut notes = Vec::new();
448        for i in 0..9 {
449            notes.push(parse_note_line(
450                &json!({"id":format!("f-{i}"),"area_id":format!("done-{i}"),"kind":"finding","status":"verified"}),
451                i + 1,
452            ));
453        }
454        for i in 0..28 {
455            notes.push(parse_note_line(
456                &json!({"id":format!("m-{i}"),"area_id":format!("def-{i}"),"kind":"meta","claim":"deferred: session limit"}),
457                100 + i,
458            ));
459        }
460        let cfg = ScratchpadConfig::default();
461        let stats = compute_coverage_stats(&inv, &notes, &cfg);
462        assert!((stats.accounted_ratio - 1.0).abs() < f64::EPSILON);
463        assert!(stats.reviewed_ratio < 0.40);
464        let outcome = coverage_gate(&inv, &notes, &cfg);
465        assert!(matches!(outcome, CoverageGateOutcome::Block { .. }));
466    }
467
468    #[test]
469    fn partial_closeout_bypasses_reviewed_hard_gate() {
470        let inv = inv_with_areas(vec![
471            InventoryArea {
472                id: "done-0".into(),
473                path: "p".into(),
474                status: AreaStatus::Done,
475                notes: String::new(),
476            },
477            InventoryArea {
478                id: "def-0".into(),
479                path: "p".into(),
480                status: AreaStatus::Deferred,
481                notes: String::new(),
482            },
483        ]);
484        let notes = vec![
485            parse_note_line(
486                &json!({"id":"f1","area_id":"done-0","kind":"finding","status":"verified"}),
487                1,
488            ),
489            parse_note_line(
490                &json!({"id":"m1","area_id":"def-0","kind":"meta","claim":"deferred: time"}),
491                2,
492            ),
493            parse_note_line(
494                &json!({"id":"pc","area_id":"_global","kind":"meta","claim":"partial_closeout: user approved partial report"}),
495                3,
496            ),
497        ];
498        let outcome = coverage_gate(&inv, &notes, &ScratchpadConfig::default());
499        assert!(matches!(outcome, CoverageGateOutcome::Allow { .. }));
500    }
501}