use serde::{Deserialize, Serialize};
use crate::actions::Via;
pub const BRIEF_MAX: usize = 16 * 1024;
pub const CARRIED_MAX: usize = 24_000;
pub const FALLBACK: &str = "speccoder.explore";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Approach {
pub title: String,
pub summary: String,
#[serde(default)]
pub tradeoffs: Vec<String>,
#[serde(default)]
pub risks: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ExploreReport {
pub summary: String,
pub status: String,
#[serde(default)]
pub approaches: Vec<Approach>,
#[serde(default)]
pub constraints: Vec<String>,
#[serde(default)]
pub open_questions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Item {
pub text: String,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct QaItem {
pub question: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub answer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Choice {
pub approach: usize,
pub constraints: Vec<Item>,
pub open_questions: Vec<Item>,
pub by: String,
pub via: Via,
pub basis: String,
pub at: u64,
pub digest: String,
pub bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct RunRef {
pub run_id: String,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub words: Option<String>,
pub status: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Started {
pub unit: String,
pub at: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ExploreSession {
pub version: u32,
pub id: String,
pub idea: String,
pub skill: String,
pub selected_because: String,
pub state: String,
pub created_at: u64,
pub updated_at: u64,
#[serde(default)]
pub runs: Vec<RunRef>,
#[serde(default)]
pub reports: Vec<ExploreReport>,
#[serde(default)]
pub qa: Vec<QaItem>,
#[serde(default)]
pub choices: Vec<Choice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started: Option<Started>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SessionEntry {
pub id: String,
pub idea: String,
pub skill: String,
pub state: String,
pub updated_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ExplorePayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<ExploreSession>,
#[serde(default)]
pub approaches: Vec<Approach>,
#[serde(default)]
pub constraints: Vec<Item>,
#[serde(default)]
pub open_questions: Vec<Item>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub choice: Option<Choice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub brief: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sessions: Option<Vec<SessionEntry>>,
}
impl ExploreSession {
#[must_use]
pub fn on_the_table(&self) -> (Vec<Approach>, Vec<Item>, Vec<Item>) {
let Some(r) = self.reports.last() else {
return (Vec::new(), Vec::new(), Vec::new());
};
let item = |text: &String, source: &str| Item {
text: text.clone(),
source: source.to_owned(),
};
let mut questions: Vec<Item> = r.open_questions.iter().map(|q| item(q, "model")).collect();
for q in self.qa.iter().filter(|q| q.answer.is_none()) {
if !questions.iter().any(|i| i.text == q.question) {
questions.push(item(&q.question, "nobody"));
}
}
let mut constraints: Vec<Item> = r.constraints.iter().map(|c| item(c, "model")).collect();
if let Some(c) = self.choices.last() {
for (kept, mine) in [
(&mut constraints, &c.constraints),
(&mut questions, &c.open_questions),
] {
for i in mine.iter().filter(|i| i.source == "person") {
if !kept.iter().any(|k| k.text == i.text) {
kept.push(i.clone());
}
}
}
}
(r.approaches.clone(), constraints, questions)
}
#[must_use]
pub fn entry(&self) -> SessionEntry {
SessionEntry {
id: self.id.clone(),
idea: self.idea.clone(),
skill: self.skill.clone(),
state: self.state.clone(),
updated_at: self.updated_at,
digest: self.choices.last().map(|c| c.digest.clone()),
unit: self.started.as_ref().map(|s| s.unit.clone()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Brief {
pub text: String,
pub digest: String,
pub bytes: usize,
}
pub fn validate_fallback(r: &ExploreReport) -> Result<(), String> {
if r.approaches.len() < 3 {
return Err(format!(
"give at least three approaches (you gave {})",
r.approaches.len()
));
}
let mut seen = std::collections::BTreeSet::new();
for a in &r.approaches {
let title = a.title.trim().to_lowercase();
if title.is_empty() {
return Err("every approach needs a title".to_owned());
}
if !seen.insert(title) {
return Err(format!(
"two approaches are both called \"{}\"",
a.title.trim()
));
}
if a.tradeoffs.iter().all(|t| t.trim().is_empty()) {
return Err(format!(
"\"{}\" needs at least one trade-off",
a.title.trim()
));
}
if a.risks.iter().all(|t| t.trim().is_empty()) {
return Err(format!("\"{}\" needs at least one risk", a.title.trim()));
}
}
Ok(())
}
fn bullets(out: &mut String, items: &[String]) {
if items.is_empty() {
out.push_str("- none\n");
}
for i in items {
out.push_str(&format!("- {}\n", i.trim()));
}
}
pub fn compose(
approach: &Approach,
constraints: &[Item],
questions: &[Item],
session: &str,
hash: impl Fn(&[u8]) -> String,
) -> Result<Brief, String> {
let mut body = String::new();
body.push_str("\n## Approach\n\n");
body.push_str(approach.title.trim());
body.push('\n');
body.push_str(approach.summary.trim());
body.push('\n');
if !approach.tradeoffs.is_empty() {
body.push_str(&format!("Trade-offs: {}\n", approach.tradeoffs.join("; ")));
}
if !approach.risks.is_empty() {
body.push_str(&format!("Risks: {}\n", approach.risks.join("; ")));
}
body.push_str("\n## Constraints\n\n");
bullets(
&mut body,
&constraints
.iter()
.map(|i| i.text.clone())
.collect::<Vec<_>>(),
);
body.push_str("\n## Open questions\n\n");
bullets(
&mut body,
&questions.iter().map(|i| i.text.clone()).collect::<Vec<_>>(),
);
let digest = hash(body.as_bytes());
let text = format!(
"Explore brief {} (session {session})\n{body}",
digest.get(..12).unwrap_or(&digest)
);
if text.len() > BRIEF_MAX {
return Err(format!(
"the brief is {} KiB; a brief is at most 16 KiB — shorten its constraints",
text.len().div_ceil(1024)
));
}
let bytes = text.len();
Ok(Brief {
text,
digest,
bytes,
})
}
#[must_use]
pub fn body_digest(text: &str, hash: impl Fn(&[u8]) -> String) -> String {
let body = text.split_once('\n').map_or("", |(_, b)| b);
hash(format!("\n{}", body.strip_prefix('\n').unwrap_or(body)).as_bytes())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event {
Choose,
Resume,
Start,
}
pub fn next_state(state: &str, event: Event) -> Result<&'static str, &'static str> {
match (state, event) {
("open" | "chosen", Event::Choose) => Ok("chosen"),
("open" | "chosen", Event::Resume) => Ok("open"),
("chosen", Event::Start) => Ok("started"),
("open", Event::Start) => Err("choose an approach first"),
("started", _) => Err("this session already started a spec; explore again"),
_ => Err("not a state of a session"),
}
}
#[must_use]
pub fn carried(s: &ExploreSession) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(c) = s.choices.last() {
let notes: Vec<&str> = c
.constraints
.iter()
.chain(&c.open_questions)
.filter(|i| i.source == "person")
.map(|i| i.text.as_str())
.collect();
if !notes.is_empty() {
parts.push(format!(
"<context kind=\"notes\" source=\"person\">\n{}\n</context>",
notes.join("\n")
));
}
}
for q in s.qa.iter().rev() {
let answer = q.answer.as_deref().map_or_else(
|| "(nobody could answer)".to_owned(),
|a| {
format!(
"{a}\n(answered by {})",
q.by.as_deref().unwrap_or("the person")
)
},
);
parts.push(format!(
"<context kind=\"question\" source=\"person\">\nQ: {}\nA: {answer}\n</context>",
q.question
));
}
for r in s.reports.iter().rev() {
let list: Vec<String> = r
.approaches
.iter()
.enumerate()
.map(|(i, a)| format!("{}. {} — {}", i + 1, a.title, a.summary))
.collect();
parts.push(format!(
"<context kind=\"approaches\" source=\"model output\">\n{}\n</context>",
list.join("\n")
));
}
let mut out = String::new();
for p in parts {
if out.len() + p.len() + 2 > CARRIED_MAX {
break;
}
out.push_str(&p);
out.push_str("\n\n");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn h(b: &[u8]) -> String {
format!(
"{:032x}{:032x}",
b.len(),
b.iter().map(|x| u64::from(*x)).sum::<u64>()
)
}
fn approach(t: &str) -> Approach {
Approach {
title: t.to_owned(),
summary: format!("{t} summary"),
tradeoffs: vec!["slower".to_owned()],
risks: vec!["drift".to_owned()],
}
}
fn report(n: usize) -> ExploreReport {
ExploreReport {
summary: "s".to_owned(),
status: "done".to_owned(),
approaches: (0..n).map(|i| approach(&format!("A{i}"))).collect(),
constraints: vec![],
open_questions: vec![],
}
}
#[test]
fn the_fallback_needs_three_distinct_approaches_each_with_a_cost() {
assert!(validate_fallback(&report(3)).is_ok());
assert!(validate_fallback(&report(2)).unwrap_err().contains("three"));
let mut dup = report(3);
dup.approaches[2].title = "a0".to_owned();
assert!(validate_fallback(&dup).unwrap_err().contains("both called"));
let mut free = report(3);
free.approaches[1].risks.clear();
assert!(validate_fallback(&free).unwrap_err().contains("risk"));
}
#[test]
fn a_brief_names_its_digest_and_the_digest_covers_its_body() {
let c = [Item {
text: "no new dependency".to_owned(),
source: "person".to_owned(),
}];
let b = compose(&approach("CSV"), &c, &[], "ex-1", h).unwrap();
assert!(b.text.starts_with(&format!(
"Explore brief {} (session ex-1)\n",
&b.digest[..12]
)));
assert!(b.text.contains("## Approach\n\nCSV\nCSV summary\n"));
assert!(b.text.contains("- no new dependency\n"));
assert_eq!(body_digest(&b.text, h), b.digest);
let changed = b.text.replace("CSV summary", "XLSX summary");
assert_ne!(body_digest(&changed, h), b.digest);
}
#[test]
fn states_follow_the_session() {
assert_eq!(next_state("open", Event::Choose), Ok("chosen"));
assert_eq!(next_state("chosen", Event::Resume), Ok("open"));
assert_eq!(next_state("chosen", Event::Start), Ok("started"));
assert!(next_state("open", Event::Start).is_err());
assert!(next_state("started", Event::Resume).is_err());
}
proptest! {
#[test]
fn compose_is_deterministic_and_bounded(title in "[a-zA-Z ]{1,40}", n in 0usize..400) {
let items: Vec<Item> = (0..n).map(|i| Item { text: format!("constraint {i}"), source: "model".to_owned() }).collect();
let a = compose(&approach(&title), &items, &[], "ex-1", h);
let b = compose(&approach(&title), &items, &[], "ex-1", h);
prop_assert_eq!(&a, &b);
if let Ok(brief) = a {
prop_assert!(brief.bytes <= BRIEF_MAX);
prop_assert_eq!(body_digest(&brief.text, h), brief.digest);
}
}
#[test]
fn carried_keeps_every_answer_labelled_within_its_cap(n in 0usize..40) {
let s = ExploreSession {
version: 1, id: "ex-1".to_owned(), idea: "i".to_owned(), skill: FALLBACK.to_owned(),
selected_because: "fallback".to_owned(), state: "open".to_owned(), created_at: 0, updated_at: 0,
runs: vec![], reports: vec![report(3)],
qa: (0..n).map(|i| QaItem { question: format!("q{i}?"), answer: Some(format!("a{i}")), by: Some("Ada".to_owned()) }).collect(),
choices: vec![], started: None,
};
let c = carried(&s);
prop_assert!(c.len() <= CARRIED_MAX);
for i in 0..n {
let pair = format!("Q: q{i}?\nA: a{i}\n(answered by Ada)");
prop_assert!(c.contains(&pair), "missing {}", pair);
}
prop_assert!(c.contains("source=\"model output\""));
}
}
}