Skip to main content

speccoder_core/
explore.rs

1//! Explore's pure parts: the report a session's run ends with (a claim,
2//! checked here), the brief the person's choice composes and its digest,
3//! the session's states, and the record a resumed run carries. No IO: the
4//! runtime stores sessions and runs the skills.
5
6use serde::{Deserialize, Serialize};
7
8use crate::actions::Via;
9
10/// The most a brief may hold.
11pub const BRIEF_MAX: usize = 16 * 1024;
12
13/// What a resumed run carries of the session, at most.
14pub const CARRIED_MAX: usize = 24_000;
15
16/// The skill `spec explore` falls back to when the format has none.
17pub const FALLBACK: &str = "speccoder.explore";
18
19/// One option the model proposes: a claim, never a decision.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
21pub struct Approach {
22    /// A short name.
23    pub title: String,
24    /// What it is.
25    pub summary: String,
26    /// What it gives up.
27    #[serde(default)]
28    pub tradeoffs: Vec<String>,
29    /// What could go wrong.
30    #[serde(default)]
31    pub risks: Vec<String>,
32}
33
34/// How a run of a session ended: the model's report, schema-checked.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
36pub struct ExploreReport {
37    /// The model's summary of the idea.
38    pub summary: String,
39    /// `done` or `incomplete`.
40    pub status: String,
41    /// The options.
42    #[serde(default)]
43    pub approaches: Vec<Approach>,
44    /// What any approach must respect.
45    #[serde(default)]
46    pub constraints: Vec<String>,
47    /// What is still open.
48    #[serde(default)]
49    pub open_questions: Vec<String>,
50}
51
52/// A constraint or open question, by its source.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
54pub struct Item {
55    /// Its words.
56    pub text: String,
57    /// `model`, `person` or `nobody` (a question nobody could answer).
58    pub source: String,
59}
60
61/// A question the run asked and how it was answered.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
63pub struct QaItem {
64    /// The model's question.
65    pub question: String,
66    /// The person's answer; none when nobody could answer.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub answer: Option<String>,
69    /// Who answered.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub by: Option<String>,
72}
73
74/// The person's choice: an approach and what they kept or added.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
76pub struct Choice {
77    /// 1-based, of the last report's approaches.
78    pub approach: usize,
79    /// Kept and added constraints.
80    pub constraints: Vec<Item>,
81    /// Kept and added open questions.
82    pub open_questions: Vec<Item>,
83    /// Who chose.
84    pub by: String,
85    /// Through what.
86    pub via: Via,
87    /// `prompt` (picked at a terminal or in the app) or `flag` (`--choose`).
88    pub basis: String,
89    /// Unix seconds.
90    pub at: u64,
91    /// The brief's digest (hex SHA-256 of its body).
92    pub digest: String,
93    /// The brief's size.
94    pub bytes: usize,
95}
96
97/// One run of a session.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
99pub struct RunRef {
100    /// The agentplane run.
101    pub run_id: String,
102    /// `explore` or `resume`.
103    pub kind: String,
104    /// What the person added when resuming.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub words: Option<String>,
107    /// How it ended.
108    pub status: String,
109}
110
111/// The unit a session started.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
113pub struct Started {
114    /// The unit.
115    pub unit: String,
116    /// Unix seconds.
117    pub at: u64,
118}
119
120/// One exploration of one idea in one project.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
122pub struct ExploreSession {
123    /// 1.
124    pub version: u32,
125    /// `ex-` and hex.
126    pub id: String,
127    /// The person's idea.
128    pub idea: String,
129    /// The skill that explores.
130    pub skill: String,
131    /// `flag`, `format` or `fallback`.
132    pub selected_because: String,
133    /// `open`, `chosen` or `started`.
134    pub state: String,
135    /// Unix seconds.
136    pub created_at: u64,
137    /// Unix seconds.
138    pub updated_at: u64,
139    /// Its runs, in order.
140    #[serde(default)]
141    pub runs: Vec<RunRef>,
142    /// Each run's report, in order.
143    #[serde(default)]
144    pub reports: Vec<ExploreReport>,
145    /// The questions asked and their answers.
146    #[serde(default)]
147    pub qa: Vec<QaItem>,
148    /// The choices made, the last one standing.
149    #[serde(default)]
150    pub choices: Vec<Choice>,
151    /// The unit it started, once.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub started: Option<Started>,
154}
155
156/// One session in a list.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
158pub struct SessionEntry {
159    /// `ex-…`.
160    pub id: String,
161    /// The idea.
162    pub idea: String,
163    /// The skill that explores it.
164    pub skill: String,
165    /// `open`, `chosen` or `started`.
166    pub state: String,
167    /// Unix seconds.
168    pub updated_at: u64,
169    /// The chosen brief's digest.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub digest: Option<String>,
172    /// The unit it started.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub unit: Option<String>,
175}
176
177/// `spec explore`'s payload: one session as it stands, or the list.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
179pub struct ExplorePayload {
180    /// The session (none for a list).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub session: Option<ExploreSession>,
183    /// The last run's approaches (the model's).
184    #[serde(default)]
185    pub approaches: Vec<Approach>,
186    /// The constraints to keep or drop, by source.
187    #[serde(default)]
188    pub constraints: Vec<Item>,
189    /// The open questions, by source.
190    #[serde(default)]
191    pub open_questions: Vec<Item>,
192    /// The person's last choice.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub choice: Option<Choice>,
195    /// The brief it made, verbatim.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub brief: Option<String>,
198    /// The project's sessions, newest first (`--list`).
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub sessions: Option<Vec<SessionEntry>>,
201}
202
203impl ExploreSession {
204    /// What the person chooses from now: the last report's approaches and
205    /// its constraints and open questions (with the questions nobody
206    /// could answer), and what the person added at their last choice —
207    /// a resume keeps their words.
208    #[must_use]
209    pub fn on_the_table(&self) -> (Vec<Approach>, Vec<Item>, Vec<Item>) {
210        let Some(r) = self.reports.last() else {
211            return (Vec::new(), Vec::new(), Vec::new());
212        };
213        let item = |text: &String, source: &str| Item {
214            text: text.clone(),
215            source: source.to_owned(),
216        };
217        let mut questions: Vec<Item> = r.open_questions.iter().map(|q| item(q, "model")).collect();
218        for q in self.qa.iter().filter(|q| q.answer.is_none()) {
219            if !questions.iter().any(|i| i.text == q.question) {
220                questions.push(item(&q.question, "nobody"));
221            }
222        }
223        let mut constraints: Vec<Item> = r.constraints.iter().map(|c| item(c, "model")).collect();
224        if let Some(c) = self.choices.last() {
225            for (kept, mine) in [
226                (&mut constraints, &c.constraints),
227                (&mut questions, &c.open_questions),
228            ] {
229                for i in mine.iter().filter(|i| i.source == "person") {
230                    if !kept.iter().any(|k| k.text == i.text) {
231                        kept.push(i.clone());
232                    }
233                }
234            }
235        }
236        (r.approaches.clone(), constraints, questions)
237    }
238
239    /// Its line in a list.
240    #[must_use]
241    pub fn entry(&self) -> SessionEntry {
242        SessionEntry {
243            id: self.id.clone(),
244            idea: self.idea.clone(),
245            skill: self.skill.clone(),
246            state: self.state.clone(),
247            updated_at: self.updated_at,
248            digest: self.choices.last().map(|c| c.digest.clone()),
249            unit: self.started.as_ref().map(|s| s.unit.clone()),
250        }
251    }
252}
253
254/// A composed brief.
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct Brief {
257    /// The whole text, its header line first.
258    pub text: String,
259    /// Hex SHA-256 of the body (everything after the header line).
260    pub digest: String,
261    /// The text's size.
262    pub bytes: usize,
263}
264
265/// Whether the fallback's report stands: at least three approaches with
266/// distinct titles, each with a trade-off and a risk. A format's own skill
267/// is not held to this (its shape is its own).
268///
269/// # Errors
270///
271/// What is missing, in words the model is told.
272pub fn validate_fallback(r: &ExploreReport) -> Result<(), String> {
273    if r.approaches.len() < 3 {
274        return Err(format!(
275            "give at least three approaches (you gave {})",
276            r.approaches.len()
277        ));
278    }
279    let mut seen = std::collections::BTreeSet::new();
280    for a in &r.approaches {
281        let title = a.title.trim().to_lowercase();
282        if title.is_empty() {
283            return Err("every approach needs a title".to_owned());
284        }
285        if !seen.insert(title) {
286            return Err(format!(
287                "two approaches are both called \"{}\"",
288                a.title.trim()
289            ));
290        }
291        if a.tradeoffs.iter().all(|t| t.trim().is_empty()) {
292            return Err(format!(
293                "\"{}\" needs at least one trade-off",
294                a.title.trim()
295            ));
296        }
297        if a.risks.iter().all(|t| t.trim().is_empty()) {
298            return Err(format!("\"{}\" needs at least one risk", a.title.trim()));
299        }
300    }
301    Ok(())
302}
303
304fn bullets(out: &mut String, items: &[String]) {
305    if items.is_empty() {
306        out.push_str("- none\n");
307    }
308    for i in items {
309        out.push_str(&format!("- {}\n", i.trim()));
310    }
311}
312
313/// The brief the person's choice makes, deterministically: the approach
314/// verbatim, then the kept constraints and open questions. `hash` is the
315/// hex SHA-256 the runtime supplies; the digest covers the body only, so
316/// the header can name it.
317///
318/// # Errors
319///
320/// The brief is larger than [`BRIEF_MAX`].
321pub fn compose(
322    approach: &Approach,
323    constraints: &[Item],
324    questions: &[Item],
325    session: &str,
326    hash: impl Fn(&[u8]) -> String,
327) -> Result<Brief, String> {
328    let mut body = String::new();
329    body.push_str("\n## Approach\n\n");
330    body.push_str(approach.title.trim());
331    body.push('\n');
332    body.push_str(approach.summary.trim());
333    body.push('\n');
334    if !approach.tradeoffs.is_empty() {
335        body.push_str(&format!("Trade-offs: {}\n", approach.tradeoffs.join("; ")));
336    }
337    if !approach.risks.is_empty() {
338        body.push_str(&format!("Risks: {}\n", approach.risks.join("; ")));
339    }
340    body.push_str("\n## Constraints\n\n");
341    bullets(
342        &mut body,
343        &constraints
344            .iter()
345            .map(|i| i.text.clone())
346            .collect::<Vec<_>>(),
347    );
348    body.push_str("\n## Open questions\n\n");
349    bullets(
350        &mut body,
351        &questions.iter().map(|i| i.text.clone()).collect::<Vec<_>>(),
352    );
353    let digest = hash(body.as_bytes());
354    let text = format!(
355        "Explore brief {} (session {session})\n{body}",
356        digest.get(..12).unwrap_or(&digest)
357    );
358    if text.len() > BRIEF_MAX {
359        return Err(format!(
360            "the brief is {} KiB; a brief is at most 16 KiB — shorten its constraints",
361            text.len().div_ceil(1024)
362        ));
363    }
364    let bytes = text.len();
365    Ok(Brief {
366        text,
367        digest,
368        bytes,
369    })
370}
371
372/// The digest of a stored brief's body (after its header line).
373#[must_use]
374pub fn body_digest(text: &str, hash: impl Fn(&[u8]) -> String) -> String {
375    let body = text.split_once('\n').map_or("", |(_, b)| b);
376    hash(format!("\n{}", body.strip_prefix('\n').unwrap_or(body)).as_bytes())
377}
378
379/// What a session event does to its state.
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub enum Event {
382    /// The person chose an approach.
383    Choose,
384    /// A new run of the session.
385    Resume,
386    /// The spec was started from its brief.
387    Start,
388}
389
390/// The state after `event` in `state`.
391///
392/// # Errors
393///
394/// The event does not apply: why, in words.
395pub fn next_state(state: &str, event: Event) -> Result<&'static str, &'static str> {
396    match (state, event) {
397        ("open" | "chosen", Event::Choose) => Ok("chosen"),
398        ("open" | "chosen", Event::Resume) => Ok("open"),
399        ("chosen", Event::Start) => Ok("started"),
400        ("open", Event::Start) => Err("choose an approach first"),
401        ("started", _) => Err("this session already started a spec; explore again"),
402        _ => Err("not a state of a session"),
403    }
404}
405
406/// What a resumed run carries of the session: earlier questions with the
407/// person's answers, earlier approaches (the model's), the person's notes
408/// — verbatim, newest first, at most [`CARRIED_MAX`] bytes. Never a summary.
409#[must_use]
410pub fn carried(s: &ExploreSession) -> String {
411    let mut parts: Vec<String> = Vec::new();
412    if let Some(c) = s.choices.last() {
413        let notes: Vec<&str> = c
414            .constraints
415            .iter()
416            .chain(&c.open_questions)
417            .filter(|i| i.source == "person")
418            .map(|i| i.text.as_str())
419            .collect();
420        if !notes.is_empty() {
421            parts.push(format!(
422                "<context kind=\"notes\" source=\"person\">\n{}\n</context>",
423                notes.join("\n")
424            ));
425        }
426    }
427    for q in s.qa.iter().rev() {
428        let answer = q.answer.as_deref().map_or_else(
429            || "(nobody could answer)".to_owned(),
430            |a| {
431                format!(
432                    "{a}\n(answered by {})",
433                    q.by.as_deref().unwrap_or("the person")
434                )
435            },
436        );
437        parts.push(format!(
438            "<context kind=\"question\" source=\"person\">\nQ: {}\nA: {answer}\n</context>",
439            q.question
440        ));
441    }
442    for r in s.reports.iter().rev() {
443        let list: Vec<String> = r
444            .approaches
445            .iter()
446            .enumerate()
447            .map(|(i, a)| format!("{}. {} — {}", i + 1, a.title, a.summary))
448            .collect();
449        parts.push(format!(
450            "<context kind=\"approaches\" source=\"model output\">\n{}\n</context>",
451            list.join("\n")
452        ));
453    }
454    let mut out = String::new();
455    for p in parts {
456        if out.len() + p.len() + 2 > CARRIED_MAX {
457            break;
458        }
459        out.push_str(&p);
460        out.push_str("\n\n");
461    }
462    out
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use proptest::prelude::*;
469
470    fn h(b: &[u8]) -> String {
471        // A stand-in hash for the pure tests: the bytes' length and sum.
472        format!(
473            "{:032x}{:032x}",
474            b.len(),
475            b.iter().map(|x| u64::from(*x)).sum::<u64>()
476        )
477    }
478
479    fn approach(t: &str) -> Approach {
480        Approach {
481            title: t.to_owned(),
482            summary: format!("{t} summary"),
483            tradeoffs: vec!["slower".to_owned()],
484            risks: vec!["drift".to_owned()],
485        }
486    }
487
488    fn report(n: usize) -> ExploreReport {
489        ExploreReport {
490            summary: "s".to_owned(),
491            status: "done".to_owned(),
492            approaches: (0..n).map(|i| approach(&format!("A{i}"))).collect(),
493            constraints: vec![],
494            open_questions: vec![],
495        }
496    }
497
498    #[test]
499    fn the_fallback_needs_three_distinct_approaches_each_with_a_cost() {
500        assert!(validate_fallback(&report(3)).is_ok());
501        assert!(validate_fallback(&report(2)).unwrap_err().contains("three"));
502        let mut dup = report(3);
503        dup.approaches[2].title = "a0".to_owned();
504        assert!(validate_fallback(&dup).unwrap_err().contains("both called"));
505        let mut free = report(3);
506        free.approaches[1].risks.clear();
507        assert!(validate_fallback(&free).unwrap_err().contains("risk"));
508    }
509
510    #[test]
511    fn a_brief_names_its_digest_and_the_digest_covers_its_body() {
512        let c = [Item {
513            text: "no new dependency".to_owned(),
514            source: "person".to_owned(),
515        }];
516        let b = compose(&approach("CSV"), &c, &[], "ex-1", h).unwrap();
517        assert!(b.text.starts_with(&format!(
518            "Explore brief {} (session ex-1)\n",
519            &b.digest[..12]
520        )));
521        assert!(b.text.contains("## Approach\n\nCSV\nCSV summary\n"));
522        assert!(b.text.contains("- no new dependency\n"));
523        assert_eq!(body_digest(&b.text, h), b.digest);
524        let changed = b.text.replace("CSV summary", "XLSX summary");
525        assert_ne!(body_digest(&changed, h), b.digest);
526    }
527
528    #[test]
529    fn states_follow_the_session() {
530        assert_eq!(next_state("open", Event::Choose), Ok("chosen"));
531        assert_eq!(next_state("chosen", Event::Resume), Ok("open"));
532        assert_eq!(next_state("chosen", Event::Start), Ok("started"));
533        assert!(next_state("open", Event::Start).is_err());
534        assert!(next_state("started", Event::Resume).is_err());
535    }
536
537    proptest! {
538        #[test]
539        fn compose_is_deterministic_and_bounded(title in "[a-zA-Z ]{1,40}", n in 0usize..400) {
540            let items: Vec<Item> = (0..n).map(|i| Item { text: format!("constraint {i}"), source: "model".to_owned() }).collect();
541            let a = compose(&approach(&title), &items, &[], "ex-1", h);
542            let b = compose(&approach(&title), &items, &[], "ex-1", h);
543            prop_assert_eq!(&a, &b);
544            if let Ok(brief) = a {
545                prop_assert!(brief.bytes <= BRIEF_MAX);
546                prop_assert_eq!(body_digest(&brief.text, h), brief.digest);
547            }
548        }
549
550        #[test]
551        fn carried_keeps_every_answer_labelled_within_its_cap(n in 0usize..40) {
552            let s = ExploreSession {
553                version: 1, id: "ex-1".to_owned(), idea: "i".to_owned(), skill: FALLBACK.to_owned(),
554                selected_because: "fallback".to_owned(), state: "open".to_owned(), created_at: 0, updated_at: 0,
555                runs: vec![], reports: vec![report(3)],
556                qa: (0..n).map(|i| QaItem { question: format!("q{i}?"), answer: Some(format!("a{i}")), by: Some("Ada".to_owned()) }).collect(),
557                choices: vec![], started: None,
558            };
559            let c = carried(&s);
560            prop_assert!(c.len() <= CARRIED_MAX);
561            for i in 0..n {
562                let pair = format!("Q: q{i}?\nA: a{i}\n(answered by Ada)");
563                prop_assert!(c.contains(&pair), "missing {}", pair);
564            }
565            prop_assert!(c.contains("source=\"model output\""));
566        }
567    }
568}