1use std::collections::BTreeMap;
29
30use serde::Serialize;
31use serde_json::{Value, json};
32
33use crate::engine::Engine;
34use crate::index::{AsgIndexStore, IndexStore};
35use crate::ledger::{AsgLedgerStore, LedgerStore};
36use crate::schema::{LedgerEntry, LedgerKind};
37
38pub const DEFAULT_CONFIDENCE_FLOOR: f64 = 0.3;
42
43#[derive(Debug, Clone, Serialize)]
47pub struct PriorThinking {
48 pub entries: Value,
53 pub summary: ThinkingSummary,
56}
57
58#[derive(Debug, Clone, Serialize)]
68pub struct ThinkingSummary {
69 #[serde(skip_serializing_if = "crate::ser_helpers::is_zero_usize")]
72 pub scanned_qnames: usize,
73 #[serde(skip_serializing_if = "crate::ser_helpers::is_zero_usize")]
76 pub matched_for_query: usize,
77 #[serde(skip_serializing_if = "crate::ser_helpers::is_zero_usize")]
81 pub surfaced: usize,
82 #[serde(skip_serializing_if = "crate::ser_helpers::is_all_zero_string_usize_map")]
87 pub by_kind: BTreeMap<String, usize>,
88 #[serde(skip_serializing_if = "crate::ser_helpers::is_all_zero_string_usize_map")]
95 pub by_kind_dropped: BTreeMap<String, usize>,
96 #[serde(skip_serializing_if = "Option::is_none")]
102 pub entries_in_workspace: Option<usize>,
103}
104
105impl ThinkingSummary {
106 pub fn is_quiet(&self) -> bool {
110 self.scanned_qnames == 0
111 && self.matched_for_query == 0
112 && self.surfaced == 0
113 && self.by_kind.values().all(|v| *v == 0)
114 && self.by_kind_dropped.values().all(|v| *v == 0)
115 && self.entries_in_workspace.is_none()
116 }
117}
118
119impl ThinkingSummary {
120 fn empty(scanned_qnames: usize) -> Self {
121 Self {
122 scanned_qnames,
123 matched_for_query: 0,
124 surfaced: 0,
125 by_kind: kind_counter_zero(),
126 by_kind_dropped: kind_counter_zero(),
127 entries_in_workspace: None,
128 }
129 }
130}
131
132fn kind_counter_zero() -> BTreeMap<String, usize> {
133 let mut m = BTreeMap::new();
134 m.insert("hypothesis".into(), 0);
135 m.insert("mental_model".into(), 0);
136 m.insert("open_question".into(), 0);
137 m.insert("failed_attempt".into(), 0);
138 m
139}
140
141struct GatherAcc {
151 hypotheses: Vec<Value>,
152 mental_models: Vec<Value>,
153 open_questions: Vec<Value>,
154 failed_attempts: Vec<Value>,
155 by_kind: BTreeMap<String, usize>,
156 by_kind_dropped: BTreeMap<String, usize>,
157 matched_for_query: usize,
158}
159
160impl GatherAcc {
161 fn new() -> Self {
162 Self {
163 hypotheses: Vec::new(),
164 mental_models: Vec::new(),
165 open_questions: Vec::new(),
166 failed_attempts: Vec::new(),
167 by_kind: kind_counter_zero(),
168 by_kind_dropped: kind_counter_zero(),
169 matched_for_query: 0,
170 }
171 }
172
173 fn accumulate(&mut self, qn: &str, entries: &[LedgerEntry], min_confidence: f64) {
175 let mut had_thinking_entry = false;
176 for entry in entries {
177 match entry.kind {
178 LedgerKind::Hypothesis => {
179 had_thinking_entry = true;
180 let conf = entry.confidence.unwrap_or(0.0);
181 if conf < min_confidence {
182 *self.by_kind_dropped.get_mut("hypothesis").unwrap() += 1;
183 continue;
184 }
185 self.hypotheses.push(json!({
186 "qname": qn,
187 "summary": entry.summary,
188 "confidence": conf,
189 }));
190 *self.by_kind.get_mut("hypothesis").unwrap() += 1;
191 }
192 LedgerKind::MentalModel => {
193 had_thinking_entry = true;
194 let body_v: Option<Value> = entry
195 .body
196 .as_deref()
197 .and_then(|b| serde_json::from_str(b).ok());
198 let symbols: Vec<String> = body_v
199 .as_ref()
200 .and_then(|v| v.get("symbols"))
201 .and_then(Value::as_array)
202 .map(|a| {
203 a.iter()
204 .filter_map(|s| s.as_str().map(String::from))
205 .collect()
206 })
207 .unwrap_or_default();
208 let name = body_v
209 .as_ref()
210 .and_then(|v| v.get("name"))
211 .and_then(Value::as_str)
212 .map(String::from);
213 self.mental_models.push(json!({
214 "qname": qn,
215 "name": name,
216 "summary": entry.summary,
217 "symbols": symbols,
218 }));
219 *self.by_kind.get_mut("mental_model").unwrap() += 1;
220 }
221 LedgerKind::OpenQuestion => {
222 had_thinking_entry = true;
223 self.open_questions.push(json!({
224 "qname": qn,
225 "question": entry.summary,
226 }));
227 *self.by_kind.get_mut("open_question").unwrap() += 1;
228 }
229 LedgerKind::FailedAttempt => {
230 had_thinking_entry = true;
231 let body_v: Option<Value> = entry
232 .body
233 .as_deref()
234 .and_then(|b| serde_json::from_str(b).ok());
235 let tried = body_v
236 .as_ref()
237 .and_then(|v| v.get("tried"))
238 .and_then(Value::as_str)
239 .map(String::from);
240 let because = body_v
241 .as_ref()
242 .and_then(|v| v.get("because"))
243 .and_then(Value::as_str)
244 .map(String::from);
245 self.failed_attempts.push(json!({
246 "qname": qn,
247 "summary": entry.summary,
248 "tried": tried,
249 "because": because,
250 }));
251 *self.by_kind.get_mut("failed_attempt").unwrap() += 1;
252 }
253 _ => {}
254 }
255 }
256 if had_thinking_entry {
257 self.matched_for_query += 1;
258 }
259 }
260
261 fn finish(self, engine: &Engine, scanned_qnames: usize) -> PriorThinking {
262 let surfaced = self.by_kind.values().sum::<usize>();
263
264 let entries_in_workspace = if self.matched_for_query == 0 {
268 Some(count_workspace_thinking(engine))
269 } else {
270 None
271 };
272
273 let summary = ThinkingSummary {
274 scanned_qnames,
275 matched_for_query: self.matched_for_query,
276 surfaced,
277 by_kind: self.by_kind,
278 by_kind_dropped: self.by_kind_dropped,
279 entries_in_workspace,
280 };
281
282 let entries = if surfaced == 0 {
283 Value::Null
284 } else {
285 let mut out = serde_json::Map::new();
286 if !self.hypotheses.is_empty() {
287 out.insert("hypotheses".into(), json!(self.hypotheses));
288 }
289 if !self.mental_models.is_empty() {
290 out.insert("mental_models".into(), json!(self.mental_models));
291 }
292 if !self.open_questions.is_empty() {
293 out.insert("open_questions".into(), json!(self.open_questions));
294 }
295 if !self.failed_attempts.is_empty() {
296 out.insert("failed_attempts".into(), json!(self.failed_attempts));
297 }
298 Value::Object(out)
299 };
300
301 PriorThinking { entries, summary }
302 }
303}
304
305pub fn gather_prior_thinking(
306 engine: &Engine,
307 qnames: &[String],
308 min_confidence: f64,
309) -> PriorThinking {
310 let index = AsgIndexStore::from_engine(engine);
311 let ledger = AsgLedgerStore::from_engine(engine);
312 let ref_name = engine.ref_name.clone();
313
314 let mut acc = GatherAcc::new();
315 for qn in qnames {
316 let sym = match index.get_symbol_by_qname(&ref_name, qn) {
317 Ok(Some(s)) => s,
318 _ => continue,
319 };
320 let entries = ledger
321 .list_entries(&ref_name, &sym.symbol_id)
322 .unwrap_or_default();
323 acc.accumulate(qn, &entries, min_confidence);
324 }
325 acc.finish(engine, qnames.len())
326}
327
328pub fn gather_prior_thinking_all(engine: &Engine, min_confidence: f64) -> PriorThinking {
335 let index = AsgIndexStore::from_engine(engine);
336 let id_map = index.build_id_map(engine);
337
338 let mut acc = GatherAcc::new();
339 for (symbol_id, entries) in all_ledger_buckets(engine) {
340 let Some(sym) = id_map.get(&symbol_id) else {
343 continue;
344 };
345 let live = live_entries(entries);
346 acc.accumulate(&sym.qname, &live, min_confidence);
347 }
348 acc.finish(engine, id_map.len())
349}
350
351pub fn all_ledger_buckets(engine: &Engine) -> Vec<(String, Vec<LedgerEntry>)> {
359 let prefix = format!("{}/ledger", crate::paths::ASD_ROOT);
360 let tree = match engine.repo.get_tree(&engine.ref_name, &prefix) {
361 Ok(t) => t,
362 Err(_) => return Vec::new(),
363 };
364 let mut buckets = Vec::new();
365 if let Value::Object(by_symbol) = tree {
366 for (symbol_id, symbol_bucket) in by_symbol {
367 if let Value::Object(entry_map) = symbol_bucket {
368 let entries: Vec<LedgerEntry> = entry_map
369 .into_iter()
370 .filter_map(|(_, v)| serde_json::from_value(v).ok())
371 .collect();
372 if !entries.is_empty() {
373 buckets.push((symbol_id, entries));
374 }
375 }
376 }
377 }
378 buckets
379}
380
381pub fn live_entries(all: Vec<LedgerEntry>) -> Vec<LedgerEntry> {
385 let superseded: std::collections::HashSet<String> = all
386 .iter()
387 .flat_map(|e| e.supersedes.iter().cloned())
388 .collect();
389 all.into_iter()
390 .filter(|e| !superseded.contains(&e.entry_id))
391 .collect()
392}
393
394pub fn all_symbol_entries(engine: &Engine) -> Vec<(String, Vec<LedgerEntry>)> {
405 let index = AsgIndexStore::from_engine(engine);
406 let id_map = index.build_id_map(engine);
407
408 let mut out: Vec<(String, Vec<LedgerEntry>)> = Vec::new();
409 for (symbol_id, entries) in all_ledger_buckets(engine) {
410 let Some(sym) = id_map.get(&symbol_id) else {
411 continue;
412 };
413 let mut live = live_entries(entries);
414 if live.is_empty() {
415 continue;
416 }
417 live.sort_by(|a, b| b.created_at.cmp(&a.created_at));
419 out.push((sym.qname.clone(), live));
420 }
421 out.sort_by(|a, b| a.0.cmp(&b.0));
422 out
423}
424
425pub fn count_workspace_thinking(engine: &Engine) -> usize {
434 all_ledger_buckets(engine)
435 .into_iter()
436 .flat_map(|(_, entries)| live_entries(entries))
437 .filter(|e| {
438 matches!(
439 e.kind,
440 LedgerKind::Hypothesis
441 | LedgerKind::MentalModel
442 | LedgerKind::OpenQuestion
443 | LedgerKind::FailedAttempt
444 )
445 })
446 .count()
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use crate::schema::{Author, AuthorKind, LedgerEntry, Position, Symbol, SymbolKind};
453
454 fn seed() -> (Engine, String) {
455 let engine = Engine::open_in_memory().unwrap();
456 let index = AsgIndexStore::from_engine(&engine);
457 let sym = Symbol {
458 symbol_id: "sym_x".into(),
459 symbol_fp: "fp_x".into(),
460 qname: "pkg.target".into(),
461 language: "python".into(),
462 kind: SymbolKind::Function,
463 file: "src/target.py".into(),
464 start: Position { line: 1, col: 0 },
465 end: Position { line: 5, col: 0 },
466 signature: None,
467 doc: None,
468 };
469 index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
470 (engine, "pkg.target".into())
471 }
472
473 fn append(
474 engine: &Engine,
475 sym_id: &str,
476 kind: LedgerKind,
477 summary: &str,
478 conf: Option<f64>,
479 body: Option<&str>,
480 ) {
481 let ledger = AsgLedgerStore::from_engine(engine);
482 let mut entry = LedgerEntry::new(
483 sym_id,
484 kind,
485 summary,
486 Author {
487 kind: AuthorKind::Agent,
488 id: "t".into(),
489 },
490 );
491 entry.confidence = conf;
492 entry.body = body.map(str::to_string);
493 ledger.append_entry(&engine.ref_name, &entry, "t").unwrap();
494 }
495
496 #[test]
497 fn bulk_gather_matches_per_qname_gather() {
498 let (engine, qn) = seed();
502 let index = AsgIndexStore::from_engine(&engine);
503 let sym2 = Symbol {
504 symbol_id: "sym_y".into(),
505 symbol_fp: "fp_y".into(),
506 qname: "pkg.other".into(),
507 language: "python".into(),
508 kind: SymbolKind::Function,
509 file: "src/other.py".into(),
510 start: Position { line: 1, col: 0 },
511 end: Position { line: 3, col: 0 },
512 signature: None,
513 doc: None,
514 };
515 index.put_symbol(&engine.ref_name, &sym2, "test").unwrap();
516
517 append(
518 &engine,
519 "sym_x",
520 LedgerKind::Hypothesis,
521 "likely rc",
522 Some(0.8),
523 None,
524 );
525 append(
526 &engine,
527 "sym_x",
528 LedgerKind::Hypothesis,
529 "below floor",
530 Some(0.1),
531 None,
532 );
533 append(
534 &engine,
535 "sym_y",
536 LedgerKind::FailedAttempt,
537 "tried caching",
538 None,
539 Some(r#"{"tried":"lru","because":"stale reads"}"#),
540 );
541 append(
542 &engine,
543 "sym_y",
544 LedgerKind::OpenQuestion,
545 "why flaky?",
546 None,
547 None,
548 );
549 append(
551 &engine,
552 "sym_x",
553 LedgerKind::Decision,
554 "use sqlite",
555 None,
556 None,
557 );
558
559 let per_qname = gather_prior_thinking(
560 &engine,
561 &[qn, "pkg.other".to_string()],
562 DEFAULT_CONFIDENCE_FLOOR,
563 );
564 let bulk = gather_prior_thinking_all(&engine, DEFAULT_CONFIDENCE_FLOOR);
565
566 assert_eq!(bulk.summary.scanned_qnames, 2);
567 assert_eq!(
568 bulk.summary.matched_for_query,
569 per_qname.summary.matched_for_query
570 );
571 assert_eq!(bulk.summary.surfaced, per_qname.summary.surfaced);
572 assert_eq!(bulk.summary.by_kind, per_qname.summary.by_kind);
573 assert_eq!(
574 bulk.summary.by_kind_dropped,
575 per_qname.summary.by_kind_dropped
576 );
577
578 for key in [
581 "hypotheses",
582 "mental_models",
583 "open_questions",
584 "failed_attempts",
585 ] {
586 let mut a: Vec<String> = per_qname.entries[key]
587 .as_array()
588 .map(|v| v.iter().map(|e| e.to_string()).collect())
589 .unwrap_or_default();
590 let mut b: Vec<String> = bulk.entries[key]
591 .as_array()
592 .map(|v| v.iter().map(|e| e.to_string()).collect())
593 .unwrap_or_default();
594 a.sort();
595 b.sort();
596 assert_eq!(a, b, "mismatch in {key}");
597 }
598 }
599
600 #[test]
601 fn all_symbol_entries_matches_per_qname_listing() {
602 let (engine, qn) = seed();
607 let index = AsgIndexStore::from_engine(&engine);
608 let ledger = AsgLedgerStore::from_engine(&engine);
609 let sym2 = Symbol {
610 symbol_id: "sym_y".into(),
611 symbol_fp: "fp_y".into(),
612 qname: "pkg.other".into(),
613 language: "python".into(),
614 kind: SymbolKind::Function,
615 file: "src/other.py".into(),
616 start: Position { line: 1, col: 0 },
617 end: Position { line: 3, col: 0 },
618 signature: None,
619 doc: None,
620 };
621 index.put_symbol(&engine.ref_name, &sym2, "test").unwrap();
622 let sym3 = Symbol {
624 symbol_id: "sym_z".into(),
625 symbol_fp: "fp_z".into(),
626 qname: "pkg.zebra".into(),
627 language: "python".into(),
628 kind: SymbolKind::Function,
629 file: "src/zebra.py".into(),
630 start: Position { line: 1, col: 0 },
631 end: Position { line: 3, col: 0 },
632 signature: None,
633 doc: None,
634 };
635 index.put_symbol(&engine.ref_name, &sym3, "test").unwrap();
636
637 append(
638 &engine,
639 "sym_x",
640 LedgerKind::Hypothesis,
641 "h1",
642 Some(0.8),
643 None,
644 );
645 append(
646 &engine,
647 "sym_x",
648 LedgerKind::Decision,
649 "use sqlite",
650 None,
651 None,
652 );
653 append(
654 &engine,
655 "sym_y",
656 LedgerKind::OpenQuestion,
657 "why flaky?",
658 None,
659 None,
660 );
661
662 let mut per_qname: Vec<(String, Vec<LedgerEntry>)> = Vec::new();
664 for q in [qn.as_str(), "pkg.other", "pkg.zebra"] {
665 let sym = index
666 .get_symbol_by_qname(&engine.ref_name, q)
667 .unwrap()
668 .unwrap();
669 let entries = ledger
670 .list_entries(&engine.ref_name, &sym.symbol_id)
671 .unwrap_or_default();
672 if !entries.is_empty() {
673 per_qname.push((sym.qname, entries));
674 }
675 }
676 per_qname.sort_by(|a, b| a.0.cmp(&b.0));
677
678 let bulk = all_symbol_entries(&engine);
679 assert_eq!(bulk.len(), per_qname.len());
680 for ((bq, be), (pq, pe)) in bulk.iter().zip(per_qname.iter()) {
681 assert_eq!(bq, pq);
682 let b_ids: Vec<&str> = be.iter().map(|e| e.entry_id.as_str()).collect();
683 let p_ids: Vec<&str> = pe.iter().map(|e| e.entry_id.as_str()).collect();
684 assert_eq!(b_ids, p_ids, "entry order mismatch for {bq}");
685 }
686 }
687
688 #[test]
689 fn returns_null_entries_when_no_thinking() {
690 let (engine, qn) = seed();
691 let pt = gather_prior_thinking(&engine, &[qn], DEFAULT_CONFIDENCE_FLOOR);
692 assert_eq!(pt.entries, Value::Null);
693 assert_eq!(pt.summary.scanned_qnames, 1);
694 assert_eq!(pt.summary.matched_for_query, 0);
695 assert_eq!(pt.summary.surfaced, 0);
696 assert_eq!(
698 pt.summary.entries_in_workspace,
699 Some(0),
700 "workspace count must be populated when query matched nothing"
701 );
702 }
703
704 #[test]
705 fn surfaces_high_confidence_hypothesis() {
706 let (engine, qn) = seed();
707 append(
708 &engine,
709 "sym_x",
710 LedgerKind::Hypothesis,
711 "X causes Y",
712 Some(0.7),
713 None,
714 );
715 let pt = gather_prior_thinking(&engine, &[qn], DEFAULT_CONFIDENCE_FLOOR);
716 let o = pt.entries.as_object().unwrap();
717 let hyps = o["hypotheses"].as_array().unwrap();
718 assert_eq!(hyps.len(), 1);
719 assert_eq!(hyps[0]["confidence"].as_f64(), Some(0.7));
720 assert_eq!(pt.summary.by_kind["hypothesis"], 1);
721 assert_eq!(pt.summary.surfaced, 1);
722 assert_eq!(pt.summary.matched_for_query, 1);
723 assert_eq!(pt.summary.entries_in_workspace, None);
725 }
726
727 #[test]
728 fn excludes_below_confidence_floor_and_records_dropped() {
729 let (engine, qn) = seed();
730 append(
731 &engine,
732 "sym_x",
733 LedgerKind::Hypothesis,
734 "weak guess",
735 Some(0.1),
736 None,
737 );
738 let pt = gather_prior_thinking(&engine, &[qn], DEFAULT_CONFIDENCE_FLOOR);
739 assert_eq!(pt.entries, Value::Null);
740 assert_eq!(pt.summary.surfaced, 0);
741 assert_eq!(
743 pt.summary.by_kind_dropped["hypothesis"], 1,
744 "filtered-out entries must increment by_kind_dropped"
745 );
746 assert_eq!(
749 pt.summary.matched_for_query, 1,
750 "qname had a thinking entry (even if dropped) → matched"
751 );
752 }
753
754 #[test]
755 fn surfaces_mental_model_with_symbols_array() {
756 let (engine, qn) = seed();
757 append(
758 &engine,
759 "sym_x",
760 LedgerKind::MentalModel,
761 "audio-pipeline: input → mix → out",
762 None,
763 Some(r#"{"symbols":["a.b","c.d"],"name":"audio-pipeline"}"#),
764 );
765 let pt = gather_prior_thinking(&engine, &[qn], DEFAULT_CONFIDENCE_FLOOR);
766 let mm = pt.entries["mental_models"].as_array().unwrap();
767 assert_eq!(mm.len(), 1);
768 assert_eq!(mm[0]["name"].as_str(), Some("audio-pipeline"));
769 assert_eq!(pt.summary.by_kind["mental_model"], 1);
770 }
771
772 #[test]
773 fn surfaces_failed_attempt_with_tried_because() {
774 let (engine, qn) = seed();
775 append(
776 &engine,
777 "sym_x",
778 LedgerKind::FailedAttempt,
779 "failed: caching — broke under reload",
780 None,
781 Some(r#"{"tried":"caching","because":"broke under reload"}"#),
782 );
783 let pt = gather_prior_thinking(&engine, &[qn], DEFAULT_CONFIDENCE_FLOOR);
784 let fa = pt.entries["failed_attempts"].as_array().unwrap();
785 assert_eq!(fa[0]["tried"].as_str(), Some("caching"));
786 assert_eq!(fa[0]["because"].as_str(), Some("broke under reload"));
787 assert_eq!(pt.summary.by_kind["failed_attempt"], 1);
788 }
789
790 #[test]
791 fn surfaces_open_question() {
792 let (engine, qn) = seed();
793 append(
794 &engine,
795 "sym_x",
796 LedgerKind::OpenQuestion,
797 "what does 4096 mean?",
798 None,
799 None,
800 );
801 let pt = gather_prior_thinking(&engine, &[qn], DEFAULT_CONFIDENCE_FLOOR);
802 let oq = pt.entries["open_questions"].as_array().unwrap();
803 assert_eq!(oq[0]["question"].as_str(), Some("what does 4096 mean?"));
804 assert_eq!(pt.summary.by_kind["open_question"], 1);
805 }
806
807 #[test]
808 fn excludes_non_thinking_kinds() {
809 let (engine, qn) = seed();
810 append(
811 &engine,
812 "sym_x",
813 LedgerKind::Decision,
814 "decided X",
815 None,
816 None,
817 );
818 append(
819 &engine,
820 "sym_x",
821 LedgerKind::Constraint,
822 "must Y",
823 None,
824 None,
825 );
826 append(
827 &engine,
828 "sym_x",
829 LedgerKind::Mapping,
830 "covers Z",
831 None,
832 None,
833 );
834 let pt = gather_prior_thinking(&engine, &[qn], DEFAULT_CONFIDENCE_FLOOR);
835 assert_eq!(pt.entries, Value::Null);
836 assert_eq!(pt.summary.matched_for_query, 0);
838 }
839
840 #[test]
841 fn workspace_count_lazy_only_when_query_matched_nothing() {
842 let (engine, _qn) = seed();
846 append(
847 &engine,
848 "sym_x",
849 LedgerKind::Hypothesis,
850 "ws hyp",
851 Some(0.8),
852 None,
853 );
854 let pt = gather_prior_thinking(
856 &engine,
857 &["nonexistent.qname".into()],
858 DEFAULT_CONFIDENCE_FLOOR,
859 );
860 assert_eq!(pt.summary.matched_for_query, 0);
861 assert_eq!(
862 pt.summary.entries_in_workspace,
863 Some(1),
864 "workspace count must find the orphan hypothesis"
865 );
866 }
867}