Skip to main content

specfold_core/audit/
pack.rs

1//! Review packs: documents that should be read together to judge contradictions and stale
2//! statements, grouped so each pack fits a reviewer's context budget.
3
4use crate::corpus::Corpus;
5use crate::graph::Graph;
6use crate::model::lifecycle;
7use serde::Serialize;
8use std::collections::BTreeMap;
9
10#[derive(Debug, Clone, Serialize)]
11pub struct Pack {
12    /// What ties the documents together: a governed path, or a document they all cite.
13    pub topic: String,
14    pub docs: Vec<String>,
15    pub paths: Vec<String>,
16    pub bytes: u64,
17}
18
19fn size(c: &Corpus, i: usize) -> u64 {
20    let d = &c.docs[i];
21    std::iter::once(&d.path)
22        .chain(&d.chapters)
23        .filter_map(|p| std::fs::metadata(c.cfg.root.join(p)).ok())
24        .map(|m| m.len())
25        .sum()
26}
27
28/// Groups current documents by the code they govern and by the documents they implement, then
29/// splits groups larger than `budget` bytes.
30pub fn build(c: &Corpus, g: &Graph, owners: &[String], budget: u64) -> Vec<Pack> {
31    let current = |i: usize| {
32        c.docs[i].status().as_deref().is_some_and(lifecycle::is_current)
33            && (owners.is_empty() || owners.contains(&c.docs[i].id.owner))
34    };
35    let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
36    for i in (0..c.docs.len()).filter(|&i| current(i)) {
37        for gv in c.docs[i].rel().remove("governs").unwrap_or_default() {
38            groups.entry(format!("governs {}", crate::placement::literal_base(&gv))).or_default().push(i);
39        }
40        for e in g.edges_from(i).filter(|e| e.kind == "implements") {
41            groups.entry(format!("implements {}", c.docs[e.to].id)).or_default().push(i);
42            groups.entry(format!("implements {}", c.docs[e.to].id)).or_default().push(e.to);
43        }
44    }
45    let mut packs = vec![];
46    for (topic, mut members) in groups {
47        members.sort();
48        members.dedup();
49        if members.len() < 2 {
50            continue;
51        }
52        let mut cur: Vec<usize> = vec![];
53        let mut bytes = 0;
54        for i in members {
55            let s = size(c, i);
56            if !cur.is_empty() && bytes + s > budget {
57                packs.push(finish(c, &topic, &cur, bytes));
58                cur.clear();
59                bytes = 0;
60            }
61            cur.push(i);
62            bytes += s;
63        }
64        if cur.len() > 1 {
65            packs.push(finish(c, &topic, &cur, bytes));
66        }
67    }
68    packs
69}
70
71fn finish(c: &Corpus, topic: &str, members: &[usize], bytes: u64) -> Pack {
72    Pack {
73        topic: topic.to_string(),
74        docs: members.iter().map(|&i| c.docs[i].id.to_string()).collect(),
75        paths: members.iter().map(|&i| c.docs[i].path.clone()).collect(),
76        bytes,
77    }
78}