Skip to main content

spar/
brainstorm.rs

1//! Brainstorming with the pair: existing pieces, paired in ways nobody has.
2//!
3//! The premise is that the parts of an answer usually exist already, in the
4//! problem's own field or an adjacent one, and what is missing is the pairing.
5//! So the agents are not asked to invent. They are asked to take inventory of
6//! what exists and then to combine, and every idea has to name its parts and
7//! the nearest thing that already does something like it.
8//!
9//! Nothing is edited between rounds, so the custody loop in [`crate::review`]
10//! does not apply. The shape is the one `spar review` uses:
11//!
12//! 1. **Diverge.** Both agents propose at the same time, neither seeing the
13//!    other. An idea both reach on their own is the strongest signal there is.
14//! 2. **Cross.** Each reads only the other's ideas and, for each, builds on it
15//!    with a further piece, challenges it by naming where it already exists or
16//!    why it cannot work, or keeps it. These are the middle rounds.
17//! 3. **Converge.** Whoever proposed a challenged idea defends it with
18//!    specifics or withdraws it, and both rank what is left.
19//!
20//! The settle is mechanical. Nothing here lets one agent overrule the other:
21//! an idea leaves the set only when its own proposer withdraws it or never
22//! answers the objection, and the order is the sum of two rankings.
23
24use std::path::{Path, PathBuf};
25
26use crate::agent::Agent;
27use crate::clock::file_stamp;
28use crate::config::{Call, Config};
29use crate::error::{ErrorKind, Result};
30use crate::model::{ConvergeDoc, CrossDoc, CrossKind, Idea, IdeasDoc};
31use crate::repo::{Repo, FOLLOWUP_MARKER};
32use crate::review_only::concurrently;
33use crate::style::{self, Style};
34use crate::{bail, log, logdim, logwarn, schema};
35
36// ---------------------------------------------------------------------------
37// Prompts
38// ---------------------------------------------------------------------------
39
40/// The one sentence every prompt here carries. A brainstorm reads, and an
41/// ambient write in the working tree discards the answer.
42const READ_ONLY: &str = "\
43Do not modify, commit, or push anything in your working directory. If you need
44scratch space, use the system temporary directory.";
45
46const DIVERGE_PROMPT: &str = "\
47{opening}
48
49The premise of this session is that the pieces of an answer usually exist
50already, in this field or an adjacent one, and what is missing is the pairing.
51So do not try to invent from nothing. Work in two steps.
52
53First, take inventory. List, for yourself, the existing techniques, primitives,
54results, tools, and patterns that bear on this, from its own field and from
55fields that solve a similar shaped problem differently. Include things that
56seem unrelated at first: the useful pairings are usually the ones nobody has
57had a reason to try.
58
59Then propose {count} ideas. Each pairs two or more pieces from your inventory
60in a way that has not been done. For every idea:
61- title: one line naming it by what it does, not by its ingredients.
62- combines: name the pieces. An idea that cannot name its parts is a wish.
63- how_it_works: the mechanism, concretely enough that somebody could start.
64- why_new: the nearest thing that already exists, and what this does that it
65  does not. If you cannot name a nearest thing, look harder before claiming
66  there is none.
67- enables: what becomes possible that was not.
68- risks: what would make it fail, honestly.
69- first_experiment: the cheapest thing that would show whether it works.
70
71Prefer an idea that would be surprising and real over one that is safe and
72obvious. The other agent proposes on the same subject without seeing your
73answer, then reads yours and challenges anything that already exists or cannot
74work, so an idea that survives is one you could defend with specifics.
75
76{read_only}";
77
78const TOPIC_OPENING: &str = "\
79Brainstorm: {subject}
80
81Your working directory is a code repository. It is context, not the subject,
82unless the subject is about it.";
83
84const REPO_OPENING: &str = "\
85Brainstorm ideas for the repository in your working directory.
86
87Read it first: the README, the code, and the recent history, until you can say
88what it is for, who uses it, and what it does not do yet. The ideas are for
89this project: things it could do, or do differently, that would matter to the
90people who use it.{open_issues}";
91
92const CROSS_PROMPT: &str = "\
93Brainstorm, continued: {subject}
94
95Both agents proposed ideas independently. The full set is numbered below. The
96ones marked \"rule on this\" were proposed by the other agent and you have not
97seen them before. Rule on each of those, and only those. An idea marked \"both
98of you proposed this\" was reached independently by each of you, which is the
99strongest signal this session produces: build on it if you can, do not
100challenge it. Your own are listed so you can see the whole set.
101
102For each idea you rule on, one verdict:
103- build: you can see a further piece that completes it or makes it stronger,
104  or a way to combine it with another idea in the set. Write the revised idea
105  out in full in build, naming everything it combines including the new piece.
106  It stands beside the original rather than replacing it.
107- challenge: it already exists, and you can name where, or it cannot work, and
108  you can say why. Put the thing or the reason in reason. The proposer answers
109  you with specifics, so an objection you cannot substantiate costs a round for
110  nothing.
111- keep: real and new as far as you can tell, and you have nothing to add.
112
113Do not challenge to seem rigorous or build to seem agreeable. The set is only
114as good as the honesty of this round.
115
116{read_only}
117
118Ideas:
119{ideas}";
120
121const CONVERGE_PROMPT: &str = "\
122Brainstorm, last round: {subject}
123
124The full set is below, with what was said about each idea.
125
126Two things.
127
128First, {defend} Set stands=true only if you can give the specific evidence
129that settles the objection in reply: the prior art that does not actually do
130this, the mechanism the objection missed, the number that makes it work. Set
131stands=false to withdraw the idea, which is the right answer when the
132objection is correct. Withdrawing costs nothing. Defending a point you cannot
133substantiate puts it in front of a person with your name on it.
134
135Second, rank every idea that is not withdrawn, best first, in ranking. Weigh
136novelty and feasibility together: an idea nobody has done that could be tried
137this month beats a moonshot, and a moonshot beats a small improvement on
138something that exists. Include every number, your own and the ones you
139challenged. The other agent ranks the same set and the two rankings are
140combined mechanically, so rank by what you actually think.
141
142{read_only}
143
144Ideas:
145{ideas}";
146
147// ---------------------------------------------------------------------------
148// What a session is
149// ---------------------------------------------------------------------------
150
151/// What one `spar brainstorm` was asked for.
152#[derive(Debug, Clone)]
153pub struct Session {
154    /// The subject, or `None` to brainstorm the repository itself.
155    pub topic: Option<String>,
156    /// Rounds, from `[loop] max_rounds`.
157    pub budget: u32,
158    /// How many ideas survive the settle.
159    pub wanted: usize,
160}
161
162impl Session {
163    /// The subject as the prompts and the file name it.
164    pub fn subject(&self) -> String {
165        match &self.topic {
166            Some(topic) => topic.clone(),
167            None => "this repository".to_string(),
168        }
169    }
170}
171
172/// Where an idea stands after the rounds that have run.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum Standing {
175    /// Proposed by one agent and not yet ruled on by the other.
176    Unverified,
177    /// Both agents reached it independently. Never challenged.
178    Corroborated,
179    /// The other agent objected and the proposer has not answered.
180    Challenged,
181    /// Objected to and defended with specifics. A person decides.
182    Defended,
183    /// Objected to and withdrawn by the agent that proposed it.
184    Withdrawn,
185}
186
187/// One idea, with everything the rounds said about it.
188#[derive(Debug, Clone)]
189pub struct Candidate {
190    /// The number the agents see. Stable for the whole session, so a ruling
191    /// from any round matches back.
192    pub id: u32,
193    pub idea: Idea,
194    /// Who proposed it. Two names when both reached it independently.
195    pub proposers: Vec<String>,
196    /// The idea this was built on, when a cross round produced it.
197    pub parent: Option<u32>,
198    /// Who objected, and what they said.
199    pub objection: Option<(String, String)>,
200    /// What the proposer said back, when it stood by the idea.
201    pub defence: Option<String>,
202    pub standing: Standing,
203    /// The sum of its positions in the final rankings, lower first.
204    pub rank_sum: u32,
205}
206
207impl Candidate {
208    fn new(id: u32, idea: Idea, proposer: &str) -> Self {
209        Self {
210            id,
211            idea,
212            proposers: vec![proposer.to_string()],
213            parent: None,
214            objection: None,
215            defence: None,
216            standing: Standing::Unverified,
217            rank_sum: 0,
218        }
219    }
220
221    fn proposed_by(&self, name: &str) -> bool {
222        self.proposers.iter().any(|p| p == name)
223    }
224}
225
226/// What a session produced.
227#[derive(Debug, Clone)]
228pub struct Outcome {
229    pub subject: String,
230    pub agents: Vec<String>,
231    pub rounds: u32,
232    /// Ideas kept, best first.
233    pub kept: Vec<Candidate>,
234    /// Ideas set aside, each with the reason.
235    pub dropped: Vec<(Candidate, String)>,
236    pub notes: Vec<String>,
237}
238
239// ---------------------------------------------------------------------------
240// The rounds
241// ---------------------------------------------------------------------------
242
243/// Run the rounds and settle the set.
244pub fn run(agents: &[Agent], cfg: &Config, repo: &Repo, session: &Session) -> Result<Outcome> {
245    let budget = session.budget.max(1);
246    let work_dir = repo.root();
247    let subject = session.subject();
248    let names: Vec<String> = agents.iter().map(|a| a.name().to_string()).collect();
249    let mut notes = Vec::new();
250
251    // -- round 1: both propose, neither seeing the other ------------------
252    log!("brainstorm: {} diverging on {subject}", names.join(" and "));
253    let prompt = diverge_prompt(session, repo);
254    let answers = concurrently(agents, |agent| {
255        let effort = agent.effort(cfg, Call::Brainstorm(1));
256        agent.ask_json::<IdeasDoc>(&prompt, &schema::brainstorm_ideas(), work_dir, &effort)
257    });
258    let mut by_agent: Vec<(String, Vec<Idea>)> = Vec::new();
259    for (name, result) in answers {
260        match result {
261            Ok(doc) => {
262                let ideas: Vec<Idea> = doc
263                    .ideas
264                    .into_iter()
265                    .filter(|idea| !idea.title.trim().is_empty())
266                    .collect();
267                logdim!(
268                    "{name} proposed {} idea{}",
269                    ideas.len(),
270                    plural(ideas.len())
271                );
272                by_agent.push((name, ideas));
273            }
274            Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
275            Err(e) => {
276                logdim!("{name} could not brainstorm: {e}");
277                notes.push(format!("{name} did not answer: {e}"));
278            }
279        }
280    }
281    if by_agent.is_empty() {
282        bail!("neither agent returned any ideas");
283    }
284    let paired = by_agent.len() == 2;
285    if !paired {
286        // One voice is a materially weaker result, not a footnote: nothing
287        // was cross-checked, so nothing below carries two models' judgement.
288        crate::logging::warn(format!(
289            "only {} answered. Nothing was cross-checked, so these ideas carry one model's \
290             judgement rather than two.",
291            by_agent[0].0
292        ));
293        notes.push("only one agent answered, so nothing was cross-checked".into());
294    }
295    let mut set = merge(&by_agent);
296    if set.is_empty() {
297        bail!("neither agent proposed anything with a title");
298    }
299    let shared = set
300        .iter()
301        .filter(|c| c.standing == Standing::Corroborated)
302        .count();
303    if shared > 0 {
304        log!(
305            "brainstorm: {} idea{} in the set, {shared} reached by both",
306            set.len(),
307            plural(set.len())
308        );
309    } else {
310        log!(
311            "brainstorm: {} idea{} in the set",
312            set.len(),
313            plural(set.len())
314        );
315    }
316
317    // -- the middle rounds: each rules on what only the other proposed ----
318    let mut rounds = 1;
319    if paired {
320        let mut round = 2;
321        while round < budget {
322            cross(agents, cfg, work_dir, &subject, &mut set, round)?;
323            rounds = round;
324            round += 1;
325        }
326        // -- the last round: defend or withdraw, then rank ----------------
327        if budget >= 2 {
328            converge(agents, cfg, work_dir, &subject, &mut set, budget)?;
329            rounds = budget;
330        }
331    }
332
333    let (kept, dropped) = settle(set, session.wanted);
334    Ok(Outcome {
335        subject,
336        agents: names,
337        rounds,
338        kept,
339        dropped,
340        notes,
341    })
342}
343
344fn diverge_prompt(session: &Session, repo: &Repo) -> String {
345    let opening = match &session.topic {
346        Some(topic) => TOPIC_OPENING.replace("{subject}", topic.trim()),
347        None => REPO_OPENING.replace("{open_issues}", &open_issues_block(repo)),
348    };
349    DIVERGE_PROMPT
350        .replace("{opening}", &opening)
351        .replace("{count}", &session.wanted.to_string())
352        .replace("{read_only}", READ_ONLY)
353}
354
355/// What is already filed, so repo mode does not propose it again. Best
356/// effort: with no `gh`, no remote, or no access the list is empty, and the
357/// reader is told so rather than left to wonder why a filed idea came back.
358fn open_issues_block(repo: &Repo) -> String {
359    let rows = repo.open_issue_rows();
360    if rows.is_empty() {
361        logdim!("no open issues to exclude, or none could be listed");
362        return String::new();
363    }
364    let mut out =
365        String::from("\n\nThese are already filed as open issues, so do not propose them again:\n");
366    for row in rows {
367        out.push_str(&format!(
368            "- #{} {}\n",
369            row.number,
370            style::one_line(&row.title)
371        ));
372    }
373    out.trim_end().to_string()
374}
375
376/// Both lists into one numbered set. An idea both agents reached is kept once,
377/// in the longer wording, with both names on it.
378pub fn merge(by_agent: &[(String, Vec<Idea>)]) -> Vec<Candidate> {
379    let mut set: Vec<Candidate> = Vec::new();
380    for (name, ideas) in by_agent {
381        for idea in ideas {
382            let same = set
383                .iter_mut()
384                .find(|c| !c.proposed_by(name) && same_idea(&c.idea, idea));
385            match same {
386                Some(existing) => {
387                    if gist(idea).len() > gist(&existing.idea).len() {
388                        existing.idea = idea.clone();
389                    }
390                    existing.proposers.push(name.clone());
391                    existing.standing = Standing::Corroborated;
392                }
393                None => {
394                    let id = set.len() as u32 + 1;
395                    set.push(Candidate::new(id, idea.clone(), name));
396                }
397            }
398        }
399    }
400    set
401}
402
403/// Whether two ideas are the same idea in different words.
404///
405/// Title plus mechanism, because `same_point` wants three shared significant
406/// words and two short titles rarely have them even when they agree.
407fn same_idea(a: &Idea, b: &Idea) -> bool {
408    crate::textsim::same_point(&gist(a), &gist(b))
409}
410
411fn gist(idea: &Idea) -> String {
412    format!("{} {}", idea.title, idea.how_it_works)
413}
414
415fn cross(
416    agents: &[Agent],
417    cfg: &Config,
418    work_dir: &Path,
419    subject: &str,
420    set: &mut Vec<Candidate>,
421    round: u32,
422) -> Result<()> {
423    let pending = set
424        .iter()
425        .filter(|c| c.standing == Standing::Unverified)
426        .count();
427    if pending == 0 {
428        return Ok(());
429    }
430    log!(
431        "brainstorm round {round}: each agent rules on the {pending} idea{} only the other \
432         proposed",
433        plural(pending)
434    );
435    let answers = concurrently(agents, |agent| {
436        let theirs = set
437            .iter()
438            .filter(|c| c.standing == Standing::Unverified && !c.proposed_by(agent.name()))
439            .count();
440        if theirs == 0 {
441            return Ok(CrossDoc::default());
442        }
443        let prompt = CROSS_PROMPT
444            .replace("{subject}", subject)
445            .replace("{read_only}", READ_ONLY)
446            .replace("{ideas}", &ideas_for_prompt(set, Some(agent.name())));
447        agent.ask_json::<CrossDoc>(
448            &prompt,
449            &schema::brainstorm_cross(),
450            work_dir,
451            &agent.effort(cfg, Call::Brainstorm(round)),
452        )
453    });
454    let mut builds = Vec::new();
455    for (name, result) in answers {
456        let doc = match result {
457            Ok(doc) => doc,
458            Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
459            Err(e) => {
460                logdim!("{name} could not rule on the other's ideas: {e}");
461                continue;
462            }
463        };
464        for warning in apply_cross(set, &name, doc, &mut builds) {
465            logwarn!("{warning}");
466        }
467    }
468    if !builds.is_empty() {
469        log!(
470            "brainstorm round {round}: {} idea{} built on another",
471            builds.len(),
472            plural(builds.len())
473        );
474    }
475    set.extend(builds);
476    Ok(())
477}
478
479/// One agent's rulings onto the set. Builds are collected rather than pushed
480/// so a build cannot be ruled on in the round that produced it.
481///
482/// Returns what was dropped and why, for the caller to log.
483pub fn apply_cross(
484    set: &mut [Candidate],
485    name: &str,
486    doc: CrossDoc,
487    builds: &mut Vec<Candidate>,
488) -> Vec<String> {
489    let mut warnings = Vec::new();
490    for verdict in doc.verdicts {
491        let Some(target) = set.iter_mut().find(|c| i64::from(c.id) == verdict.idea) else {
492            warnings.push(format!(
493                "{name} ruled on idea {} which is not in the set, so that ruling was dropped",
494                verdict.idea
495            ));
496            continue;
497        };
498        if target.standing == Standing::Corroborated {
499            // Both proposed it, so neither may challenge it, and the prompt
500            // said so. Building on it is welcome from either.
501            if verdict.verdict != CrossKind::Build {
502                continue;
503            }
504        } else if target.proposed_by(name) {
505            warnings.push(format!(
506                "{name} ruled on its own idea {} and that ruling was dropped",
507                target.id
508            ));
509            continue;
510        } else if target.standing != Standing::Unverified && verdict.verdict != CrossKind::Build {
511            // One already ruled on stays ruled on.
512            continue;
513        }
514        match verdict.verdict {
515            CrossKind::Keep => {}
516            CrossKind::Challenge => {
517                target.standing = Standing::Challenged;
518                target.objection = Some((name.to_string(), verdict.reason));
519            }
520            CrossKind::Build => {
521                let Some(idea) = verdict.build.filter(|i| !i.title.trim().is_empty()) else {
522                    warnings.push(format!(
523                        "{name} said it built on idea {} but wrote nothing, so that was dropped",
524                        target.id
525                    ));
526                    continue;
527                };
528                let mut built = Candidate::new(0, idea, name);
529                built.parent = Some(target.id);
530                builds.push(built);
531            }
532        }
533    }
534    // Numbered after the set, densely, however many rulings were dropped and
535    // whichever agent's builds came first.
536    let mut id = set.len() as u32;
537    for built in builds.iter_mut() {
538        id += 1;
539        built.id = id;
540    }
541    warnings
542}
543
544fn converge(
545    agents: &[Agent],
546    cfg: &Config,
547    work_dir: &Path,
548    subject: &str,
549    set: &mut [Candidate],
550    round: u32,
551) -> Result<()> {
552    let challenged = set
553        .iter()
554        .filter(|c| c.standing == Standing::Challenged)
555        .count();
556    log!(
557        "brainstorm round {round}: {challenged} objection{} to answer, then both rank",
558        plural(challenged)
559    );
560    let answers = concurrently(agents, |agent| {
561        let mine: Vec<String> = set
562            .iter()
563            .filter(|c| c.standing == Standing::Challenged && c.proposed_by(agent.name()))
564            .map(|c| c.id.to_string())
565            .collect();
566        let defend = if mine.is_empty() {
567            "nothing you proposed was challenged, so defences is empty.".to_string()
568        } else {
569            format!(
570                "answer each objection to an idea you proposed. Yours that were challenged: \
571                 {}. One entry in defences for each.",
572                mine.join(", ")
573            )
574        };
575        let prompt = CONVERGE_PROMPT
576            .replace("{subject}", subject)
577            .replace("{defend}", &defend)
578            .replace("{read_only}", READ_ONLY)
579            .replace("{ideas}", &ideas_for_prompt(set, None));
580        agent.ask_json::<ConvergeDoc>(
581            &prompt,
582            &schema::brainstorm_converge(),
583            work_dir,
584            &agent.effort(cfg, Call::Brainstorm(round)),
585        )
586    });
587    for (name, result) in answers {
588        let doc = match result {
589            Ok(doc) => doc,
590            Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
591            Err(e) => {
592                logdim!("{name} could not rank: {e}");
593                continue;
594            }
595        };
596        for warning in apply_converge(set, &name, doc) {
597            logwarn!("{warning}");
598        }
599    }
600    Ok(())
601}
602
603/// One agent's defences and ranking onto the set.
604///
605/// A defence counts only from an agent that proposed the idea. An idea an
606/// agent left out of its ranking scores one place below its last, so an
607/// omission costs the idea rather than the agent that forgot it.
608pub fn apply_converge(set: &mut [Candidate], name: &str, doc: ConvergeDoc) -> Vec<String> {
609    let mut warnings = Vec::new();
610    for defence in doc.defences {
611        let Some(target) = set.iter_mut().find(|c| i64::from(c.id) == defence.idea) else {
612            warnings.push(format!(
613                "{name} defended idea {} which is not in the set, so that was dropped",
614                defence.idea
615            ));
616            continue;
617        };
618        if !target.proposed_by(name) || target.standing != Standing::Challenged {
619            continue;
620        }
621        if defence.stands {
622            target.standing = Standing::Defended;
623            target.defence = Some(defence.reply);
624        } else {
625            target.standing = Standing::Withdrawn;
626        }
627    }
628    let mut placed: Vec<u32> = Vec::new();
629    for (position, id) in doc.ranking.iter().enumerate() {
630        let Some(target) = set.iter_mut().find(|c| i64::from(c.id) == *id) else {
631            warnings.push(format!(
632                "{name} ranked idea {id} which is not in the set, so that place was dropped"
633            ));
634            continue;
635        };
636        if placed.contains(&target.id) {
637            continue;
638        }
639        placed.push(target.id);
640        target.rank_sum += position as u32 + 1;
641    }
642    let last = placed.len() as u32 + 1;
643    for c in set.iter_mut() {
644        if c.standing != Standing::Withdrawn && !placed.contains(&c.id) {
645            c.rank_sum += last;
646        }
647    }
648    warnings
649}
650
651/// Keep the best `wanted`, and say why the rest were set aside.
652///
653/// Reached by both first, then by rank, then by number. A challenged idea whose
654/// proposer never answered goes with the withdrawn ones: nobody stood by it.
655pub fn settle(set: Vec<Candidate>, wanted: usize) -> (Vec<Candidate>, Vec<(Candidate, String)>) {
656    let mut standing = Vec::new();
657    let mut dropped = Vec::new();
658    for c in set {
659        match c.standing {
660            Standing::Withdrawn => {
661                let why = objection_line(&c, "withdrawn after");
662                dropped.push((c, why));
663            }
664            Standing::Challenged => {
665                let why = objection_line(&c, "not defended against");
666                dropped.push((c, why));
667            }
668            _ => standing.push(c),
669        }
670    }
671    standing.sort_by_key(|c| (c.standing != Standing::Corroborated, c.rank_sum, c.id));
672    let rest = standing.split_off(wanted.min(standing.len()));
673    for c in rest {
674        dropped.push((c, "ranked below the cut".into()));
675    }
676    (standing, dropped)
677}
678
679/// One line for the header list. The objection is clipped to a sentence or
680/// two: the whole argument is in the run's log, and a paragraph per dropped
681/// idea would push the ideas that were kept off the first screen.
682fn objection_line(c: &Candidate, lead: &str) -> String {
683    match &c.objection {
684        Some((who, why)) => format!(
685            "{lead} {who}'s objection: {}",
686            style::clip(&style::one_line(why), 240)
687        ),
688        None => format!("{lead} an objection"),
689    }
690}
691
692// ---------------------------------------------------------------------------
693// Rendering
694// ---------------------------------------------------------------------------
695
696/// The set as the agents read it: numbered, tagged by whose it is, with what
697/// has been said about each. `reader` is the agent being asked, so the tags
698/// can say which ideas are theirs to rule on.
699fn ideas_for_prompt(set: &[Candidate], reader: Option<&str>) -> String {
700    let mut out = String::new();
701    for c in set {
702        let tag = match (reader, c.standing) {
703            (_, Standing::Corroborated) => "both of you proposed this".to_string(),
704            (Some(me), Standing::Unverified) if c.proposed_by(me) => "yours".to_string(),
705            (Some(_), Standing::Unverified) => "rule on this".to_string(),
706            (_, Standing::Withdrawn) => "withdrawn".to_string(),
707            _ => format!("proposed by {}", c.proposers.join(" and ")),
708        };
709        out.push_str(&format!(
710            "{}. {} [{tag}]\n",
711            c.id,
712            style::one_line(&c.idea.title)
713        ));
714        if let Some(parent) = c.parent {
715            out.push_str(&format!("   builds on idea {parent}\n"));
716        }
717        out.push_str(&format!(
718            "   combines: {}\n",
719            c.idea
720                .combines
721                .iter()
722                .map(|s| style::one_line(s))
723                .collect::<Vec<_>>()
724                .join("; ")
725        ));
726        for (label, text) in [
727            ("how it works", &c.idea.how_it_works),
728            ("why new", &c.idea.why_new),
729            ("enables", &c.idea.enables),
730            ("risks", &c.idea.risks),
731            ("first experiment", &c.idea.first_experiment),
732        ] {
733            if !text.trim().is_empty() {
734                out.push_str(&format!("   {label}: {}\n", style::one_line(text)));
735            }
736        }
737        if let Some((who, why)) = &c.objection {
738            out.push_str(&format!(
739                "   objection from {who}: {}\n",
740                style::one_line(why)
741            ));
742        }
743        if let Some(reply) = &c.defence {
744            out.push_str(&format!("   reply: {}\n", style::one_line(reply)));
745        }
746        out.push('\n');
747    }
748    out.trim_end().to_string()
749}
750
751/// The six sections an idea is written in, wherever it is written.
752///
753/// One renderer for the session file and the issue body, so a person who read
754/// one recognises the other, and so the headings stay `### `: the session
755/// parser splits entries on `## ` and would take a `## Risks` for a new idea.
756pub fn idea_sections(idea: &Idea, style: &Style) -> String {
757    let mut out = String::new();
758    out.push_str("### Combines\n");
759    let combines: Vec<String> = idea
760        .combines
761        .iter()
762        .map(|s| style::scrub(&style::one_line(s), style))
763        .filter(|s| !s.is_empty())
764        .collect();
765    if combines.is_empty() {
766        out.push_str("(not named)\n");
767    }
768    for piece in combines {
769        out.push_str(&format!("- {piece}\n"));
770    }
771    for (heading, text) in [
772        ("How it works", &idea.how_it_works),
773        ("Why it is new", &idea.why_new),
774        ("What it enables", &idea.enables),
775        ("Risks", &idea.risks),
776        ("First experiment", &idea.first_experiment),
777    ] {
778        out.push_str(&format!("\n### {heading}\n"));
779        let text = style::scrub(text, style);
780        if text.is_empty() {
781            out.push_str("(not given)\n");
782        } else {
783            out.push_str(&text);
784            out.push('\n');
785        }
786    }
787    out
788}
789
790/// The objection and the reply, for a reader deciding whether to try it.
791/// Unnamed on purpose: who said it is provenance, and provenance stays out of
792/// anything that may be filed.
793fn discussion(c: &Candidate, style: &Style) -> String {
794    let mut out = String::new();
795    if let Some((_, why)) = &c.objection {
796        out.push_str(&format!(
797            "\n### Discussion\nObjection: {}\n",
798            style::scrub(why, style)
799        ));
800        if let Some(reply) = &c.defence {
801            out.push_str(&format!("\nReply: {}\n", style::scrub(reply, style)));
802        }
803    }
804    out
805}
806
807/// The whole body of an idea as it is filed: the sections and the discussion.
808pub fn issue_body(c: &Candidate, style: &Style) -> String {
809    let mut body = idea_sections(&c.idea, style);
810    body.push_str(&discussion(c, style));
811    body.trim_end().to_string()
812}
813
814/// Who proposed it and what happened to it. Written above the sections in the
815/// session file, and stripped again by [`parse_session`], so it never reaches
816/// an issue: agent names are usually vendor names, and the attribution gate
817/// exists to keep those off a tracker.
818fn provenance(c: &Candidate, set: &[Candidate]) -> String {
819    let mut line = if c.standing == Standing::Corroborated {
820        format!("Reached independently by {}.", c.proposers.join(" and "))
821    } else {
822        match c.parent.and_then(|id| set.iter().find(|p| p.id == id)) {
823            Some(parent) => format!(
824                "Proposed by {}, building on \"{}\".",
825                c.proposers.join(" and "),
826                style::one_line(&parent.idea.title)
827            ),
828            None => format!("Proposed by {}.", c.proposers.join(" and ")),
829        }
830    };
831    if let (Some((who, _)), Standing::Defended) = (&c.objection, c.standing) {
832        line.push_str(&format!(
833            " Challenged by {who}, defended by {}.",
834            c.proposers.join(" and ")
835        ));
836    }
837    line
838}
839
840const PROVENANCE_LEADS: [&str; 2] = ["Proposed by ", "Reached independently by "];
841
842/// The session file.
843///
844/// A header a person reads, then one entry per kept idea in the follow-up
845/// queue's shape, so `parse_session` and `spar followup --file` both read it
846/// back. Two rules keep that true and the tests hold them: no `## ` line other
847/// than an idea's title, and nothing after the last idea, which is why the
848/// ideas set aside are listed in the header rather than at the end.
849pub fn render_session(outcome: &Outcome, when: &str, style: &Style) -> String {
850    let mut out = String::new();
851    out.push_str(&format!(
852        "# Brainstorm: {}\n",
853        style::scrub(&style::one_line(&outcome.subject), style)
854    ));
855    out.push_str(&format!("Date: {when}\n"));
856    out.push_str(&format!("Agents: {}\n", outcome.agents.join(", ")));
857    out.push_str(&format!("Rounds: {}\n", outcome.rounds));
858    for note in &outcome.notes {
859        out.push_str(&format!(
860            "Note: {}\n",
861            style::scrub(&style::one_line(note), style)
862        ));
863    }
864    if !outcome.dropped.is_empty() {
865        out.push_str("\nAlso considered, and set aside:\n");
866        for (c, why) in &outcome.dropped {
867            out.push_str(&format!(
868                "- {}: {}\n",
869                style::scrub(&style::one_line(&c.idea.title), style),
870                style::scrub(why, style)
871            ));
872        }
873    }
874    let all: Vec<Candidate> = outcome
875        .kept
876        .iter()
877        .chain(outcome.dropped.iter().map(|(c, _)| c))
878        .cloned()
879        .collect();
880    for c in &outcome.kept {
881        out.push_str(&format!(
882            "\n{FOLLOWUP_MARKER}\n## {}\n\n{}\n\n",
883            style::title(&c.idea.title, style),
884            style::scrub(&provenance(c, &all), style)
885        ));
886        out.push_str(&idea_sections(&c.idea, style));
887        out.push_str(&discussion(c, style));
888    }
889    out.trim_end().to_string() + "\n"
890}
891
892/// The ideas in a saved session, as title and body, with the provenance line
893/// taken out of each body so what is filed carries no agent's name.
894pub fn parse_session(text: &str) -> Vec<(String, String)> {
895    crate::followups::parse(text)
896        .into_iter()
897        .filter(|entry| !entry.title.trim().is_empty())
898        .map(|entry| {
899            let body: Vec<&str> = entry
900                .body
901                .lines()
902                .filter(|line| !PROVENANCE_LEADS.iter().any(|lead| line.starts_with(lead)))
903                .collect();
904            (entry.title, body.join("\n").trim().to_string())
905        })
906        .collect()
907}
908
909/// What the kept ideas become when filed from a live run.
910pub fn issue_entries(outcome: &Outcome, style: &Style) -> Vec<(String, String)> {
911    outcome
912        .kept
913        .iter()
914        .map(|c| (c.idea.title.clone(), issue_body(c, style)))
915        .collect()
916}
917
918/// File each idea, or add it to the issue that already covers it. One failure
919/// does not stop the rest; the write ledger makes the exit status say so.
920pub fn file_ideas(repo: &Repo, entries: &[(String, String)]) {
921    for (title, body) in entries {
922        match crate::review::file_as_issue(repo, title, body) {
923            Ok(filed) => println!("  {}", filed.describe(title)),
924            Err(e) => {
925                crate::logging::error(format!("could not file '{}': {e}", style::clip(title, 60)))
926            }
927        }
928    }
929}
930
931/// What the terminal gets: the kept ideas in order, and where the rest went.
932pub fn print_summary(outcome: &Outcome, path: &Path) {
933    println!();
934    println!(
935        "{} idea{} kept, {} set aside, {} round{}",
936        outcome.kept.len(),
937        plural(outcome.kept.len()),
938        outcome.dropped.len(),
939        outcome.rounds,
940        plural(outcome.rounds as usize)
941    );
942    for (i, c) in outcome.kept.iter().enumerate() {
943        let mark = match c.standing {
944            Standing::Corroborated => " (reached by both)",
945            Standing::Defended => " (challenged and defended)",
946            _ => "",
947        };
948        println!(
949            "  {}. {}{mark}",
950            i + 1,
951            style::clip_bare(&style::one_line(&c.idea.title), 90)
952        );
953    }
954    for note in &outcome.notes {
955        println!("  note: {note}");
956    }
957    println!("\nwritten to {}", path.display());
958}
959
960/// Where a session goes unless `--out` says otherwise.
961pub fn default_path(repo: &Repo, session: &Session, now_secs: u64) -> PathBuf {
962    let name = match &session.topic {
963        Some(topic) => slug(topic),
964        None => "repo".to_string(),
965    };
966    repo.brainstorms_dir()
967        .join(format!("{}-{name}.md", file_stamp(now_secs)))
968}
969
970/// A file name fragment from a topic: lowercase, runs of anything but a letter
971/// or digit collapsed to one hyphen, cut at a word, "idea" when nothing is left.
972pub fn slug(text: &str) -> String {
973    let mut out = String::new();
974    let mut gap = false;
975    for ch in text.chars().flat_map(char::to_lowercase) {
976        if ch.is_ascii_alphanumeric() {
977            if gap && !out.is_empty() {
978                out.push('-');
979            }
980            gap = false;
981            out.push(ch);
982        } else {
983            gap = true;
984        }
985    }
986    if out.len() > 40 {
987        let cut = out[..40].rfind('-').unwrap_or(40);
988        out.truncate(cut);
989    }
990    if out.is_empty() {
991        "idea".to_string()
992    } else {
993        out
994    }
995}
996
997fn plural(n: usize) -> &'static str {
998    if n == 1 {
999        ""
1000    } else {
1001        "s"
1002    }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008    use crate::model::{CrossVerdict, Defence};
1009
1010    fn idea(title: &str, how: &str) -> Idea {
1011        Idea {
1012            title: title.into(),
1013            combines: vec!["hash time locks".into(), "batch auctions".into()],
1014            how_it_works: how.into(),
1015            why_new: "The nearest thing is a plain escrow, which cannot batch.".into(),
1016            enables: "Settling many trades in one transaction.".into(),
1017            risks: "Fee spikes make the batch uneconomic.".into(),
1018            first_experiment: "Simulate ten trades on regtest.".into(),
1019        }
1020    }
1021
1022    fn pair(a: Vec<Idea>, b: Vec<Idea>) -> Vec<Candidate> {
1023        merge(&[("a".to_string(), a), ("b".to_string(), b)])
1024    }
1025
1026    #[test]
1027    fn an_idea_both_agents_reach_is_kept_once_with_both_names() {
1028        let set = pair(
1029            vec![idea(
1030                "Batch settlement with timelocks",
1031                "Each trade posts a hash timelock and a batch auction clears them together at the deadline.",
1032            )],
1033            vec![idea(
1034                "Timelocked batch auction settlement",
1035                "Trades post hash timelocks, and one batch auction clears every one of them at the deadline together.",
1036            )],
1037        );
1038        assert_eq!(1, set.len());
1039        assert_eq!(Standing::Corroborated, set[0].standing);
1040        assert_eq!(vec!["a", "b"], set[0].proposers);
1041    }
1042
1043    #[test]
1044    fn different_ideas_get_their_own_numbers_in_order() {
1045        let set = pair(
1046            vec![idea(
1047                "One",
1048                "A covenant restricts the spend path to a template.",
1049            )],
1050            vec![idea(
1051                "Two",
1052                "A watchtower gossips penalty transactions over a mesh radio.",
1053            )],
1054        );
1055        assert_eq!(vec![1, 2], set.iter().map(|c| c.id).collect::<Vec<_>>());
1056        assert!(set.iter().all(|c| c.standing == Standing::Unverified));
1057    }
1058
1059    fn two() -> Vec<Candidate> {
1060        pair(
1061            vec![idea(
1062                "One",
1063                "A covenant restricts the spend path to a template.",
1064            )],
1065            vec![idea(
1066                "Two",
1067                "A watchtower gossips penalty transactions over a mesh radio.",
1068            )],
1069        )
1070    }
1071
1072    fn verdict(id: i64, kind: CrossKind, build: Option<Idea>) -> CrossVerdict {
1073        CrossVerdict {
1074            idea: id,
1075            verdict: kind,
1076            reason: "because".into(),
1077            build,
1078        }
1079    }
1080
1081    #[test]
1082    fn a_challenge_records_who_objected_and_why() {
1083        let mut set = two();
1084        let mut builds = Vec::new();
1085        let doc = CrossDoc {
1086            verdicts: vec![verdict(1, CrossKind::Challenge, None)],
1087        };
1088        let warnings = apply_cross(&mut set, "b", doc, &mut builds);
1089        assert!(warnings.is_empty(), "{warnings:?}");
1090        assert_eq!(Standing::Challenged, set[0].standing);
1091        assert_eq!(
1092            Some(("b".to_string(), "because".to_string())),
1093            set[0].objection
1094        );
1095        assert_eq!(Standing::Unverified, set[1].standing);
1096    }
1097
1098    /// An agent rules on the other's ideas, never its own, and a ruling on a
1099    /// number that is not in the set says so rather than landing somewhere.
1100    #[test]
1101    fn rulings_on_your_own_idea_or_an_unknown_one_are_dropped_with_a_reason() {
1102        let mut set = two();
1103        let mut builds = Vec::new();
1104        let doc = CrossDoc {
1105            verdicts: vec![
1106                verdict(2, CrossKind::Challenge, None),
1107                verdict(9, CrossKind::Challenge, None),
1108            ],
1109        };
1110        let warnings = apply_cross(&mut set, "b", doc, &mut builds);
1111        assert_eq!(2, warnings.len(), "{warnings:?}");
1112        assert!(warnings[0].contains("its own idea 2"));
1113        assert!(warnings[1].contains("idea 9"));
1114        assert!(set.iter().all(|c| c.standing == Standing::Unverified));
1115    }
1116
1117    #[test]
1118    fn a_build_becomes_a_new_idea_numbered_after_the_set_and_linked_to_its_parent() {
1119        let mut set = two();
1120        let mut builds = Vec::new();
1121        let doc = CrossDoc {
1122            verdicts: vec![
1123                verdict(1, CrossKind::Keep, None),
1124                verdict(
1125                    1,
1126                    CrossKind::Build,
1127                    Some(idea(
1128                        "One, plus",
1129                        "The covenant template also commits to a fee.",
1130                    )),
1131                ),
1132                verdict(1, CrossKind::Build, None),
1133            ],
1134        };
1135        let warnings = apply_cross(&mut set, "b", doc, &mut builds);
1136        assert_eq!(1, warnings.len(), "{warnings:?}");
1137        assert!(warnings[0].contains("wrote nothing"));
1138        assert_eq!(1, builds.len());
1139        assert_eq!(3, builds[0].id);
1140        assert_eq!(Some(1), builds[0].parent);
1141        assert_eq!(vec!["b"], builds[0].proposers);
1142        assert_eq!(Standing::Unverified, builds[0].standing);
1143    }
1144
1145    #[test]
1146    fn an_idea_both_reached_cannot_be_challenged_but_can_be_built_on() {
1147        let mut set = pair(
1148            vec![idea(
1149                "Batch settlement with timelocks",
1150                "Each trade posts a hash timelock and a batch auction clears them together at the deadline.",
1151            )],
1152            vec![idea(
1153                "Timelocked batch auction settlement",
1154                "Trades post hash timelocks, and one batch auction clears every one of them at the deadline together.",
1155            )],
1156        );
1157        // A challenge is ignored, as the prompt said it would be. A build is
1158        // not, from either of them.
1159        let mut builds = Vec::new();
1160        let warnings = apply_cross(
1161            &mut set,
1162            "b",
1163            CrossDoc {
1164                verdicts: vec![
1165                    verdict(1, CrossKind::Challenge, None),
1166                    verdict(
1167                        1,
1168                        CrossKind::Build,
1169                        Some(idea("Batched, plus fees", "The auction also clears fees.")),
1170                    ),
1171                ],
1172            },
1173            &mut builds,
1174        );
1175        assert!(warnings.is_empty(), "{warnings:?}");
1176        assert_eq!(Standing::Corroborated, set[0].standing);
1177        assert!(set[0].objection.is_none());
1178        assert_eq!(1, builds.len());
1179        assert_eq!(Some(1), builds[0].parent);
1180        assert_eq!(2, builds[0].id);
1181    }
1182
1183    #[test]
1184    fn a_defence_stands_or_withdraws_and_only_the_proposer_may_give_one() {
1185        let mut set = two();
1186        set[0].standing = Standing::Challenged;
1187        set[0].objection = Some(("b".into(), "exists".into()));
1188        set[1].standing = Standing::Challenged;
1189        set[1].objection = Some(("a".into(), "cannot work".into()));
1190        let doc = ConvergeDoc {
1191            defences: vec![
1192                Defence {
1193                    idea: 1,
1194                    stands: true,
1195                    reply: "the prior art cannot batch".into(),
1196                },
1197                Defence {
1198                    idea: 2,
1199                    stands: false,
1200                    reply: String::new(),
1201                },
1202            ],
1203            ranking: vec![1, 2],
1204        };
1205        // "a" proposed 1, so its word on 2 does not count.
1206        let warnings = apply_converge(&mut set, "a", doc);
1207        assert!(warnings.is_empty(), "{warnings:?}");
1208        assert_eq!(Standing::Defended, set[0].standing);
1209        assert_eq!(
1210            Some("the prior art cannot batch".to_string()),
1211            set[0].defence
1212        );
1213        assert_eq!(Standing::Challenged, set[1].standing);
1214    }
1215
1216    /// Leaving an idea out of a ranking is not a way to bury it silently: it
1217    /// scores one place below the last one named.
1218    #[test]
1219    fn an_omitted_idea_ranks_last_and_two_rankings_add_up() {
1220        let mut set = two();
1221        set.push(Candidate::new(3, idea("Three", "A third mechanism."), "a"));
1222        apply_converge(
1223            &mut set,
1224            "a",
1225            ConvergeDoc {
1226                defences: vec![],
1227                ranking: vec![3, 1],
1228            },
1229        );
1230        apply_converge(
1231            &mut set,
1232            "b",
1233            ConvergeDoc {
1234                defences: vec![],
1235                ranking: vec![1, 2, 3, 1],
1236            },
1237        );
1238        // a: 3 first, 1 second, 2 omitted so third. b: 1, 2, 3 in order.
1239        assert_eq!(2 + 1, set[0].rank_sum);
1240        assert_eq!(3 + 2, set[1].rank_sum);
1241        assert_eq!(1 + 3, set[2].rank_sum);
1242    }
1243
1244    #[test]
1245    fn settle_keeps_the_best_and_says_why_each_of_the_rest_went() {
1246        let mut set = two();
1247        set.push(Candidate::new(3, idea("Three", "A third mechanism."), "a"));
1248        set.push(Candidate::new(4, idea("Four", "A fourth mechanism."), "b"));
1249        set[0].rank_sum = 5;
1250        set[1].rank_sum = 2;
1251        set[1].standing = Standing::Corroborated;
1252        set[2].standing = Standing::Withdrawn;
1253        set[2].objection = Some(("b".into(), "already in bip 119".into()));
1254        set[3].standing = Standing::Challenged;
1255        set[3].objection = Some(("a".into(), "breaks under reorg".into()));
1256        let (kept, dropped) = settle(set, 1);
1257        assert_eq!(vec![2], kept.iter().map(|c| c.id).collect::<Vec<_>>());
1258        let reasons: Vec<(u32, String)> =
1259            dropped.iter().map(|(c, why)| (c.id, why.clone())).collect();
1260        assert_eq!(3, reasons.len());
1261        assert_eq!(
1262            (
1263                3,
1264                "withdrawn after b's objection: already in bip 119".to_string()
1265            ),
1266            reasons[0]
1267        );
1268        assert_eq!(
1269            (
1270                4,
1271                "not defended against a's objection: breaks under reorg".to_string()
1272            ),
1273            reasons[1]
1274        );
1275        assert_eq!((1, "ranked below the cut".to_string()), reasons[2]);
1276    }
1277
1278    #[test]
1279    fn reached_by_both_comes_first_whatever_the_ranking_said() {
1280        let mut set = two();
1281        set[0].rank_sum = 2;
1282        set[1].rank_sum = 4;
1283        set[1].standing = Standing::Corroborated;
1284        let (kept, _) = settle(set, 5);
1285        assert_eq!(vec![2, 1], kept.iter().map(|c| c.id).collect::<Vec<_>>());
1286    }
1287
1288    fn outcome() -> Outcome {
1289        let mut set = two();
1290        set[0].standing = Standing::Defended;
1291        set[0].objection = Some(("b".into(), "CTV does this already".into()));
1292        set[0].defence = Some("CTV cannot commit to the fee".into());
1293        let mut built = Candidate::new(3, idea("Three", "A third mechanism."), "b");
1294        built.parent = Some(1);
1295        set.push(built);
1296        set[1].standing = Standing::Withdrawn;
1297        set[1].objection = Some(("a".into(), "radios are not the bottleneck".into()));
1298        let (kept, dropped) = settle(set, 5);
1299        Outcome {
1300            subject: "novel uses of covenants".into(),
1301            agents: vec!["a".into(), "b".into()],
1302            rounds: 3,
1303            kept,
1304            dropped,
1305            notes: vec![],
1306        }
1307    }
1308
1309    /// The whole reason the file is shaped as it is: what spar wrote, spar can
1310    /// read back, and so can `spar followup --file`.
1311    #[test]
1312    fn the_session_file_round_trips_through_the_follow_up_parser() {
1313        let style = Style::default();
1314        let text = render_session(&outcome(), "2026-09-12 14:03 UTC", &style);
1315        let entries = parse_session(&text);
1316        assert_eq!(2, entries.len(), "{text}");
1317        assert_eq!("One", entries[0].0);
1318        assert_eq!("Three", entries[1].0);
1319        for (_, body) in &entries {
1320            assert!(body.starts_with("### Combines"), "{body}");
1321            assert!(!body.contains("Proposed by"), "{body}");
1322            assert!(!body.contains("Challenged by"), "{body}");
1323            assert!(body.contains("### First experiment"), "{body}");
1324        }
1325        assert!(entries[0].1.contains("Objection: CTV does this already"));
1326        assert!(entries[0].1.contains("Reply: CTV cannot commit to the fee"));
1327        // Nothing after the last idea, and no heading that would open one.
1328        let headings: Vec<&str> = text.lines().filter(|l| l.starts_with("## ")).collect();
1329        assert_eq!(vec!["## One", "## Three"], headings);
1330        assert!(
1331            text.contains("Also considered, and set aside:\n- Two: withdrawn after a's objection")
1332        );
1333        assert!(text.contains("Proposed by b, building on \"One\"."));
1334        assert!(text.contains("Challenged by b, defended by a."));
1335    }
1336
1337    #[test]
1338    fn what_is_written_passes_the_style_gate() {
1339        let style = Style::default();
1340        let mut out = outcome();
1341        out.kept[0].idea.how_it_works =
1342            "A covenant \u{2014} the template kind \u{2014} restricts it.".into();
1343        out.kept[0]
1344            .idea
1345            .combines
1346            .push("Generated with Claude Code".into());
1347        let text = render_session(&out, "2026-09-12 14:03 UTC", &style);
1348        assert!(style::violations(&text, &style).is_empty(), "{text}");
1349        assert!(text.contains("A covenant, the template kind, restricts it."));
1350        let body = issue_body(&out.kept[0], &style);
1351        assert!(style::violations(&body, &style).is_empty(), "{body}");
1352        assert!(!body.contains("Proposed by"));
1353    }
1354
1355    #[test]
1356    fn the_prompt_listing_tags_whose_ideas_are_whose() {
1357        let mut set = two();
1358        set.push(Candidate::new(3, idea("Three", "A third mechanism."), "a"));
1359        set[2].standing = Standing::Corroborated;
1360        set[2].proposers.push("b".into());
1361        let text = ideas_for_prompt(&set, Some("a"));
1362        assert!(text.contains("1. One [yours]"), "{text}");
1363        assert!(text.contains("2. Two [rule on this]"), "{text}");
1364        assert!(
1365            text.contains("3. Three [both of you proposed this]"),
1366            "{text}"
1367        );
1368        let text = ideas_for_prompt(&set, None);
1369        assert!(text.contains("1. One [proposed by a]"), "{text}");
1370    }
1371
1372    #[test]
1373    fn slugs_are_file_names() {
1374        assert_eq!(
1375            "novel-uses-of-bitcoin-script",
1376            slug("Novel uses of Bitcoin Script!")
1377        );
1378        assert_eq!("idea", slug("   ???  "));
1379        let long = slug("one two three four five six seven eight nine ten eleven twelve");
1380        assert!(long.len() <= 40, "{long}");
1381        assert!(!long.ends_with('-'));
1382        assert_eq!("one-two-three-four-five-six-seven-eight", long);
1383    }
1384
1385    /// The prompts and the schemas say the same things about the same fields.
1386    #[test]
1387    fn the_prompts_name_every_field_the_schema_asks_for() {
1388        let schema = schema::brainstorm_ideas();
1389        let fields = schema["properties"]["ideas"]["items"]["properties"]
1390            .as_object()
1391            .unwrap();
1392        for field in fields.keys() {
1393            assert!(
1394                DIVERGE_PROMPT.contains(field.as_str()),
1395                "{field} is in the schema but not the diverge prompt"
1396            );
1397        }
1398        for verdict in ["build", "challenge", "keep"] {
1399            assert!(CROSS_PROMPT.contains(&format!("- {verdict}:")));
1400        }
1401        assert!(CONVERGE_PROMPT.contains("stands=true"));
1402        assert!(CONVERGE_PROMPT.contains("ranking"));
1403    }
1404}