Skip to main content

ljos_cli/
converge.rs

1//! A panel closes itself. Once the substance has passed, a nit, a wording
2//! note or a style note is written on the issue and never becomes a child.
3//! A persona that wants a child marks the objection blocking and gives a
4//! reason; the default, with no mark or a mark and no reason, is a nit.
5//! The same objection is not filed twice. A dissent child does not file
6//! a grandchild unless the new objection is blocking. At the round limit,
7//! or when the budget is spent, the item closes as settled-with-notes and
8//! lists what remains. Nothing here waits on a person.
9
10use anyhow::{Context, Result};
11use serde::Deserialize;
12use serde_json::Value;
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::PathBuf;
15
16/// Severities that stay on the issue as notes once the substance has settled.
17pub const BELOW_CHILD: &[&str] = &["nit", "wording", "style"];
18
19/// How serious an objection is. A child is filed only for [`Severity::Blocking`]
20/// with a non-empty reason, or for a held minority on an unsettled issue
21/// that is not itself a dissent child.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Severity {
24    Nit,
25    Wording,
26    Style,
27    Blocking,
28}
29
30impl Severity {
31    #[must_use]
32    pub fn parse(word: &str) -> Option<Self> {
33        match word.trim().to_ascii_lowercase().as_str() {
34            "nit" => Some(Self::Nit),
35            "wording" => Some(Self::Wording),
36            "style" => Some(Self::Style),
37            "blocking" => Some(Self::Blocking),
38            _ => None,
39        }
40    }
41
42    #[must_use]
43    pub fn word(self) -> &'static str {
44        match self {
45            Self::Nit => "nit",
46            Self::Wording => "wording",
47            Self::Style => "style",
48            Self::Blocking => "blocking",
49        }
50    }
51
52    /// Below the line that files a child.
53    #[must_use]
54    pub fn is_note(self) -> bool {
55        !matches!(self, Self::Blocking)
56    }
57}
58
59/// One objection a persona raised against a target (an issue, or a span on it).
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Objection {
62    pub persona: String,
63    pub severity: Severity,
64    /// Required for a blocking mark. Empty means the mark did not take,
65    /// and the objection is a nit.
66    pub reason: String,
67    pub text: String,
68    pub target: String,
69    /// A ballot that still holds a minority option on an issue whose
70    /// substance has not settled. Filed once, at the top of a tree only.
71    pub held: bool,
72}
73
74impl Objection {
75    #[must_use]
76    pub fn nit(persona: &str, text: &str, target: &str) -> Self {
77        Self::noted(persona, Severity::Nit, "", text, target)
78    }
79
80    #[must_use]
81    pub fn wording(persona: &str, text: &str, target: &str) -> Self {
82        Self::noted(persona, Severity::Wording, "", text, target)
83    }
84
85    #[must_use]
86    pub fn style(persona: &str, text: &str, target: &str) -> Self {
87        Self::noted(persona, Severity::Style, "", text, target)
88    }
89
90    #[must_use]
91    pub fn blocking(persona: &str, reason: &str, text: &str, target: &str) -> Self {
92        Self::noted(persona, Severity::Blocking, reason, text, target)
93    }
94
95    fn noted(persona: &str, severity: Severity, reason: &str, text: &str, target: &str) -> Self {
96        let reason = reason.trim().to_string();
97        let severity = if severity == Severity::Blocking && reason.is_empty() {
98            Severity::Nit
99        } else {
100            severity
101        };
102        Self {
103            persona: persona.trim().to_string(),
104            severity,
105            reason,
106            text: text.trim().to_string(),
107            target: target.trim().to_string(),
108            held: false,
109        }
110    }
111
112    /// A blocking mark with a reason. Anything else stays a note.
113    #[must_use]
114    pub fn files_as_child(&self) -> bool {
115        self.severity == Severity::Blocking && !self.reason.trim().is_empty()
116    }
117
118    /// Whether this objection may become a child from here.
119    /// A dissent child (`depth` >= 1) files only a blocking objection.
120    #[must_use]
121    pub fn may_file(&self, passed: bool, depth: usize) -> bool {
122        if self.files_as_child() {
123            return true;
124        }
125        self.held && !passed && depth == 0
126    }
127
128    /// Normalized text plus normalized target. Two objections with this
129    /// pair are the same objection.
130    #[must_use]
131    pub fn key(&self) -> (String, String) {
132        (normalize(&self.text), normalize(&self.target))
133    }
134}
135
136/// Collapse case, punctuation and whitespace so a rephrased comma is the
137/// same objection as the last round's comma.
138#[must_use]
139pub fn normalize(text: &str) -> String {
140    text.chars()
141        .map(|c| {
142            if c.is_ascii_alphanumeric() {
143                c.to_ascii_lowercase()
144            } else {
145                ' '
146            }
147        })
148        .collect::<String>()
149        .split_whitespace()
150        .collect::<Vec<_>>()
151        .join(" ")
152}
153
154/// `finding: NAME: TEXT` is a nit. `finding: NAME: wording: TEXT` and
155/// `finding: NAME: style: TEXT` stay notes. `finding: NAME: blocking: REASON :: TEXT`
156/// files a child. A blocking mark with no reason is a nit. `@ target` at
157/// the end names the target; otherwise `default_target` is the target.
158#[must_use]
159pub fn parse_objection(line: &str, default_target: &str) -> Option<Objection> {
160    let line = line.trim().trim_start_matches('-').trim();
161    let rest = line
162        .strip_prefix("finding:")
163        .or_else(|| line.strip_prefix("objection:"))
164        .map(str::trim)
165        .filter(|s| !s.is_empty())?;
166    let (persona, rest) = rest.split_once(':')?;
167    let persona = persona.trim();
168    if persona.is_empty() {
169        return None;
170    }
171    let rest = rest.trim();
172    let (severity, rest) = match rest.split_once(':') {
173        Some((word, after)) => match Severity::parse(word) {
174            Some(severity) => (severity, after.trim()),
175            None => (Severity::Nit, rest),
176        },
177        None => (Severity::Nit, rest),
178    };
179    let (reason, rest) = if severity == Severity::Blocking {
180        match rest.split_once("::") {
181            Some((reason, text)) => (reason.trim().to_string(), text.trim()),
182            None => (String::new(), rest),
183        }
184    } else {
185        (String::new(), rest)
186    };
187    let (text, target) = match rest.rsplit_once(" @ ") {
188        Some((text, target)) if !target.trim().is_empty() => (text.trim(), target.trim()),
189        _ => (rest, default_target),
190    };
191    if text.is_empty() {
192        return None;
193    }
194    Some(Objection::noted(persona, severity, &reason, text, target))
195}
196
197/// Rounds, children, and optional wall time or token estimate.
198/// Unset wall time and tokens do not bind.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct ScopeBudget {
201    pub max_rounds: usize,
202    pub max_children: usize,
203    pub max_descendants: usize,
204    pub max_wall_secs: Option<u64>,
205    pub max_tokens: Option<u64>,
206}
207
208impl ScopeBudget {
209    /// A single panel: three rounds, a handful of children.
210    #[must_use]
211    pub fn panel() -> Self {
212        Self {
213            max_rounds: 3,
214            max_children: 8,
215            max_descendants: 12,
216            max_wall_secs: None,
217            max_tokens: None,
218        }
219    }
220
221    /// A feature workflow, which files design and fix children of its own.
222    #[must_use]
223    pub fn workflow() -> Self {
224        Self {
225            max_rounds: 3,
226            max_children: 24,
227            max_descendants: 48,
228            max_wall_secs: None,
229            max_tokens: None,
230        }
231    }
232
233    fn overlay(&mut self, file: &ScopeFile) {
234        if let Some(n) = file.max_rounds {
235            self.max_rounds = n;
236        }
237        if let Some(n) = file.max_children {
238            self.max_children = n;
239        }
240        if let Some(n) = file.max_descendants {
241            self.max_descendants = n;
242        }
243        if file.max_wall_secs.is_some() {
244            self.max_wall_secs = file.max_wall_secs;
245        }
246        if file.max_tokens.is_some() {
247            self.max_tokens = file.max_tokens;
248        }
249    }
250}
251
252/// Budgets for one panel and for a workflow parent. Defaults apply until
253/// a toml file says otherwise.
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct PanelBudget {
256    pub panel: ScopeBudget,
257    pub workflow: ScopeBudget,
258}
259
260impl Default for PanelBudget {
261    fn default() -> Self {
262        Self {
263            panel: ScopeBudget::panel(),
264            workflow: ScopeBudget::workflow(),
265        }
266    }
267}
268
269#[derive(Debug, Clone, Deserialize, Default)]
270struct BudgetFile {
271    #[serde(default)]
272    panel: ScopeFile,
273    #[serde(default)]
274    workflow: ScopeFile,
275}
276
277#[derive(Debug, Clone, Deserialize, Default)]
278struct ScopeFile {
279    max_rounds: Option<usize>,
280    max_children: Option<usize>,
281    max_descendants: Option<usize>,
282    max_wall_secs: Option<u64>,
283    max_tokens: Option<u64>,
284}
285
286/// The budget a toml document names, over the defaults. A document with
287/// neither table is the defaults. Unknown keys are ignored.
288#[must_use]
289pub fn budget_from_toml(text: &str) -> PanelBudget {
290    let mut budget = PanelBudget::default();
291    let Ok(file) = toml::from_str::<BudgetFile>(text) else {
292        return budget;
293    };
294    budget.panel.overlay(&file.panel);
295    budget.workflow.overlay(&file.workflow);
296    budget
297}
298
299/// Config dir first, then `./vissue.toml` and `./ljos.toml`, then
300/// `VISSUE_CONFIG` and `LJOS_CONFIG`. A later file overrides the keys it
301/// sets. A missing file is skipped. A file that does not parse is skipped.
302#[must_use]
303pub fn load_budget() -> PanelBudget {
304    let mut budget = PanelBudget::default();
305    for path in budget_paths() {
306        let Ok(text) = std::fs::read_to_string(&path) else {
307            continue;
308        };
309        let Ok(file) = toml::from_str::<BudgetFile>(&text) else {
310            continue;
311        };
312        budget.panel.overlay(&file.panel);
313        budget.workflow.overlay(&file.workflow);
314    }
315    budget
316}
317
318fn budget_paths() -> Vec<PathBuf> {
319    let mut paths = Vec::new();
320    if let Some(base) = config_base() {
321        paths.push(base.join("vissue").join("config.toml"));
322        paths.push(base.join("ljos").join("ljos.toml"));
323    }
324    paths.push(PathBuf::from("vissue.toml"));
325    paths.push(PathBuf::from("ljos.toml"));
326    if let Some(path) = std::env::var_os("VISSUE_CONFIG").filter(|p| !p.is_empty()) {
327        paths.push(PathBuf::from(path));
328    }
329    if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|p| !p.is_empty()) {
330        paths.push(PathBuf::from(path));
331    }
332    paths
333}
334
335fn config_base() -> Option<PathBuf> {
336    std::env::var_os("XDG_CONFIG_HOME")
337        .map(PathBuf::from)
338        .filter(|p| p.is_absolute())
339        .or_else(|| {
340            std::env::var_os("HOME")
341                .map(|h| PathBuf::from(h).join(".config"))
342                .filter(|p| p.is_absolute())
343        })
344}
345
346/// Where a panel stands when the next round is asked for.
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct PanelState {
349    pub round: usize,
350    pub passed: bool,
351    /// 0 on the work. 1 on a dissent child, and so on.
352    pub dissent_depth: usize,
353    pub children_filed: usize,
354    pub open_children: usize,
355    /// The round has not been sat: ballots are missing. This is not a pause
356    /// for a person to raise the round limit.
357    pub awaiting_ballots: bool,
358    pub seen: Vec<(String, String)>,
359    pub wall_secs: Option<u64>,
360    pub tokens: Option<u64>,
361    pub objections: Vec<Objection>,
362}
363
364impl PanelState {
365    #[must_use]
366    pub fn at_round(round: usize) -> Self {
367        Self {
368            round,
369            passed: false,
370            dissent_depth: 0,
371            children_filed: 0,
372            open_children: 0,
373            awaiting_ballots: false,
374            seen: Vec::new(),
375            wall_secs: None,
376            tokens: None,
377            objections: Vec::new(),
378        }
379    }
380}
381
382/// What to do with one objection.
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub enum Fate {
385    Note(Objection),
386    File(Objection),
387    Duplicate(Objection),
388    /// Would have been a child, and the budget closed the item instead.
389    Listed(Objection),
390}
391
392/// What one call does with the objections it was shown.
393#[derive(Debug, Clone, PartialEq, Eq)]
394pub struct Disposal {
395    pub fates: Vec<Fate>,
396    pub close: bool,
397    pub summary: String,
398}
399
400/// The next step of a workflow panel. A close is the step, not an error
401/// and not a wait.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub enum RoundPlan {
404    OpenRound { n: usize },
405    FileBlocking { objections: Vec<Objection> },
406    Settle { summary: String },
407    Hold { why: String },
408    Passed,
409}
410
411fn budget_why(state: &PanelState, limits: &ScopeBudget) -> Option<String> {
412    if state.round > 0 && state.round >= limits.max_rounds {
413        return Some(format!(
414            "round {} reached the limit of {}",
415            state.round, limits.max_rounds
416        ));
417    }
418    if state.children_filed >= limits.max_children {
419        return Some(format!(
420            "{} children filed, at the limit of {}",
421            state.children_filed, limits.max_children
422        ));
423    }
424    if let (Some(used), Some(max)) = (state.wall_secs, limits.max_wall_secs) {
425        if used >= max {
426            return Some(format!("wall time {used}s reached the limit of {max}s"));
427        }
428    }
429    if let (Some(used), Some(max)) = (state.tokens, limits.max_tokens) {
430        if used >= max {
431            return Some(format!("token estimate {used} reached the limit of {max}"));
432        }
433    }
434    None
435}
436
437fn fresh(state: &PanelState) -> Vec<Objection> {
438    let mut batch = state.seen.iter().cloned().collect::<BTreeSet<_>>();
439    let mut out = Vec::new();
440    for objection in &state.objections {
441        let key = objection.key();
442        if batch.contains(&key) {
443            continue;
444        }
445        batch.insert(key);
446        out.push(objection.clone());
447    }
448    out
449}
450
451fn summary_of(why: &str, listed: &[Objection]) -> String {
452    let mut out = format!("settled-with-notes: {why}");
453    if listed.is_empty() {
454        return out;
455    }
456    out.push_str(". remaining:");
457    for (i, objection) in listed.iter().take(12).enumerate() {
458        out.push_str(&format!(
459            " ({}) {} {}: {}",
460            i + 1,
461            objection.persona,
462            objection.severity.word(),
463            clip(&objection.text, 80)
464        ));
465    }
466    if listed.len() > 12 {
467        out.push_str(&format!(" and {} more", listed.len() - 12));
468    }
469    out
470}
471
472fn clip(text: &str, n: usize) -> String {
473    let mut out: String = text.chars().take(n).collect();
474    if text.chars().count() > n {
475        out.push('…');
476    }
477    out
478}
479
480/// Sort the objections on one issue: notes, one child each for what may
481/// be filed, duplicates dropped, and a close when the budget is spent.
482#[must_use]
483pub fn dispose(state: &PanelState, limits: &ScopeBudget) -> Disposal {
484    let mut fates = Vec::new();
485    let mut batch = state.seen.iter().cloned().collect::<BTreeSet<_>>();
486    let mut fileable = Vec::new();
487    for objection in &state.objections {
488        let key = objection.key();
489        if batch.contains(&key) {
490            fates.push(Fate::Duplicate(objection.clone()));
491            continue;
492        }
493        batch.insert(key);
494        if objection.may_file(state.passed, state.dissent_depth) {
495            fileable.push(objection.clone());
496        } else {
497            fates.push(Fate::Note(objection.clone()));
498        }
499    }
500    if let Some(why) = budget_why(state, limits) {
501        for objection in &fileable {
502            fates.push(Fate::Listed(objection.clone()));
503        }
504        return Disposal {
505            fates,
506            close: true,
507            summary: summary_of(&why, &fileable),
508        };
509    }
510    let room = limits.max_children.saturating_sub(state.children_filed);
511    let mut listed = Vec::new();
512    for (i, objection) in fileable.into_iter().enumerate() {
513        if i < room {
514            fates.push(Fate::File(objection));
515        } else {
516            listed.push(objection);
517        }
518    }
519    let close = !listed.is_empty();
520    let summary = if close {
521        summary_of(
522            &format!(
523                "{} children filed, at the limit of {}",
524                state.children_filed + room,
525                limits.max_children
526            ),
527            &listed,
528        )
529    } else {
530        String::new()
531    };
532    for objection in listed {
533        fates.push(Fate::Listed(objection));
534    }
535    Disposal {
536        fates,
537        close,
538        summary,
539    }
540}
541
542/// The next workflow step. Open children and uncast ballots hold. A
543/// blocking objection files one child, including after the substance has
544/// passed, and the next round waits until that child is closed. A pass
545/// with nothing blocking left is a pass. A round of nits closes. At the
546/// round limit a blocking objection is listed, and a pass with nothing
547/// left to file stays a pass.
548#[must_use]
549pub fn plan_round(state: &PanelState, limits: &ScopeBudget) -> RoundPlan {
550    if state.open_children > 0 {
551        return RoundPlan::Hold {
552            why: format!(
553                "{} {} still open",
554                state.open_children,
555                if state.open_children == 1 {
556                    "child is"
557                } else {
558                    "children are"
559                }
560            ),
561        };
562    }
563    if state.awaiting_ballots {
564        return RoundPlan::Hold {
565            why: "the panel has not all voted".into(),
566        };
567    }
568    let mut fileable = Vec::new();
569    let mut seen = state.seen.iter().cloned().collect::<BTreeSet<_>>();
570    let mut addressed = 0;
571    for objection in &state.objections {
572        let key = objection.key();
573        if seen.contains(&key) {
574            if objection.files_as_child() {
575                addressed += 1;
576            }
577            continue;
578        }
579        seen.insert(key);
580        if objection.may_file(state.passed, state.dissent_depth) {
581            fileable.push(objection.clone());
582        }
583    }
584    if let Some(why) = budget_why(state, limits) {
585        if state.passed && fileable.is_empty() {
586            return RoundPlan::Passed;
587        }
588        return RoundPlan::Settle {
589            summary: summary_of(&why, &fresh(state)),
590        };
591    }
592    if !fileable.is_empty() {
593        let room = limits.max_children.saturating_sub(state.children_filed);
594        if room == 0 {
595            return RoundPlan::Settle {
596                summary: summary_of(
597                    &format!(
598                        "{} children filed, at the limit of {}",
599                        state.children_filed, limits.max_children
600                    ),
601                    &fileable,
602                ),
603            };
604        }
605        fileable.truncate(room);
606        return RoundPlan::FileBlocking {
607            objections: fileable,
608        };
609    }
610    if state.passed {
611        return RoundPlan::Passed;
612    }
613    if state.round == 0 {
614        return RoundPlan::OpenRound { n: 1 };
615    }
616    if addressed > 0 && state.round < limits.max_rounds {
617        return RoundPlan::OpenRound { n: state.round + 1 };
618    }
619    let listed = fresh(state);
620    let why = if listed.is_empty() {
621        "no blocking objection remains".to_string()
622    } else {
623        "the objections are below blocking".to_string()
624    };
625    RoundPlan::Settle {
626        summary: summary_of(&why, &listed),
627    }
628}
629
630/// Whether `pass` is the unique strict majority, or, on any other ballot,
631/// one option leads by a strict majority. A tie has not settled.
632#[must_use]
633pub fn substance_passed(choices: &[(String, String)]) -> bool {
634    if choices.is_empty() {
635        return false;
636    }
637    let norm = |s: &str| s.trim().to_ascii_lowercase();
638    let pass = choices.iter().filter(|(_, c)| norm(c) == "pass").count();
639    let revise = choices.iter().filter(|(_, c)| norm(c) == "revise").count();
640    if pass + revise == choices.len() {
641        return pass > revise && pass * 2 > choices.len();
642    }
643    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
644    for (_, choice) in choices {
645        *counts.entry(normalize(choice)).or_default() += 1;
646    }
647    let mut ranked: Vec<(String, usize)> = counts.into_iter().collect();
648    ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
649    ranked.len() == 1 || (ranked[0].1 > ranked[1].1 && ranked[0].1 * 2 > choices.len())
650}
651
652/// True when two choices share the highest count. A single ballot, and a
653/// strict lead, are not ties.
654fn ballot_tie(choices: &[(String, String)]) -> bool {
655    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
656    for (_, choice) in choices {
657        *counts.entry(normalize(choice)).or_default() += 1;
658    }
659    let Some(max) = counts.values().copied().max() else {
660        return false;
661    };
662    counts.values().filter(|n| **n == max).count() > 1
663}
664
665/// Findings on an issue JSON: logbook notes and body lines.
666#[must_use]
667pub fn objections_in(v: &Value, default_target: &str) -> Vec<Objection> {
668    let mut out = Vec::new();
669    let mut push = |text: &str| {
670        if let Some(objection) = parse_objection(text, default_target) {
671            out.push(objection);
672        }
673    };
674    for entry in v["logbook"].as_array().into_iter().flatten() {
675        if let Some(note) = entry["note"].as_str() {
676            push(note);
677        }
678        if let Some(raw) = entry["raw"].as_str() {
679            push(raw);
680        }
681    }
682    if let Some(body) = v["body"].as_str() {
683        for line in body.lines() {
684            push(line);
685        }
686    }
687    out
688}
689
690/// Depth of a dissent child: the `dissent-depth: N` note, else 1 when the
691/// title is a dissent, else 0.
692#[must_use]
693pub fn dissent_depth(v: &Value) -> usize {
694    for entry in v["logbook"].as_array().into_iter().flatten() {
695        let Some(note) = entry["note"].as_str() else {
696            continue;
697        };
698        if let Some(rest) = note.trim().strip_prefix("dissent-depth:") {
699            if let Ok(n) = rest.trim().parse::<usize>() {
700                return n;
701            }
702        }
703    }
704    let title = v["title"].as_str().unwrap_or("");
705    if title.trim_start().starts_with("Dissent holds") {
706        1
707    } else {
708        0
709    }
710}
711
712/// The text a filed child carries, so a later round recognises it.
713#[must_use]
714pub fn filed_key(title: &str, target: &str) -> Option<(String, String)> {
715    let title = title.trim();
716    let text = title
717        .strip_prefix("Dissent holds ")
718        .or_else(|| title.strip_prefix("fix: "))
719        .or_else(|| title.strip_prefix("fix:"))?;
720    let text = text.trim();
721    if text.is_empty() {
722        return None;
723    }
724    Some((normalize(text), normalize(target)))
725}
726
727fn project_of(v: &Value, id: &str) -> String {
728    v["project"]
729        .as_str()
730        .filter(|s| !s.trim().is_empty())
731        .map(str::to_string)
732        .or_else(|| id.rsplit_once('-').map(|(p, _)| p.to_string()))
733        .unwrap_or_default()
734}
735
736/// Options a voter still holds that are not the unique lead. A tie, a
737/// single option, and an empty ballot name nothing: there is no minority
738/// to file.
739fn minority_options(choices: &[(String, String)]) -> Vec<String> {
740    let mut counts: BTreeMap<String, (String, usize)> = BTreeMap::new();
741    for (_, choice) in choices {
742        let shown = choice.trim();
743        let key = normalize(shown);
744        if key.is_empty() {
745            continue;
746        }
747        let row = counts.entry(key).or_insert_with(|| (shown.to_string(), 0));
748        row.1 += 1;
749    }
750    let Some(max) = counts.values().map(|(_, n)| *n).max() else {
751        return Vec::new();
752    };
753    if counts.values().filter(|(_, n)| *n == max).count() != 1 {
754        return Vec::new();
755    }
756    let mut out: Vec<String> = counts
757        .into_values()
758        .filter(|(_, n)| *n < max)
759        .map(|(shown, _)| shown)
760        .collect();
761    out.sort();
762    out
763}
764
765/// One `Dissent holds OPTION` child per minority on a settled majority.
766/// A child already filed for that option is named and left alone.
767fn file_settled_minority(
768    issue: &str,
769    project: &str,
770    choices: &[(String, String)],
771    children: &[String],
772    depth: usize,
773) -> Vec<String> {
774    let mut lines = Vec::new();
775    for option in minority_options(choices) {
776        let title = format!("Dissent holds {option}");
777        if children.iter().any(|t| normalize(t) == normalize(&title)) {
778            lines.push(format!("{issue}: dissent {option} already filed"));
779            continue;
780        }
781        match file_child(issue, project, &title, depth) {
782            Ok(id) => lines.push(format!("filed {id}  TODO  [#C]  {title}")),
783            Err(e) => lines.push(format!("dissent: file refused: {e:#}")),
784        }
785    }
786    lines
787}
788
789/// File dissent children and note the rest for `issue`. A settled majority
790/// still files one `Dissent holds OPTION` child for each option a voter
791/// holds that is not the lead. A tie files none. A tracker that does not
792/// answer leaves the reading as it was: filing is a side effect.
793#[must_use]
794pub fn record_dissents(issue: &str) -> Vec<String> {
795    let Ok(v) = crate::issue_record(issue) else {
796        return Vec::new();
797    };
798    let choices: Vec<(String, String)> = crate::ballot_rows(issue)
799        .into_iter()
800        .filter_map(|row| {
801            let agent = row.get("agent")?.as_str()?.to_string();
802            let choice = row.get("choice")?.as_str()?.to_string();
803            Some((agent, choice))
804        })
805        .collect();
806    let passed = substance_passed(&choices);
807    // A tie has no held side to file. Both options lead, so a dissent child
808    // for each would be the settle saying it could not choose.
809    let tied = ballot_tie(&choices);
810    let mut objections = objections_in(&v, issue);
811    let noted: BTreeSet<(String, String)> = objections.iter().map(Objection::key).collect();
812    if !passed && !tied {
813        for (persona, choice) in &choices {
814            if normalize(choice) == "pass" {
815                continue;
816            }
817            let mut held = Objection::nit(persona, &format!("holds {choice}"), issue);
818            held.held = true;
819            if !noted.contains(&held.key())
820                && !objections
821                    .iter()
822                    .any(|o| o.persona == *persona && o.files_as_child())
823            {
824                objections.push(held);
825            }
826        }
827    }
828    let children = child_titles(issue);
829    let mut seen = Vec::new();
830    let mut children_filed = 0;
831    for title in &children {
832        if let Some(key) = filed_key(title, issue) {
833            seen.push(key);
834            children_filed += 1;
835        }
836    }
837    let state = PanelState {
838        round: crate::panel_round_number(v["title"].as_str().unwrap_or("")).unwrap_or(0),
839        passed,
840        dissent_depth: dissent_depth(&v),
841        children_filed,
842        open_children: 0,
843        awaiting_ballots: false,
844        seen,
845        wall_secs: None,
846        tokens: None,
847        objections,
848    };
849    let limits = load_budget().panel;
850    let disposal = dispose(&state, &limits);
851    let mut lines = Vec::new();
852    let project = project_of(&v, issue);
853    let depth = state.dissent_depth;
854    for fate in disposal.fates {
855        match fate {
856            Fate::Duplicate(_) => {}
857            Fate::Note(objection) if noted.contains(&objection.key()) => {}
858            Fate::Note(objection) => match note_issue(issue, &format_note(&objection)) {
859                Ok(()) => lines.push(format!(
860                    "noted {} from {} on {}: {}",
861                    objection.severity.word(),
862                    objection.persona,
863                    objection.target,
864                    objection.text
865                )),
866                Err(e) => lines.push(format!("dissent: note refused: {e:#}")),
867            },
868            Fate::File(objection) => {
869                let title = dissent_title(&objection);
870                match file_child(issue, &project, &title, depth + 1) {
871                    Ok(id) => lines.push(format!("filed {id}  TODO  [#C]  {title}")),
872                    Err(e) => lines.push(format!("dissent: file refused: {e:#}")),
873                }
874            }
875            Fate::Listed(objection) => lines.push(format!(
876                "listed {} from {}: {}",
877                objection.severity.word(),
878                objection.persona,
879                objection.text
880            )),
881        }
882    }
883    if disposal.close {
884        match close_settled(issue, &disposal.summary) {
885            Ok(said) => lines.push(said.trim().to_string()),
886            Err(e) => lines.push(format!("dissent: close refused: {e:#}")),
887        }
888    }
889    // A strict majority has settled the substance. The option a voter
890    // still holds, and that is not the lead, is still a dissent child.
891    // The tie never reaches here with a minority to name.
892    if passed {
893        lines.extend(file_settled_minority(
894            issue,
895            &project,
896            &choices,
897            &children,
898            depth + 1,
899        ));
900    }
901    lines
902}
903
904fn format_note(objection: &Objection) -> String {
905    if objection.files_as_child() {
906        format!(
907            "finding: {}: blocking: {} :: {} @ {}",
908            objection.persona, objection.reason, objection.text, objection.target
909        )
910    } else {
911        format!(
912            "finding: {}: {}: {} @ {}",
913            objection.persona,
914            objection.severity.word(),
915            objection.text,
916            objection.target
917        )
918    }
919}
920
921fn dissent_title(objection: &Objection) -> String {
922    let text = clip(&objection.text, 120);
923    format!("Dissent holds {text}")
924}
925
926fn child_titles(issue: &str) -> Vec<String> {
927    let Ok(said) = crate::run_captured("vissue", &["children", issue, "--json"]) else {
928        return Vec::new();
929    };
930    let Ok(rows) = serde_json::from_str::<Vec<Value>>(&said.stdout) else {
931        return Vec::new();
932    };
933    rows.into_iter()
934        .filter_map(|row| {
935            let title = row["title"].as_str().map(str::to_string);
936            if title.is_some() {
937                return title;
938            }
939            let id = row["id"].as_str()?;
940            crate::issue_record(id)
941                .ok()
942                .and_then(|v| v["title"].as_str().map(str::to_string))
943        })
944        .collect()
945}
946
947fn file_child(parent: &str, project: &str, title: &str, depth: usize) -> Result<String> {
948    let mut args = vec!["q".to_string()];
949    if !project.is_empty() {
950        args.push("-p".into());
951        args.push(project.to_string());
952    }
953    args.extend([
954        "--parent".into(),
955        parent.to_string(),
956        "-t".into(),
957        "task".into(),
958        title.to_string(),
959    ]);
960    let said = crate::run_captured("vissue", &args)?;
961    let id = said.stdout.trim().to_string();
962    if id.is_empty() {
963        anyhow::bail!("vissue q printed no id");
964    }
965    let _ = crate::run_captured("vissue", &["note", &id, &format!("dissent-depth: {depth}")]);
966    Ok(id)
967}
968
969fn note_issue(id: &str, text: &str) -> Result<()> {
970    crate::run_captured("vissue", &["note", id, text])?;
971    Ok(())
972}
973
974/// Note the summary and mark the issue done. The close is the whole step.
975pub fn close_settled(id: &str, summary: &str) -> Result<String> {
976    note_issue(id, summary).with_context(|| format!("note {id}"))?;
977    crate::run_captured("vissue", &["update", id, "-s", "DONE"])
978        .with_context(|| format!("close {id}"))?;
979    let mut out = format!("{id} closed as {summary}\n");
980    out.push_str(&crate::persist_tracker(id, "settled with notes"));
981    Ok(out)
982}
983
984/// File one `fix:` child for a blocking objection. The review of it is the
985/// next panel round, not a second child.
986pub fn file_fix(parent: &str, project: &str, objection: &Objection) -> Result<String> {
987    let title = format!("fix: {}", clip(&objection.text, 120));
988    let id = crate::workflow_child(parent, project, "task", crate::Stage::Implement, &title)?;
989    let _ = note_issue(
990        &id,
991        &format!(
992            "finding: {}: blocking: {} :: {} @ {}",
993            objection.persona, objection.reason, objection.text, parent
994        ),
995    );
996    Ok(id)
997}
998
999/// One issue in an export, enough to see a tree running away.
1000#[derive(Debug, Clone, PartialEq, Eq)]
1001struct Node {
1002    id: String,
1003    parent: Option<String>,
1004    title: String,
1005    open: bool,
1006    blocking: usize,
1007}
1008
1009/// Lines for trees that have run away: more descendants than the budget,
1010/// or panel rounds that grew without a new blocking objection. The top of
1011/// the tree is named, not every child. `apply` closes each flagged issue
1012/// as settled-with-notes.
1013#[must_use]
1014pub fn reap_runaways(apply: bool) -> String {
1015    let Ok(said) = crate::run_captured("vissue", &["export"]) else {
1016        return String::new();
1017    };
1018    let budget = load_budget();
1019    let mut out = String::new();
1020    for (id, why) in flag_runaways(&said.stdout, &budget) {
1021        out.push_str(&format!("runaway: {id} {why}\n"));
1022        if !apply {
1023            continue;
1024        }
1025        let summary = format!("settled-with-notes: runaway, {why}");
1026        match close_settled(&id, &summary) {
1027            Ok(said) => out.push_str(&said),
1028            Err(e) => out.push_str(&format!("reap: {id}: close refused: {e:#}\n")),
1029        }
1030    }
1031    out
1032}
1033
1034/// Runaway trees in a `vissue export` (one JSON issue a line).
1035#[must_use]
1036pub fn flag_runaways(export: &str, budget: &PanelBudget) -> Vec<(String, String)> {
1037    let nodes = nodes_of(export);
1038    let children = children_of(&nodes);
1039    let mut desc_memo: BTreeMap<String, usize> = BTreeMap::new();
1040    let mut round_memo: BTreeMap<String, usize> = BTreeMap::new();
1041    let mut block_memo: BTreeMap<String, usize> = BTreeMap::new();
1042    let mut flags = Vec::new();
1043    for node in &nodes {
1044        if !node.open {
1045            continue;
1046        }
1047        let limits = if is_workflow(&nodes, &node.id) {
1048            &budget.workflow
1049        } else {
1050            &budget.panel
1051        };
1052        let desc = descendants(&node.id, &children, &mut desc_memo, &mut BTreeSet::new());
1053        let parent_over = node.parent.as_ref().is_some_and(|p| {
1054            let parent_limits = if is_workflow(&nodes, p) {
1055                &budget.workflow
1056            } else {
1057                &budget.panel
1058            };
1059            descendants(p, &children, &mut desc_memo, &mut BTreeSet::new())
1060                > parent_limits.max_descendants
1061        });
1062        if desc > limits.max_descendants && !parent_over {
1063            flags.push((
1064                node.id.clone(),
1065                format!(
1066                    "has {desc} descendants, over the limit of {}",
1067                    limits.max_descendants
1068                ),
1069            ));
1070        }
1071        let round = max_round(
1072            &node.id,
1073            &nodes,
1074            &children,
1075            &mut round_memo,
1076            &mut BTreeSet::new(),
1077        );
1078        let blocking = blocking_under(
1079            &node.id,
1080            &nodes,
1081            &children,
1082            &mut block_memo,
1083            &mut BTreeSet::new(),
1084        );
1085        let grew = round >= 2 && round > blocking + 1;
1086        let parent_grew = node.parent.as_ref().is_some_and(|p| {
1087            let parent_round =
1088                max_round(p, &nodes, &children, &mut round_memo, &mut BTreeSet::new());
1089            let parent_blocking =
1090                blocking_under(p, &nodes, &children, &mut block_memo, &mut BTreeSet::new());
1091            parent_round >= round && parent_round >= 2 && parent_round > parent_blocking + 1
1092        });
1093        if grew && !parent_grew {
1094            flags.push((
1095                node.id.clone(),
1096                format!(
1097                    "reached round {round} with {blocking} blocking {}; rounds grew without a new blocking objection",
1098                    if blocking == 1 { "objection" } else { "objections" }
1099                ),
1100            ));
1101        }
1102    }
1103    flags
1104}
1105
1106fn is_workflow(nodes: &[Node], id: &str) -> bool {
1107    nodes.iter().any(|n| {
1108        n.id == id
1109            && (n.title.contains("workflow")
1110                || n.title.starts_with("design:")
1111                || n.title.starts_with("panel r"))
1112    }) || nodes
1113        .iter()
1114        .any(|n| n.parent.as_deref() == Some(id) && crate::panel_round_number(&n.title).is_some())
1115}
1116
1117fn nodes_of(export: &str) -> Vec<Node> {
1118    let mut nodes = Vec::new();
1119    for line in export.lines() {
1120        let Ok(v) = serde_json::from_str::<Value>(line) else {
1121            continue;
1122        };
1123        let Some(id) = v["id"].as_str().filter(|s| !s.is_empty()) else {
1124            continue;
1125        };
1126        let state = v["state"].as_str().unwrap_or("");
1127        let open = !matches!(state, "DONE" | "CANCELLED");
1128        let blocking = objections_in(&v, id)
1129            .iter()
1130            .filter(|o| o.files_as_child())
1131            .count();
1132        nodes.push(Node {
1133            id: id.to_string(),
1134            parent: v["parent"]
1135                .as_str()
1136                .filter(|s| !s.is_empty())
1137                .map(str::to_string),
1138            title: v["title"].as_str().unwrap_or("").to_string(),
1139            open,
1140            blocking,
1141        });
1142    }
1143    nodes
1144}
1145
1146fn children_of(nodes: &[Node]) -> BTreeMap<String, Vec<String>> {
1147    let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
1148    for node in nodes {
1149        if let Some(parent) = &node.parent {
1150            map.entry(parent.clone()).or_default().push(node.id.clone());
1151        }
1152    }
1153    map
1154}
1155
1156fn descendants(
1157    id: &str,
1158    children: &BTreeMap<String, Vec<String>>,
1159    memo: &mut BTreeMap<String, usize>,
1160    stack: &mut BTreeSet<String>,
1161) -> usize {
1162    if let Some(n) = memo.get(id) {
1163        return *n;
1164    }
1165    if !stack.insert(id.to_string()) {
1166        return 0;
1167    }
1168    let n = children.get(id).map_or(0, |kids| {
1169        kids.iter()
1170            .map(|kid| 1 + descendants(kid, children, memo, stack))
1171            .sum()
1172    });
1173    stack.remove(id);
1174    memo.insert(id.to_string(), n);
1175    n
1176}
1177
1178fn max_round(
1179    id: &str,
1180    nodes: &[Node],
1181    children: &BTreeMap<String, Vec<String>>,
1182    memo: &mut BTreeMap<String, usize>,
1183    stack: &mut BTreeSet<String>,
1184) -> usize {
1185    if let Some(n) = memo.get(id) {
1186        return *n;
1187    }
1188    if !stack.insert(id.to_string()) {
1189        return 0;
1190    }
1191    let own = nodes
1192        .iter()
1193        .find(|n| n.id == id)
1194        .map(|n| crate::panel_round_number(&n.title).unwrap_or(0))
1195        .unwrap_or(0);
1196    let below = children.get(id).map_or(0, |kids| {
1197        kids.iter()
1198            .map(|kid| max_round(kid, nodes, children, memo, stack))
1199            .max()
1200            .unwrap_or(0)
1201    });
1202    stack.remove(id);
1203    let n = own.max(below);
1204    memo.insert(id.to_string(), n);
1205    n
1206}
1207
1208fn blocking_under(
1209    id: &str,
1210    nodes: &[Node],
1211    children: &BTreeMap<String, Vec<String>>,
1212    memo: &mut BTreeMap<String, usize>,
1213    stack: &mut BTreeSet<String>,
1214) -> usize {
1215    if let Some(n) = memo.get(id) {
1216        return *n;
1217    }
1218    if !stack.insert(id.to_string()) {
1219        return 0;
1220    }
1221    let own = nodes
1222        .iter()
1223        .find(|n| n.id == id)
1224        .map(|n| n.blocking)
1225        .unwrap_or(0);
1226    let below: usize = children.get(id).map_or(0, |kids| {
1227        kids.iter()
1228            .map(|kid| blocking_under(kid, nodes, children, memo, stack))
1229            .sum()
1230    });
1231    stack.remove(id);
1232    let n = own + below;
1233    memo.insert(id.to_string(), n);
1234    n
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::*;
1240
1241    fn nit(n: usize) -> Objection {
1242        Objection::nit("ceo", &format!("comma in note {n}"), "anneal-z64x")
1243    }
1244
1245    #[test]
1246    fn a_blocking_mark_without_a_reason_is_a_nit() {
1247        let line = "finding: tester: blocking: the comma is wrong";
1248        let got = parse_objection(line, "anneal-z64x").unwrap();
1249        assert_eq!(got.severity, Severity::Nit);
1250        assert!(!got.files_as_child());
1251        let marked = parse_objection(
1252            "finding: tester: blocking: the exit status is dropped :: the gate ignores a failure @ anneal-z64x",
1253            "other",
1254        )
1255        .unwrap();
1256        assert!(marked.files_as_child());
1257        assert_eq!(marked.reason, "the exit status is dropped");
1258        assert_eq!(marked.text, "the gate ignores a failure");
1259        assert_eq!(marked.target, "anneal-z64x");
1260        let plain = parse_objection("finding: cto: heading tone", "anneal-z64x").unwrap();
1261        assert_eq!(plain.severity, Severity::Nit);
1262        assert_eq!(plain.text, "heading tone");
1263        let wording =
1264            parse_objection("finding: cto: wording: heading tone", "anneal-z64x").unwrap();
1265        assert_eq!(wording.severity, Severity::Wording);
1266        assert!(parse_objection("not a finding", "x").is_none());
1267    }
1268
1269    #[test]
1270    fn the_same_text_and_target_are_one_objection() {
1271        let a = Objection::nit("ceo", "Heading, tone!", "anneal-z64x");
1272        let b = Objection::wording("cto", "heading tone", "Anneal-z64x");
1273        assert_eq!(a.key(), b.key());
1274        let other = Objection::nit("ceo", "heading tone", "anneal-other");
1275        assert_ne!(a.key(), other.key());
1276    }
1277
1278    #[test]
1279    fn an_endless_stream_of_nits_after_a_pass_files_nothing() {
1280        let limits = ScopeBudget::panel();
1281        let mut state = PanelState::at_round(1);
1282        state.passed = true;
1283        let mut filed = 0;
1284        let mut opened = 0;
1285        for n in 0..10 {
1286            state.objections.push(nit(n));
1287            state
1288                .objections
1289                .push(Objection::wording("cto", "heading tone", "anneal-z64x"));
1290            state.objections.push(Objection::style(
1291                "junior-dev",
1292                "whitespace in the comment",
1293                "anneal-z64x",
1294            ));
1295            match plan_round(&state, &limits) {
1296                RoundPlan::FileBlocking { objections } => filed += objections.len(),
1297                RoundPlan::OpenRound { n } => {
1298                    opened += 1;
1299                    state.round = n;
1300                }
1301                RoundPlan::Settle { summary } => {
1302                    panic!("a pass does not need a close to stop: {summary}");
1303                }
1304                RoundPlan::Passed => {}
1305                RoundPlan::Hold { why } => panic!("a pass does not hold: {why}"),
1306            }
1307        }
1308        assert_eq!(filed, 0, "nits after a pass are notes");
1309        assert_eq!(opened, 0, "a pass does not open another round");
1310        assert!(state.round <= limits.max_rounds);
1311
1312        // The anneal as it actually ran: revise votes on wording, so the
1313        // round does not read as passed, and the agent asks for another.
1314        let mut state = PanelState::at_round(1);
1315        let mut filed = 0;
1316        let mut opened = 0;
1317        let mut settled = 0;
1318        let mut last = String::new();
1319        for n in 0..10 {
1320            state.objections.push(nit(n));
1321            state
1322                .objections
1323                .push(Objection::wording("cto", "heading tone", "anneal-z64x"));
1324            match plan_round(&state, &limits) {
1325                RoundPlan::FileBlocking { objections } => filed += objections.len(),
1326                RoundPlan::OpenRound { n } => {
1327                    opened += 1;
1328                    state.round = n;
1329                }
1330                RoundPlan::Settle { summary } => {
1331                    settled += 1;
1332                    last = summary;
1333                }
1334                RoundPlan::Passed | RoundPlan::Hold { .. } => {}
1335            }
1336        }
1337        assert_eq!(filed, 0);
1338        assert_eq!(opened, 0);
1339        assert!(settled >= 1);
1340        assert!(state.round <= limits.max_rounds);
1341        assert!(last.contains("settled-with-notes"), "{last}");
1342        assert!(last.contains("comma in note 0"), "{last}");
1343        assert!(last.contains("heading tone"), "{last}");
1344        // The repeated wording is one objection, not one per round.
1345        assert_eq!(last.matches("heading tone").count(), 1, "{last}");
1346    }
1347
1348    #[test]
1349    fn a_blocking_dissent_files_a_child_and_a_nit_does_not_file_a_grandchild() {
1350        let limits = ScopeBudget::panel();
1351        let mut state = PanelState::at_round(1);
1352        state.passed = true;
1353        state.objections.push(Objection::blocking(
1354            "tester",
1355            "the gate drops a real failure",
1356            "the exit status is ignored",
1357            "anneal-z64x",
1358        ));
1359        state
1360            .objections
1361            .push(Objection::nit("ceo", "comma in note 0", "anneal-z64x"));
1362        let plan = plan_round(&state, &limits);
1363        let RoundPlan::FileBlocking { objections } = plan else {
1364            panic!("a blocking dissent is a child: {plan:?}");
1365        };
1366        assert_eq!(objections.len(), 1);
1367        assert!(objections[0].text.contains("exit status"));
1368
1369        let mut child = PanelState::at_round(1);
1370        child.dissent_depth = 1;
1371        child.passed = true;
1372        child
1373            .objections
1374            .push(Objection::nit("ceo", "rename the local", "anneal-child"));
1375        child
1376            .objections
1377            .push(Objection::wording("cto", "heading tone", "anneal-child"));
1378        match plan_round(&child, &limits) {
1379            RoundPlan::Passed | RoundPlan::Settle { .. } => {}
1380            other => panic!("a dissent child does not file a nit grandchild: {other:?}"),
1381        }
1382        let mut deeper = child.clone();
1383        deeper.passed = true;
1384        deeper.objections.push(Objection::blocking(
1385            "tester",
1386            "the child still drops the status",
1387            "the wait is unchecked",
1388            "anneal-child",
1389        ));
1390        match plan_round(&deeper, &limits) {
1391            RoundPlan::FileBlocking { objections } => {
1392                assert_eq!(objections.len(), 1);
1393                assert!(objections[0].text.contains("unchecked"));
1394            }
1395            other => panic!("a blocking objection on a dissent child is still a child: {other:?}"),
1396        }
1397    }
1398
1399    #[test]
1400    fn new_blocking_objections_stop_at_the_round_limit() {
1401        let limits = ScopeBudget::panel();
1402        let mut state = PanelState::at_round(1);
1403        let mut filed = Vec::new();
1404        for n in 0..6 {
1405            state.objections.push(Objection::blocking(
1406                "tester",
1407                &format!("failure {n} escapes"),
1408                &format!("bug {n} is still open"),
1409                "anneal-z64x",
1410            ));
1411            state.open_children = 0;
1412            match plan_round(&state, &limits) {
1413                RoundPlan::FileBlocking { objections } => {
1414                    assert_eq!(objections.len(), 1, "{objections:?}");
1415                    let objection = &objections[0];
1416                    filed.push(objection.text.clone());
1417                    state.seen.push(objection.key());
1418                    state.children_filed += 1;
1419                    match plan_round(&state, &limits) {
1420                        RoundPlan::OpenRound { n } => state.round = n,
1421                        RoundPlan::Settle { summary } => {
1422                            assert!(summary.contains("settled-with-notes"), "{summary}");
1423                            break;
1424                        }
1425                        other => panic!("after the fix closed: {other:?}"),
1426                    }
1427                }
1428                RoundPlan::Settle { summary } => {
1429                    assert!(summary.contains("settled-with-notes"), "{summary}");
1430                    assert!(summary.contains("bug"), "{summary}");
1431                    break;
1432                }
1433                other => panic!("step {n}: {other:?}"),
1434            }
1435        }
1436        assert!(state.round <= limits.max_rounds, "round {}", state.round);
1437        assert!(filed.len() < 6, "filed without a cap: {filed:?}");
1438        assert!(filed.iter().any(|text| text.contains("bug 0")), "{filed:?}");
1439        assert!(
1440            !filed.iter().any(|text| text.contains("bug 5")),
1441            "the later bugs are listed, not filed: {filed:?}"
1442        );
1443    }
1444
1445    #[test]
1446    fn a_wall_budget_closes_without_filing() {
1447        let mut limits = ScopeBudget::panel();
1448        limits.max_wall_secs = Some(60);
1449        let mut state = PanelState::at_round(1);
1450        state.wall_secs = Some(120);
1451        state.objections.push(Objection::blocking(
1452            "tester",
1453            "a real failure",
1454            "the exit status is ignored",
1455            "anneal-z64x",
1456        ));
1457        match plan_round(&state, &limits) {
1458            RoundPlan::Settle { summary } => {
1459                assert!(summary.contains("wall time"), "{summary}");
1460                assert!(summary.contains("exit status"), "{summary}");
1461            }
1462            other => panic!("over the wall budget the objection is listed: {other:?}"),
1463        }
1464    }
1465
1466    #[test]
1467    fn a_settled_majority_names_only_the_minority() {
1468        let majority = vec![
1469            ("alice".to_string(), "drop".to_string()),
1470            ("bob".to_string(), "drop".to_string()),
1471            ("keeper".to_string(), "keep".to_string()),
1472        ];
1473        assert_eq!(minority_options(&majority), vec!["keep".to_string()]);
1474        let tie = vec![
1475            ("alice".to_string(), "yes".to_string()),
1476            ("bob".to_string(), "no".to_string()),
1477        ];
1478        assert!(minority_options(&tie).is_empty());
1479        let unanimous = vec![
1480            ("alice".to_string(), "ship".to_string()),
1481            ("bob".to_string(), "ship".to_string()),
1482        ];
1483        assert!(minority_options(&unanimous).is_empty());
1484    }
1485
1486    #[test]
1487    fn a_duplicate_is_not_filed_again() {
1488        let limits = ScopeBudget::panel();
1489        let mut state = PanelState::at_round(1);
1490        let objection = Objection::blocking(
1491            "tester",
1492            "the gate drops a real failure",
1493            "the exit status is ignored",
1494            "anneal-z64x",
1495        );
1496        state.seen.push(objection.key());
1497        state.objections.push(objection);
1498        state.objections.push(Objection::blocking(
1499            "cto",
1500            "the gate drops a real failure",
1501            "The exit status is ignored.",
1502            "anneal-z64x",
1503        ));
1504        let disposal = dispose(&state, &limits);
1505        assert!(disposal
1506            .fates
1507            .iter()
1508            .all(|f| matches!(f, Fate::Duplicate(_))));
1509        assert!(!disposal.close);
1510    }
1511
1512    #[test]
1513    fn toml_overlays_the_defaults_and_leaves_the_other_scope() {
1514        let budget = budget_from_toml(
1515            "[panel]\nmax_rounds = 2\nmax_tokens = 1000\n[workflow]\nmax_children = 4\n",
1516        );
1517        assert_eq!(budget.panel.max_rounds, 2);
1518        assert_eq!(budget.panel.max_children, ScopeBudget::panel().max_children);
1519        assert_eq!(budget.panel.max_tokens, Some(1000));
1520        assert_eq!(budget.workflow.max_children, 4);
1521        assert_eq!(budget.workflow.max_rounds, 3);
1522        assert_eq!(budget_from_toml("not toml"), PanelBudget::default());
1523    }
1524
1525    #[test]
1526    fn reap_flags_a_wide_tree_and_rounds_with_no_new_blocking_objection() {
1527        let budget = PanelBudget::default();
1528        let mut lines = Vec::new();
1529        lines.push(
1530            r#"{"id":"anneal-z64x","title":"anneal the notes","state":"STARTED","parent":""}"#
1531                .to_string(),
1532        );
1533        for n in 1..=10 {
1534            lines.push(format!(
1535                r#"{{"id":"anneal-r{n}","title":"panel r{n}: anneal","state":"DONE","parent":"anneal-z64x"}}"#
1536            ));
1537        }
1538        for n in 0..60 {
1539            lines.push(format!(
1540                r#"{{"id":"anneal-c{n}","title":"fix: comma {n}","state":"TODO","parent":"anneal-z64x"}}"#
1541            ));
1542        }
1543        // 70 descendants and round 10, with no blocking note.
1544        let flags = flag_runaways(&lines.join("\n"), &budget);
1545        let ids: Vec<&str> = flags.iter().map(|(id, _)| id.as_str()).collect();
1546        assert!(ids.contains(&"anneal-z64x"), "{flags:?}");
1547        let why = flags
1548            .iter()
1549            .filter(|(id, _)| id == "anneal-z64x")
1550            .map(|(_, why)| why.as_str())
1551            .collect::<Vec<_>>()
1552            .join(" ");
1553        assert!(why.contains("descendants"), "{why}");
1554        assert!(why.contains("round 10"), "{why}");
1555        assert!(why.contains("without a new blocking"), "{why}");
1556
1557        // One blocking objection justifies a second round. A third does not
1558        // when nothing new is blocking.
1559        let small = [
1560            r#"{"id":"p","title":"widget","state":"STARTED"}"#,
1561            r#"{"id":"p-r1","title":"panel r1: widget","state":"DONE","parent":"p","logbook":[{"note":"finding: tester: blocking: drops a failure :: exit status @ p"}]}"#,
1562            r#"{"id":"p-r2","title":"panel r2: widget","state":"DONE","parent":"p"}"#,
1563        ]
1564        .join("\n");
1565        let flags = flag_runaways(&small, &budget);
1566        assert!(
1567            flags.iter().all(|(id, _)| id != "p"),
1568            "round 2 with one blocking objection is the fix, not a runaway: {flags:?}"
1569        );
1570        let wide_round = [
1571            r#"{"id":"p","title":"widget","state":"STARTED"}"#,
1572            r#"{"id":"p-r1","title":"panel r1: widget","state":"DONE","parent":"p"}"#,
1573            r#"{"id":"p-r2","title":"panel r2: widget","state":"DONE","parent":"p"}"#,
1574            r#"{"id":"p-r3","title":"panel r3: widget","state":"DONE","parent":"p"}"#,
1575        ]
1576        .join("\n");
1577        let flags = flag_runaways(&wide_round, &budget);
1578        assert!(
1579            flags
1580                .iter()
1581                .any(|(id, why)| id == "p" && why.contains("round 3")),
1582            "{flags:?}"
1583        );
1584    }
1585}