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