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