Skip to main content

blotter/commands/
verify.rs

1use crate::cli::VerifyArgs;
2use crate::commands::triage::{self, Candidate};
3use crate::error::{AppError, AppResult};
4use crate::output::{self, Meta};
5use crate::store;
6use crate::{ItemStatus, ListItem};
7use jiff::Timestamp;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10use std::path::PathBuf;
11
12#[derive(Debug, Serialize, Deserialize)]
13pub struct VerifyData {
14    pub recurrences: Vec<Recurrence>,
15    pub count: usize,
16    pub distinct_recurring_cuts: usize,
17    pub scanned: usize,
18}
19
20#[derive(Debug, Serialize, Deserialize)]
21pub struct Recurrence {
22    pub resolved_id: String,
23    pub resolved_text: String,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub origin: Option<crate::Origin>,
26    pub resolution: VerifyResolution,
27    pub recurrence_ids: Vec<String>,
28    pub count: usize,
29    pub first_recurrence_ts: String,
30}
31
32#[derive(Debug, Serialize, Deserialize)]
33pub struct VerifyResolution {
34    pub ts: String,
35    pub disposition: crate::Disposition,
36    pub disposition_ts: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub task: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub pr: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub commit: Option<String>,
43}
44
45struct ResolvedAnchor {
46    candidate: Candidate,
47    disposition_timestamp: Timestamp,
48}
49
50pub(crate) struct RecurrenceGroup {
51    pub(crate) anchor: Candidate,
52    pub(crate) members: Vec<Candidate>,
53}
54
55pub(crate) struct RecurrenceAnalysis {
56    pub(crate) recurrences: Vec<RecurrenceGroup>,
57    pub(crate) scanned: usize,
58}
59
60struct OrderedRecurrence {
61    data: RecurrenceGroup,
62    first_recurrence_timestamp: Timestamp,
63}
64
65fn is_verify_eligible(item: &ListItem) -> bool {
66    if item.kind != "cut" {
67        return false;
68    }
69
70    match item.status {
71        ItemStatus::Open => true,
72        ItemStatus::Resolved => {
73            let resolution = item
74                .resolution
75                .as_ref()
76                .expect("resolved folded items have a resolution");
77            // r48/r52: anchors are resolved cuts whose winning disposition is
78            // fixed or promoted. accepted and invalid resolved cuts are never
79            // anchors — accepted tolerates the friction on purpose, and
80            // invalid says it was never friction at all.
81            !resolution.dropped
82                && !triage::normalized_title(&item.text).is_empty()
83                && matches!(
84                    resolution.disposition,
85                    Some(crate::Disposition::Fixed) | Some(crate::Disposition::Promoted)
86                )
87        }
88    }
89}
90
91pub fn run(_args: VerifyArgs, file: Option<PathBuf>, pretty: bool) -> AppResult<i32> {
92    let resolved = store::discover(file)?;
93    let store::LoadedFold {
94        items, warnings, ..
95    } = store::load_folded(&resolved)?;
96
97    let data = verify(items);
98    let exit = i32::from(!data.recurrences.is_empty());
99    let mut meta = Meta::new();
100    meta.file = Some(resolved.path.to_string_lossy().into_owned());
101    meta.warnings = warnings;
102    output::write_success(data, pretty, meta)
103        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
104    Ok(exit)
105}
106
107fn verify(items: Vec<ListItem>) -> VerifyData {
108    let analysis = recurrence_groups(items);
109    let recurrences: Vec<_> = analysis
110        .recurrences
111        .iter()
112        .map(materialize_recurrence)
113        .collect();
114
115    // r16 makes every eligible resolved cut an independent anchor, so one open
116    // cut recurs once against each anchor it resembles. `count` is therefore
117    // the number of historical cuts that came back, and this is the number of
118    // live ones.
119    let distinct_recurring_cuts = recurrences
120        .iter()
121        .flat_map(|recurrence| recurrence.recurrence_ids.iter().map(String::as_str))
122        .collect::<BTreeSet<_>>()
123        .len();
124
125    VerifyData {
126        count: recurrences.len(),
127        distinct_recurring_cuts,
128        recurrences,
129        scanned: analysis.scanned,
130    }
131}
132
133pub(crate) fn recurrence_groups(items: Vec<ListItem>) -> RecurrenceAnalysis {
134    let mut open = Vec::new();
135    let mut anchors = Vec::new();
136
137    for item in items {
138        if !is_verify_eligible(&item) {
139            continue;
140        }
141
142        let normalized_title = triage::normalized_title(&item.text);
143        let candidate = Candidate {
144            timestamp: item
145                .ts
146                .parse()
147                .expect("folded items have valid RFC3339 timestamps"),
148            tags: item.tags.iter().cloned().collect(),
149            tokens: triage::scoring_tokens(&normalized_title),
150            normalized_title,
151            item,
152        };
153
154        match candidate.item.status {
155            ItemStatus::Open => open.push(candidate),
156            ItemStatus::Resolved => {
157                let resolution = candidate
158                    .item
159                    .resolution
160                    .as_ref()
161                    .expect("resolved folded items have a resolution");
162                anchors.push(ResolvedAnchor {
163                    disposition_timestamp: resolution
164                        .disposition_ts
165                        .as_deref()
166                        .expect("resolved cut anchors carry disposition_ts")
167                        .parse()
168                        .expect("folded resolutions have valid RFC3339 timestamps"),
169                    candidate,
170                });
171            }
172        }
173    }
174
175    open.sort_by(triage::candidate_order);
176    let scanned = open.len();
177    let frequencies = triage::corpus_frequencies(
178        open.iter()
179            .chain(anchors.iter().map(|anchor| &anchor.candidate)),
180    );
181    // The prefilter indexes the open cuts, but the frequencies stay the
182    // open-plus-anchor counts computed above. Rebuilding them over `open` alone
183    // would change document-frequency rarity — and so `linked`'s verdict — and
184    // would panic on an anchor token that no open cut carries, because the
185    // representative scored here is an anchor.
186    let index = triage::CandidateIndex::new(&open, &frequencies);
187    let mut scratch = triage::CandidateScratch::new(scanned);
188    let mut recurrences = Vec::new();
189    for anchor in anchors {
190        // `open` is sorted by (timestamp, id) and the prefilter returns
191        // positions into it, so the disposition_ts cutoff is a floor on the
192        // bitset walk rather than a second pass. Triage's
193        // `candidate <= representative` self-exclusion has no counterpart: an
194        // anchor is never a member of `open`.
195        let floor =
196            open.partition_point(|candidate| candidate.timestamp <= anchor.disposition_timestamp);
197        let recurring: Vec<_> = scratch
198            .matching_candidates(&anchor.candidate, &index, &frequencies, floor)
199            .indices_from(floor)
200            .filter(|&candidate| triage::linked(&anchor.candidate, &open[candidate], &frequencies))
201            .map(|candidate| open[candidate].clone())
202            .collect();
203        let Some(first) = recurring.first() else {
204            continue;
205        };
206        recurrences.push(OrderedRecurrence {
207            first_recurrence_timestamp: first.timestamp,
208            data: RecurrenceGroup {
209                anchor: anchor.candidate,
210                members: recurring,
211            },
212        });
213    }
214    recurrences.sort_by(|left, right| {
215        left.first_recurrence_timestamp
216            .cmp(&right.first_recurrence_timestamp)
217            .then_with(|| left.data.anchor.item.id.cmp(&right.data.anchor.item.id))
218    });
219
220    RecurrenceAnalysis {
221        recurrences: recurrences
222            .into_iter()
223            .map(|recurrence| recurrence.data)
224            .collect(),
225        scanned,
226    }
227}
228
229fn materialize_recurrence(group: &RecurrenceGroup) -> Recurrence {
230    let resolution = group
231        .anchor
232        .item
233        .resolution
234        .as_ref()
235        .expect("resolved anchors have a resolution");
236    let first = group
237        .members
238        .first()
239        .expect("recurrence groups have members");
240
241    Recurrence {
242        resolved_id: group.anchor.item.id.clone(),
243        resolved_text: group.anchor.item.text.clone(),
244        origin: group.anchor.item.origin.clone(),
245        resolution: VerifyResolution {
246            ts: resolution.ts.clone(),
247            disposition: resolution
248                .disposition
249                .expect("resolved anchors carry a disposition"),
250            disposition_ts: resolution
251                .disposition_ts
252                .clone()
253                .expect("resolved cut anchors carry disposition_ts"),
254            task: resolution.task.clone(),
255            pr: resolution.pr.clone(),
256            commit: resolution.commit.clone(),
257        },
258        recurrence_ids: group
259            .members
260            .iter()
261            .map(|candidate| candidate.item.id.clone())
262            .collect(),
263        count: group.members.len(),
264        first_recurrence_ts: first.item.ts.clone(),
265    }
266}