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