Skip to main content

choir_cli/
triage.rs

1//! Client-side triage and next-action derivation over `/api/view`:
2//! a branch-triage and agent-state view of what needs attention.
3//!
4//! Both functions are pure folds of the view JSON the node already
5//! serves. Deriving client-side rather than adding endpoints keeps the
6//! ACL story unchanged: the node filters `/api/view` per credential
7//! (D29), so a triage computed from the filtered document can only see
8//! what its caller may see. The cost is honesty bookkeeping: a ref
9//! missing from the response is *either* not created yet or not granted
10//! — indistinguishable by design — so rows carry `landed: null` rather
11//! than a guess when the destination ref is not visible.
12//!
13//! Output is bounded: every list is capped at
14//! [`LIST_CAP`] rows ranked most-actionable-first, and every truncation
15//! is marked in-band with an `omitted` count, never silent.
16
17use std::collections::BTreeMap;
18
19/// Cap on every list in a derived document. Twenty rows of the most
20/// actionable material bounds the token cost of a poll; the full detail
21/// is always one `choir view` away.
22pub const LIST_CAP: usize = 20;
23
24/// Buckets a review can land in, most actionable first. The discriminant
25/// order is the ranking used when a capped list must choose rows.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27enum ReviewBucket {
28    /// An approval was retroactively slashed; the same `(ref, commit)`
29    /// needs a fresh review before it can authorize a landing again.
30    ReReviewRequired,
31    /// Complete with at least one standing `RequestChanges`.
32    ChangesRequested,
33    /// Approved and the destination ref does not point at the reviewed
34    /// commit. The view holds no commit graph, so "not landed yet" and
35    /// "the ref moved past it" are deliberately one bucket — deciding
36    /// between them takes ancestry only git can answer, and a guess here
37    /// would read as a fact.
38    ApprovedAwaitingLanding,
39    /// Assigned reviewers have not all answered.
40    AwaitingVerdicts,
41    /// Opened unassigned; the node's draw has not filled the list yet.
42    Unassigned,
43    /// Approved but bound to no destination ref; nothing can land it.
44    ApprovedUnbound,
45    /// The destination ref points at the reviewed commit.
46    Landed,
47    /// Settled and its bulk dropped.
48    Archived,
49}
50
51impl ReviewBucket {
52    fn name(self) -> &'static str {
53        match self {
54            Self::ReReviewRequired => "re-review-required",
55            Self::ChangesRequested => "changes-requested",
56            Self::ApprovedAwaitingLanding => "approved-awaiting-landing",
57            Self::AwaitingVerdicts => "awaiting-verdicts",
58            Self::Unassigned => "unassigned",
59            Self::ApprovedUnbound => "approved-unbound",
60            Self::Landed => "landed",
61            Self::Archived => "archived",
62        }
63    }
64}
65
66/// Facts about one review, read straight off its `/api/view` row.
67struct ReviewFacts<'a> {
68    target: Option<&'a str>,
69    target_ref: Option<&'a str>,
70    archived: bool,
71    approved: bool,
72    complete: bool,
73    re_review_required: bool,
74    reviewers: Vec<&'a str>,
75    answered: Vec<&'a str>,
76    changes_requested_by: Vec<&'a str>,
77    /// Channels holding a read receipt on the review.
78    viewed: Vec<&'a str>,
79    /// `Some(true/false)` when the destination ref is visible in the
80    /// response; `None` when it is absent — not created yet, or not
81    /// granted (D29 makes those indistinguishable on purpose).
82    landed: Option<bool>,
83}
84
85impl<'a> ReviewFacts<'a> {
86    fn read(
87        row: &'a serde_json::Value,
88        refs: Option<&'a serde_json::Map<String, serde_json::Value>>,
89    ) -> Self {
90        let target = row["target"].as_str();
91        let target_ref = row["target_ref"].as_str();
92        let reviewers: Vec<&str> = row["reviewers"]
93            .as_array()
94            .map(|list| list.iter().filter_map(serde_json::Value::as_str).collect())
95            .unwrap_or_default();
96        let verdicts = row["verdicts"].as_object();
97        let answered: Vec<&str> = verdicts
98            .map(|map| map.keys().map(String::as_str).collect())
99            .unwrap_or_default();
100        let changes_requested_by: Vec<&str> = verdicts
101            .map(|map| {
102                map.iter()
103                    .filter(|(_, v)| v["verdict"].as_str() == Some("RequestChanges"))
104                    .map(|(who, _)| who.as_str())
105                    .collect()
106            })
107            .unwrap_or_default();
108        let landed = match (target_ref, target) {
109            (Some(name), Some(commit)) => refs
110                .and_then(|map| map.get(name))
111                .map(|current| current.as_str() == Some(commit)),
112            _ => None,
113        };
114        Self {
115            target,
116            target_ref,
117            archived: row["archived"].as_bool().unwrap_or(false),
118            approved: row["approved"].as_bool().unwrap_or(false),
119            complete: row["complete"].as_bool().unwrap_or(false),
120            re_review_required: row["re_review_required"].as_bool().unwrap_or(false),
121            reviewers,
122            answered,
123            changes_requested_by,
124            viewed: row["viewed"]
125                .as_object()
126                .map(|map| map.keys().map(String::as_str).collect())
127                .unwrap_or_default(),
128            landed,
129        }
130    }
131
132    fn missing(&self) -> Vec<&'a str> {
133        self.reviewers
134            .iter()
135            .filter(|r| !self.answered.contains(r))
136            .copied()
137            .collect()
138    }
139
140    fn bucket(&self) -> ReviewBucket {
141        if self.re_review_required {
142            return ReviewBucket::ReReviewRequired;
143        }
144        if self.landed == Some(true) {
145            return ReviewBucket::Landed;
146        }
147        if self.archived {
148            return ReviewBucket::Archived;
149        }
150        if self.approved {
151            // Landed was handled above, so a bound review here is either
152            // unlanded or its ref is not visible; both are awaiting.
153            return if self.target_ref.is_none() {
154                ReviewBucket::ApprovedUnbound
155            } else {
156                ReviewBucket::ApprovedAwaitingLanding
157            };
158        }
159        if self.reviewers.is_empty() {
160            return ReviewBucket::Unassigned;
161        }
162        if !self.complete {
163            return ReviewBucket::AwaitingVerdicts;
164        }
165        ReviewBucket::ChangesRequested
166    }
167
168    fn row_json(&self, bucket: ReviewBucket) -> serde_json::Value {
169        serde_json::json!({
170            "bucket": bucket.name(),
171            "target": self.target,
172            "target_ref": self.target_ref,
173            "landed": self.landed,
174            "missing_verdicts": capped_list(&self.missing()),
175            "changes_requested_by": capped_list(&self.changes_requested_by),
176        })
177    }
178}
179
180/// The first [`LIST_CAP`] items plus an in-band `omitted` count.
181fn capped_list(items: &[&str]) -> serde_json::Value {
182    serde_json::json!({
183        "items": items.iter().take(LIST_CAP).collect::<Vec<_>>(),
184        "omitted": items.len().saturating_sub(LIST_CAP),
185    })
186}
187
188/// Reviews indexed by the hex commit they target.
189fn reviews_by_target(
190    reviews: Option<&serde_json::Map<String, serde_json::Value>>,
191) -> BTreeMap<&str, Vec<&str>> {
192    let mut by_target: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
193    for (id, row) in reviews.into_iter().flatten() {
194        if let Some(target) = row["target"].as_str() {
195            by_target.entry(target).or_default().push(id);
196        }
197    }
198    by_target
199}
200
201/// One change's bucket: `archived`, `unstarted` (no revision beyond its
202/// base), `unreviewed` (a revision no review targets), or `in-review`.
203fn change_row(
204    row: &serde_json::Value,
205    by_target: &BTreeMap<&str, Vec<&str>>,
206) -> (&'static str, Vec<String>) {
207    if row["active_workspace"].is_null() {
208        return ("archived", Vec::new());
209    }
210    let revision = row["revision_id"].as_str().unwrap_or_default();
211    if row["base_revision"].as_str() == Some(revision) {
212        return ("unstarted", Vec::new());
213    }
214    match by_target.get(revision) {
215        Some(ids) => (
216            "in-review",
217            ids.iter().take(LIST_CAP).map(ToString::to_string).collect(),
218        ),
219        None => ("unreviewed", Vec::new()),
220    }
221}
222
223/// Derives the triage document from a `/api/view` response: every review
224/// and change classified into a bucket, ranked most-actionable-first,
225/// capped and counted.
226#[must_use]
227pub fn triage(view: &serde_json::Value) -> serde_json::Value {
228    let refs = view["refs"].as_object();
229    let reviews = view["reviews"].as_object();
230    let mut rows: Vec<(ReviewBucket, &str, serde_json::Value)> = reviews
231        .into_iter()
232        .flatten()
233        .map(|(id, row)| {
234            let facts = ReviewFacts::read(row, refs);
235            let bucket = facts.bucket();
236            (bucket, id.as_str(), facts.row_json(bucket))
237        })
238        .collect();
239    rows.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
240
241    let mut bucket_counts: BTreeMap<&str, usize> = BTreeMap::new();
242    for (bucket, _, _) in &rows {
243        *bucket_counts.entry(bucket.name()).or_insert(0) += 1;
244    }
245    let review_total = rows.len();
246    let review_rows: serde_json::Map<String, serde_json::Value> = rows
247        .into_iter()
248        .take(LIST_CAP)
249        .map(|(_, id, row)| (id.to_string(), row))
250        .collect();
251
252    let by_target = reviews_by_target(reviews);
253    let changes = view["changes"].as_object();
254    let mut change_counts: BTreeMap<&str, usize> = BTreeMap::new();
255    let mut change_rows = serde_json::Map::new();
256    let change_total = changes.map_or(0, serde_json::Map::len);
257    for (id, row) in changes.into_iter().flatten() {
258        let (bucket, review_ids) = change_row(row, &by_target);
259        *change_counts.entry(bucket).or_insert(0) += 1;
260        if change_rows.len() < LIST_CAP {
261            change_rows.insert(
262                id.clone(),
263                serde_json::json!({ "bucket": bucket, "reviews": review_ids }),
264            );
265        }
266    }
267
268    serde_json::json!({
269        "format_version": 1,
270        "review_buckets": bucket_counts,
271        "reviews": review_rows,
272        "reviews_omitted": review_total.saturating_sub(LIST_CAP),
273        "change_buckets": change_counts,
274        "changes": change_rows,
275        "changes_omitted": change_total.saturating_sub(LIST_CAP),
276        // What the classification could not see: with no refs section in
277        // the (possibly ACL-filtered) response, no landing is decidable.
278        "refs_visible": refs.is_some(),
279    })
280}
281
282/// One recommended action. `mutates` and `needs_network` are independent
283/// axes (an inspection needs the network but changes nothing), and the
284/// discriminant order ranks actions by urgency: answering unblocks other
285/// people, landing rots, revising and opening reviews unblock only you.
286#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
287enum ActionKind {
288    /// A review assigned to you is waiting on your verdict.
289    AnswerReview,
290    /// Your approved review is unlanded; submit the landing.
291    Land,
292    /// A reviewer requested changes; revise and checkpoint again.
293    Revise,
294    /// Your checkpointed revision has no review; open one.
295    RequestReview,
296    /// Your change has published nothing beyond its base; checkpoint.
297    Checkpoint,
298}
299
300impl ActionKind {
301    fn describe(self) -> (&'static str, bool, bool) {
302        match self {
303            Self::AnswerReview => ("answer-review", true, true),
304            Self::Land => ("land", true, true),
305            Self::Revise => ("revise", true, false),
306            Self::RequestReview => ("request-review", true, true),
307            Self::Checkpoint => ("checkpoint", true, true),
308        }
309    }
310}
311
312fn action_json(
313    kind: ActionKind,
314    subject: (&str, &str),
315    command: String,
316    why: String,
317) -> serde_json::Value {
318    let (name, mutates, needs_network) = kind.describe();
319    let (subject_kind, subject_id) = subject;
320    serde_json::json!({
321        "action": name,
322        subject_kind: subject_id,
323        "command": command,
324        "mutates": mutates,
325        "needs_network": needs_network,
326        "why": why,
327    })
328}
329
330/// Derives one bounded next-actions document for `channel` from a
331/// `/api/view` response fetched from `api`: what you owe others, what
332/// your own changes need, and what you are waiting on, ranked. Command
333/// strings carry `<key-file>` (and other placeholders) literally where
334/// only the caller knows the value.
335#[must_use]
336pub fn next_actions(view: &serde_json::Value, api: &str, channel: &str) -> serde_json::Value {
337    let refs = view["refs"].as_object();
338    let reviews = view["reviews"].as_object();
339    let mut actions: Vec<(ActionKind, &str, serde_json::Value)> = Vec::new();
340    let mut waiting: Vec<serde_json::Value> = Vec::new();
341
342    for (id, row) in reviews.into_iter().flatten() {
343        let facts = ReviewFacts::read(row, refs);
344        if facts.archived {
345            continue;
346        }
347        if facts.reviewers.contains(&channel) && !facts.answered.contains(&channel) {
348            actions.push((
349                ActionKind::AnswerReview,
350                id,
351                action_json(
352                    ActionKind::AnswerReview,
353                    ("review", id),
354                    format!("choir verdict {api} <key-file> {channel} {id} approve|request-changes [note]"),
355                    "assigned to you and unanswered; the review cannot complete without you".into(),
356                ),
357            ));
358        }
359    }
360
361    let by_target = reviews_by_target(reviews);
362    for (change_id, row) in view["changes"].as_object().into_iter().flatten() {
363        if row["owner"].as_str() != Some(channel) || row["active_workspace"].is_null() {
364            continue;
365        }
366        let revision = row["revision_id"].as_str().unwrap_or_default();
367        let workspace = row["workspace_id"].as_str().unwrap_or_default();
368        if row["base_revision"].as_str() == Some(revision) {
369            actions.push((
370                ActionKind::Checkpoint,
371                change_id,
372                action_json(
373                    ActionKind::Checkpoint,
374                    ("change", change_id),
375                    format!("choir checkpoint {api} <key-file> {channel} {change_id} {workspace} <git-oid>"),
376                    "no revision published beyond the base; commit and push, then checkpoint".into(),
377                ),
378            ));
379            continue;
380        }
381        let Some(review_ids) = by_target.get(revision) else {
382            actions.push((
383                ActionKind::RequestReview,
384                change_id,
385                action_json(
386                    ActionKind::RequestReview,
387                    ("change", change_id),
388                    format!("choir review {api} <key-file> {channel} <review-id> {revision} [--ref <repo:ref>]"),
389                    "latest revision has no review; name no reviewers and the node draws them".into(),
390                ),
391            ));
392            continue;
393        };
394        for id in review_ids {
395            let facts = ReviewFacts::read(&reviews.expect("indexed from reviews")[*id], refs);
396            match facts.bucket() {
397                ReviewBucket::ChangesRequested | ReviewBucket::ReReviewRequired => {
398                    actions.push((
399                        ActionKind::Revise,
400                        change_id,
401                        action_json(
402                            ActionKind::Revise,
403                            ("change", change_id),
404                            format!("choir checkpoint {api} <key-file> {channel} {change_id} {workspace} <git-oid>"),
405                            format!(
406                                "review {id}: changes requested by {:?}; revise, checkpoint, request a fresh review",
407                                facts.changes_requested_by
408                            ),
409                        ),
410                    ));
411                }
412                ReviewBucket::ApprovedAwaitingLanding => {
413                    let target_ref = facts.target_ref.unwrap_or("<repo:ref>");
414                    actions.push((
415                        ActionKind::Land,
416                        change_id,
417                        action_json(
418                            ActionKind::Land,
419                            ("review", id),
420                            format!(
421                                "choir submit {api} <key-file> {channel} '{{\"format_version\":1,\"kind\":{{\"SetRef\":{{\"name\":\"{target_ref}\",\"commit\":\"{revision}\",\"prev\":<current-or-null>}}}}}}'"
422                            ),
423                            "approved and unlanded; check the ref has not moved past work \
424                             this commit lacks (the view holds no commit graph) before \
425                             landing — a protected ref needs this exact (ref, commit)"
426                                .into(),
427                        ),
428                    ));
429                }
430                ReviewBucket::AwaitingVerdicts => {
431                    let missing = facts.missing();
432                    // Which of the awaited reviewers hold a read receipt:
433                    // "read but unanswered" and "never looked" call for
434                    // different nudges.
435                    let read: Vec<&str> = missing
436                        .iter()
437                        .filter(|r| facts.viewed.contains(r))
438                        .copied()
439                        .collect();
440                    waiting.push(serde_json::json!({
441                        "review": id,
442                        "why": format!("awaiting verdicts from {missing:?}"),
443                        "read_by": read,
444                    }));
445                }
446                ReviewBucket::Unassigned => {
447                    waiting.push(serde_json::json!({
448                        "review": id,
449                        "why": "unassigned; the node's reviewer draw has not filled the list",
450                    }));
451                }
452                _ => {}
453            }
454        }
455    }
456
457    actions.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
458    let action_total = actions.len();
459    let waiting_total = waiting.len();
460    serde_json::json!({
461        "format_version": 1,
462        "channel": channel,
463        // Echoed so a signer can bind its next op without a second read.
464        "log": view["log"],
465        "actions": actions.into_iter().take(LIST_CAP).map(|(_, _, a)| a).collect::<Vec<_>>(),
466        "actions_omitted": action_total.saturating_sub(LIST_CAP),
467        "waiting": waiting.iter().take(LIST_CAP).collect::<Vec<_>>(),
468        "waiting_omitted": waiting_total.saturating_sub(LIST_CAP),
469        "refs_visible": refs.is_some(),
470    })
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    /// A view with one review in each interesting configuration, plus
478    /// refs and changes exercising the landed/moved distinction.
479    fn sample_view() -> serde_json::Value {
480        serde_json::json!({
481            "log": { "node": "01-aa", "head": null, "scope_required": false },
482            "refs": { "demo.git:refs/heads/main": "01-c1" },
483            "reviews": {
484                "r-landed": { "target": "01-c1", "target_ref": "demo.git:refs/heads/main",
485                    "reviewers": ["ops/ana"], "verdicts": { "ops/ana": { "verdict": "Approve", "note": "" } },
486                    "slashes": {}, "complete": true, "approved": true, "approval_weight": 1,
487                    "re_review_required": false, "archived": false, "comments": [] },
488                "r-moved": { "target": "01-c0", "target_ref": "demo.git:refs/heads/main",
489                    "reviewers": ["ops/ana"], "verdicts": { "ops/ana": { "verdict": "Approve", "note": "" } },
490                    "slashes": {}, "complete": true, "approved": true, "approval_weight": 1,
491                    "re_review_required": false, "archived": false, "comments": [] },
492                "r-pending": { "target": "01-c2", "target_ref": null,
493                    "reviewers": ["ops/ana", "ops/bob"],
494                    "verdicts": { "ops/ana": { "verdict": "Approve", "note": "" } },
495                    "slashes": {}, "complete": false, "approved": false, "approval_weight": 1,
496                    "re_review_required": false, "archived": false, "comments": [] },
497                "r-rejected": { "target": "01-c3", "target_ref": null,
498                    "reviewers": ["ops/bob"],
499                    "verdicts": { "ops/bob": { "verdict": "RequestChanges", "note": "no" } },
500                    "slashes": {}, "complete": true, "approved": false, "approval_weight": 0,
501                    "re_review_required": false, "archived": false, "comments": [] },
502                "r-unassigned": { "target": "01-c4", "target_ref": null,
503                    "reviewers": [], "verdicts": {}, "slashes": {}, "complete": false,
504                    "approved": false, "approval_weight": 0,
505                    "re_review_required": false, "archived": false, "comments": [] },
506            },
507            "changes": {
508                "ch-idle": { "owner": "dev/kim", "workspace_id": "ws-1",
509                    "active_workspace": "ws-1", "base_revision": "01-b0", "revision_id": "01-b0" },
510                "ch-reviewed": { "owner": "dev/kim", "workspace_id": "ws-2",
511                    "active_workspace": "ws-2", "base_revision": "01-b0", "revision_id": "01-c3" },
512            },
513        })
514    }
515
516    #[test]
517    fn buckets_cover_the_sample() {
518        let doc = triage(&sample_view());
519        let bucket = |id: &str| doc["reviews"][id]["bucket"].as_str().unwrap().to_string();
520        assert_eq!(bucket("r-landed"), "landed");
521        assert_eq!(bucket("r-moved"), "approved-awaiting-landing");
522        assert_eq!(bucket("r-pending"), "awaiting-verdicts");
523        assert_eq!(bucket("r-rejected"), "changes-requested");
524        assert_eq!(bucket("r-unassigned"), "unassigned");
525        assert_eq!(doc["review_buckets"]["landed"], 1);
526        assert_eq!(doc["reviews_omitted"], 0);
527        assert_eq!(doc["changes"]["ch-idle"]["bucket"], "unstarted");
528        assert_eq!(doc["changes"]["ch-reviewed"]["bucket"], "in-review");
529        assert_eq!(doc["refs_visible"], true);
530    }
531
532    #[test]
533    fn missing_refs_section_yields_null_landed_not_a_guess() {
534        let mut view = sample_view();
535        view.as_object_mut().unwrap().remove("refs");
536        let doc = triage(&view);
537        assert_eq!(doc["refs_visible"], false);
538        assert!(doc["reviews"]["r-landed"]["landed"].is_null());
539        assert_eq!(
540            doc["reviews"]["r-landed"]["bucket"],
541            "approved-awaiting-landing"
542        );
543    }
544
545    #[test]
546    fn reviewer_owes_a_verdict() {
547        let doc = next_actions(&sample_view(), "http://n", "ops/bob");
548        let kinds: Vec<&str> = doc["actions"]
549            .as_array()
550            .unwrap()
551            .iter()
552            .map(|a| a["action"].as_str().unwrap())
553            .collect();
554        assert_eq!(kinds, ["answer-review"]);
555        assert!(doc["actions"][0]["command"]
556            .as_str()
557            .unwrap()
558            .contains("choir verdict http://n <key-file> ops/bob r-pending"));
559    }
560
561    #[test]
562    fn owner_sees_change_work_ranked() {
563        let doc = next_actions(&sample_view(), "http://n", "dev/kim");
564        let kinds: Vec<&str> = doc["actions"]
565            .as_array()
566            .unwrap()
567            .iter()
568            .map(|a| a["action"].as_str().unwrap())
569            .collect();
570        // Revise (r-rejected targets ch-reviewed's revision) outranks the
571        // fresh checkpoint on the idle change.
572        assert_eq!(kinds, ["revise", "checkpoint"]);
573        assert_eq!(doc["actions_omitted"], 0);
574    }
575
576    #[test]
577    fn boundaries_hold_on_an_empty_view() {
578        let empty = serde_json::json!({});
579        let doc = triage(&empty);
580        assert_eq!(doc["review_buckets"], serde_json::json!({}));
581        assert_eq!(doc["refs_visible"], false);
582        let actions = next_actions(&empty, "http://n", "nobody");
583        assert_eq!(actions["actions"], serde_json::json!([]));
584        assert_eq!(actions["waiting"], serde_json::json!([]));
585    }
586}
587
588/// The contribution funnel, derived from the view.
589///
590/// Five stages, counted from what the node already serves rather than
591/// from telemetry nobody keeps: how many contributors are admitted, how
592/// many of them have opened a change, how many of those changes reached
593/// review, how many drew a verdict, and how many landed. The number that
594/// matters is not any one stage but the fall between two of them.
595///
596/// # What this deliberately cannot see
597///
598/// **S0, first contact.** Whether anybody read a page before asking for
599/// an invite is not in the view and is not inferable from it, so it is
600/// reported as `null` rather than as zero. A funnel that silently
601/// renders an unmeasured stage as zero is worse than one that admits the
602/// gap: it reads as total failure at the top, which is where a reader
603/// looks first.
604///
605/// **Whether a stage is empty or merely invisible.** The node filters
606/// `/api/view` per credential (D29), so these counts are of what the
607/// caller may see. Run as an auditor for the node-wide answer.
608///
609/// The stage names match the ones in the onboarding programme so a
610/// tripwire can be written against a number that exists.
611#[must_use]
612pub fn funnel(view: &serde_json::Value) -> serde_json::Value {
613    let object = |key: &str| {
614        view.get(key)
615            .and_then(serde_json::Value::as_object)
616            .cloned()
617            .unwrap_or_default()
618    };
619    let bindings = object("bindings");
620    let changes = object("changes");
621    let reviews = object("reviews");
622
623    // Admitted actors, by the channel their key is bound to. Counted by
624    // channel rather than by key so a contributor who rotated a key
625    // (D44) is one person, not two.
626    //
627    // `None`, not zero, when there are no bindings at all. Only a key
628    // bound by a `BindKey` op appears here; a key the operator pasted
629    // into the trusted-keys file is trusted by the node and invisible to
630    // the view, so on a file-registered node an empty map means "not
631    // measurable here" rather than "nobody was admitted". Reporting the
632    // zero was the first thing this funnel got wrong when it was pointed
633    // at a real node: it read as total failure at the stage everyone
634    // looks at first, on a node with contributors visibly past it.
635    let admitted: Option<usize> = (!bindings.is_empty()).then(|| {
636        bindings
637            .values()
638            .filter_map(|b| b.get("channel").and_then(serde_json::Value::as_str))
639            .collect::<std::collections::BTreeSet<_>>()
640            .len()
641    });
642
643    // Owners who got as far as opening a change. A subset of the
644    // admitted in every healthy case; a channel here that is not in
645    // `admitted` means a key was revoked after its work, which is
646    // interesting rather than an error, so the sets are reported and not
647    // reconciled.
648    let proposing: std::collections::BTreeSet<String> = changes
649        .values()
650        .filter_map(|c| c.get("owner").and_then(serde_json::Value::as_str))
651        .map(ToString::to_string)
652        .collect();
653
654    let checkpointed = changes
655        .values()
656        .filter(|c| c.get("revision_id") != c.get("base_revision"))
657        .count();
658    let (mut answered, mut assigned) = (0usize, 0usize);
659    for review in reviews.values() {
660        let reviewers = review
661            .get("reviewers")
662            .and_then(serde_json::Value::as_array)
663            .map(Vec::as_slice)
664            .unwrap_or_default();
665        if !reviewers.is_empty() {
666            assigned += 1;
667        }
668        if reviewers.iter().any(|who| {
669            who.as_str().is_some_and(|who| {
670                review
671                    .get("verdicts")
672                    .and_then(|v| v.get(who))
673                    .and_then(|v| v.get("verdict"))
674                    .is_some()
675            })
676        }) {
677            answered += 1;
678        }
679    }
680
681    let stage = |name: &str, boundary: &str, count: Option<usize>| {
682        serde_json::json!({
683            "stage": name,
684            "boundary": boundary,
685            "count": count,
686        })
687    };
688    let proposing = proposing.len();
689    serde_json::json!({
690        "stages": [
691            stage(
692                "S0 land",
693                "read a page before asking for an invite (not in the view)",
694                None::<usize>,
695            ),
696            stage(
697                "S1 admit",
698                "actor key bound to a channel by a BindKey op; null on a node whose keys are \
699                 registered in the operator's trusted-keys file, which the view cannot see",
700                admitted,
701            ),
702            stage("S2 equip", "opened at least one change", Some(proposing)),
703            stage("S3 propose", "change published a revision past its base", Some(checkpointed)),
704            stage("S4 review", "review has drawn reviewers", Some(assigned)),
705            stage("S5 verdict", "at least one drawn reviewer answered", Some(answered)),
706        ],
707        // Named rather than left for the reader to divide, because the
708        // fall between two stages is the whole measurement and a reader
709        // scanning six counts will not compute it.
710        "largest_fall": largest_fall(&[
711            ("S1 admit -> S2 equip", admitted, Some(proposing)),
712            ("S2 equip -> S3 propose", Some(proposing), Some(checkpointed)),
713            ("S3 propose -> S4 review", Some(checkpointed), Some(assigned)),
714            ("S4 review -> S5 verdict", Some(assigned), Some(answered)),
715        ]),
716        "note": "counts are of what this credential may read (D29); S0 is not in the view \
717                 and is reported as null rather than zero",
718    })
719}
720
721/// The steepest drop between two adjacent stages, or `null` when nothing
722/// has entered the funnel.
723///
724/// A transition out of an empty stage is skipped rather than reported as
725/// a total loss: zero of zero is not a hundred percent drop, and a node
726/// with no contributors yet would otherwise always name its first
727/// transition as the problem.
728fn largest_fall(transitions: &[(&str, Option<usize>, Option<usize>)]) -> serde_json::Value {
729    // A transition with an unmeasured end is skipped entirely rather
730    // than treated as a drop to zero: an unknown is not a loss, and
731    // naming one as the worst transition would send a reader to fix the
732    // stage that is merely invisible.
733    let worst = transitions
734        .iter()
735        .filter_map(|(name, from, to)| Some((name, (*from)?, (*to)?)))
736        .filter(|(_, from, _)| *from > 0)
737        .max_by_key(|(_, from, to)| from.saturating_sub(*to));
738    match worst {
739        Some((name, from, to)) if from > to => serde_json::json!({
740            "transition": name,
741            "from": from,
742            "to": to,
743            "lost": from - to,
744        }),
745        _ => serde_json::Value::Null,
746    }
747}
748
749#[cfg(test)]
750mod funnel_tests {
751    use super::funnel;
752
753    fn view() -> serde_json::Value {
754        serde_json::json!({
755            "bindings": {
756                "k1": { "channel": "ana/agent" },
757                "k2": { "channel": "bo/agent" },
758                "k3": { "channel": "cy/agent" },
759            },
760            "changes": {
761                "c1": { "owner": "ana/agent", "base_revision": "11-aa", "revision_id": "11-bb" },
762                "c2": { "owner": "bo/agent", "base_revision": "11-cc", "revision_id": "11-cc" },
763            },
764            "reviews": {
765                "c1": { "reviewers": ["bo/agent"], "verdicts": { "bo/agent": { "verdict": "Approve" } } },
766                "c2": { "reviewers": [], "verdicts": {} },
767            },
768        })
769    }
770
771    fn count(doc: &serde_json::Value, stage: &str) -> serde_json::Value {
772        doc["stages"]
773            .as_array()
774            .expect("stages")
775            .iter()
776            .find(|s| s["stage"] == stage)
777            .expect("named stage")["count"]
778            .clone()
779    }
780
781    #[test]
782    fn counts_each_stage_from_the_view() {
783        let doc = funnel(&view());
784        assert_eq!(count(&doc, "S1 admit"), 3);
785        assert_eq!(count(&doc, "S2 equip"), 2);
786        // Only c1 moved past its base.
787        assert_eq!(count(&doc, "S3 propose"), 1);
788        assert_eq!(count(&doc, "S4 review"), 1);
789        assert_eq!(count(&doc, "S5 verdict"), 1);
790    }
791
792    #[test]
793    fn s0_is_null_not_zero() {
794        // The stage nothing measures must not read as total failure.
795        assert!(count(&funnel(&view()), "S0 land").is_null());
796    }
797
798    #[test]
799    fn names_the_steepest_drop() {
800        let doc = funnel(&view());
801        // 2 owners opened a change, 1 published a revision: a fall of 1,
802        // tied with admit->equip, and the tie goes to the first max.
803        let fall = &doc["largest_fall"];
804        assert_eq!(fall["lost"], 1);
805        assert!(fall["transition"].as_str().is_some());
806    }
807
808    #[test]
809    fn a_file_registered_node_reports_admission_as_null_not_zero() {
810        // The shape that exposed this: a node whose keys live in the
811        // operator's trusted-keys file has no `BindKey` ops, so
812        // `bindings` is empty while people are visibly contributing.
813        // Reporting 0 there read as total failure at the first stage.
814        let file_registered = serde_json::json!({
815            "bindings": {},
816            "changes": {
817                "c1": { "owner": "ana/agent", "base_revision": "11-aa", "revision_id": "11-bb" }
818            },
819            "reviews": {
820                "c1": { "reviewers": ["bo/agent"], "verdicts": {} }
821            },
822        });
823        let doc = funnel(&file_registered);
824        assert!(
825            count(&doc, "S1 admit").is_null(),
826            "an unmeasurable stage was reported as zero"
827        );
828        assert_eq!(count(&doc, "S2 equip"), 1);
829        // And the unknown must not be named as the worst transition: the
830        // real fall here is review -> verdict.
831        assert_eq!(doc["largest_fall"]["transition"], "S4 review -> S5 verdict");
832    }
833
834    #[test]
835    fn an_empty_node_names_no_fall() {
836        // Zero of zero is not a hundred percent drop.
837        let empty = serde_json::json!({});
838        assert!(funnel(&empty)["largest_fall"].is_null());
839        assert!(count(&funnel(&empty), "S1 admit").is_null());
840        assert_eq!(count(&funnel(&empty), "S2 equip"), 0);
841    }
842
843    #[test]
844    fn a_stage_that_did_not_lose_anyone_is_not_reported_as_a_fall() {
845        let perfect = serde_json::json!({
846            "bindings": { "k1": { "channel": "ana/agent" } },
847            "changes": {
848                "c1": { "owner": "ana/agent", "base_revision": "11-aa", "revision_id": "11-bb" }
849            },
850            "reviews": {
851                "c1": { "reviewers": ["bo/agent"], "verdicts": { "bo/agent": { "verdict": "Approve" } } }
852            },
853        });
854        assert!(funnel(&perfect)["largest_fall"].is_null());
855    }
856}