1use serde::{Deserialize, Serialize};
7
8use crate::actions::Via;
9
10pub const BRIEF_MAX: usize = 16 * 1024;
12
13pub const CARRIED_MAX: usize = 24_000;
15
16pub const FALLBACK: &str = "speccoder.explore";
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
21pub struct Approach {
22 pub title: String,
24 pub summary: String,
26 #[serde(default)]
28 pub tradeoffs: Vec<String>,
29 #[serde(default)]
31 pub risks: Vec<String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
36pub struct ExploreReport {
37 pub summary: String,
39 pub status: String,
41 #[serde(default)]
43 pub approaches: Vec<Approach>,
44 #[serde(default)]
46 pub constraints: Vec<String>,
47 #[serde(default)]
49 pub open_questions: Vec<String>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
54pub struct Item {
55 pub text: String,
57 pub source: String,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
63pub struct QaItem {
64 pub question: String,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub answer: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub by: Option<String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
76pub struct Choice {
77 pub approach: usize,
79 pub constraints: Vec<Item>,
81 pub open_questions: Vec<Item>,
83 pub by: String,
85 pub via: Via,
87 pub basis: String,
89 pub at: u64,
91 pub digest: String,
93 pub bytes: usize,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
99pub struct RunRef {
100 pub run_id: String,
102 pub kind: String,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub words: Option<String>,
107 pub status: String,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
113pub struct Started {
114 pub unit: String,
116 pub at: u64,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
122pub struct ExploreSession {
123 pub version: u32,
125 pub id: String,
127 pub idea: String,
129 pub skill: String,
131 pub selected_because: String,
133 pub state: String,
135 pub created_at: u64,
137 pub updated_at: u64,
139 #[serde(default)]
141 pub runs: Vec<RunRef>,
142 #[serde(default)]
144 pub reports: Vec<ExploreReport>,
145 #[serde(default)]
147 pub qa: Vec<QaItem>,
148 #[serde(default)]
150 pub choices: Vec<Choice>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub started: Option<Started>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
158pub struct SessionEntry {
159 pub id: String,
161 pub idea: String,
163 pub skill: String,
165 pub state: String,
167 pub updated_at: u64,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub digest: Option<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub unit: Option<String>,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
179pub struct ExplorePayload {
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub session: Option<ExploreSession>,
183 #[serde(default)]
185 pub approaches: Vec<Approach>,
186 #[serde(default)]
188 pub constraints: Vec<Item>,
189 #[serde(default)]
191 pub open_questions: Vec<Item>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub choice: Option<Choice>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub brief: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub sessions: Option<Vec<SessionEntry>>,
201}
202
203impl ExploreSession {
204 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct Brief {
257 pub text: String,
259 pub digest: String,
261 pub bytes: usize,
263}
264
265pub 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
313pub 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub enum Event {
382 Choose,
384 Resume,
386 Start,
388}
389
390pub 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#[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 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}