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