Skip to main content

agentstatedeveloper_core/
thinking.rs

1//! Plan G t-006: read-side helper for surfacing captured thinking
2//! (Hypothesis / MentalModel / FailedAttempt / OpenQuestion) into
3//! prepare-change and context-for responses.
4//!
5//! AcmeFlow refinement (2026-06-04): the helper now returns a
6//! struct (`PriorThinking`) carrying BOTH the projected entries and
7//! a `ThinkingSummary` metadata block. The summary always emits even
8//! when no entries surface, so callers can distinguish:
9//!
10//!   - "thinking exists on the queried symbols but was filtered out"
11//!     (e.g. all hypotheses below the confidence floor): `surfaced == 0`
12//!     AND `by_kind_dropped` populated → action: lower the floor or
13//!     run `asd think list <qname>` to inspect.
14//!
15//!   - "no thinking entries exist for the queried symbols": all-zero
16//!     summary → action: maybe capture some via `asd think speculate/
17//!     model/question/failed`.
18//!
19//!   - "thinking exists somewhere in the workspace but not on these
20//!     symbols": `matched_for_query == 0` AND `entries_in_workspace > 0`
21//!     → action: broaden the query, or this symbol cluster is unmapped.
22//!
23//! `entries_in_workspace` is the only field that requires a workspace-
24//! wide scan; per the AcmeFlow design call (Craig, 2026-06-04) it's
25//! computed ONLY when `matched_for_query == 0` so the typical hot-path
26//! call pays nothing extra.
27
28use 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
38/// Hypotheses with confidence below this are excluded from auto-surface
39/// (still queryable via `asd think list`). Plan G t-001 picked 0.3 as
40/// the default; callers may override.
41pub const DEFAULT_CONFIDENCE_FLOOR: f64 = 0.3;
42
43/// Result of `gather_prior_thinking` — entries projected to JSON +
44/// metadata summary explaining what was scanned, what surfaced, and
45/// what was filtered.
46#[derive(Debug, Clone, Serialize)]
47pub struct PriorThinking {
48    /// The projected entries object (hypotheses/mental_models/
49    /// open_questions/failed_attempts arrays). `Value::Null` when
50    /// nothing surfaced — distinct from `{}`, lets callers omit the
51    /// field cleanly. Use `summary.surfaced > 0` as the boolean check.
52    pub entries: Value,
53    /// Always-emitted metadata. Even when `entries` is Null, the
54    /// summary tells the agent WHY (see module docs).
55    pub summary: ThinkingSummary,
56}
57
58/// Metadata accompanying the prior_thinking projection. Parallel in
59/// shape to `FeedbackMetrics` — same idea, different domain.
60///
61/// Token economy (1.0.78): every count field that's zero is skipped
62/// during serialization, and the all-zero `by_kind` / `by_kind_dropped`
63/// maps are skipped entirely. On a query where no thinking exists, the
64/// serialized form collapses to `{"scanned_qnames": N}` (or empty if
65/// even scanned_qnames is 0), saving ~200 chars/call. Non-zero counts
66/// still emit — accuracy preserved.
67#[derive(Debug, Clone, Serialize)]
68pub struct ThinkingSummary {
69    /// Number of qnames the caller passed in for scanning.
70    /// Skipped when zero (no qnames provided → nothing to summarize).
71    #[serde(skip_serializing_if = "crate::ser_helpers::is_zero_usize")]
72    pub scanned_qnames: usize,
73    /// Qnames that had at least one thinking entry (any kind).
74    /// Skipped when zero; agent infers "no match" from absence.
75    #[serde(skip_serializing_if = "crate::ser_helpers::is_zero_usize")]
76    pub matched_for_query: usize,
77    /// Entries kept after kind-filter + confidence-floor cuts.
78    /// Skipped when zero; agent infers "no entries surfaced" from
79    /// absence (and from `prior_thinking` being null/absent).
80    #[serde(skip_serializing_if = "crate::ser_helpers::is_zero_usize")]
81    pub surfaced: usize,
82    /// Surfaced counts broken out by kind. Skipped entirely when
83    /// every kind is zero. When emitted, ALL kinds appear (so the
84    /// agent doesn't have to defensive-check) — this is the
85    /// established `feedback_summary` pattern.
86    #[serde(skip_serializing_if = "crate::ser_helpers::is_all_zero_string_usize_map")]
87    pub by_kind: BTreeMap<String, usize>,
88    /// Entries the kind/confidence filters dropped, broken out by kind.
89    /// The load-bearing field for the "thinking exists but isn't
90    /// showing" case — when `by_kind_dropped["hypothesis"] > 0` AND
91    /// `by_kind["hypothesis"] == 0`, the agent knows hypotheses exist
92    /// for these symbols but all fell below the confidence floor.
93    /// Skipped when all-zero (most calls).
94    #[serde(skip_serializing_if = "crate::ser_helpers::is_all_zero_string_usize_map")]
95    pub by_kind_dropped: BTreeMap<String, usize>,
96    /// Workspace-wide thinking count (across ALL indexed symbols).
97    /// Only populated when `matched_for_query == 0` — gives the agent
98    /// a signal whether to broaden the query (workspace has entries
99    /// elsewhere) or capture new ones (workspace is genuinely empty).
100    /// `None` otherwise to keep the typical hot path cheap.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub entries_in_workspace: Option<usize>,
103}
104
105impl ThinkingSummary {
106    /// True when the summary carries no signal — useful for the
107    /// caller's "emit at all?" decision per the sentinel rule
108    /// (emit only when prior_thinking is null AND scanned_qnames > 0).
109    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
141/// Walk the given qnames, collect Plan G thinking entries, project to
142/// the compact `prior_thinking` JSON shape AND a metadata summary.
143/// Hypotheses below `min_confidence` are dropped (and counted into
144/// `summary.by_kind_dropped`).
145///
146/// `entries` is `Value::Null` when nothing surfaces; `summary` is
147/// always populated.
148/// Shared accumulator for the two gather entry points — field-for-field
149/// the aggregates the original inline loop built.
150struct 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    /// Classify one symbol's live ledger entries into the projection.
174    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        // Lazy workspace count: only walk when nothing matched the query —
265        // that's when the agent might want to know "is there ANY thinking
266        // in this workspace, or should I capture some?"
267        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
328/// Workspace-wide variant: ONE ledger-tree walk + ONE id-map build instead
329/// of a per-qname symbol lookup + per-symbol ledger read. Same output shape
330/// and filtering rules as calling [`gather_prior_thinking`] with every
331/// indexed qname, but O(entries) instead of O(symbols × tree-depth) — the
332/// per-qname form measured ~9 minutes on a 9.6k-symbol repo with a cold
333/// FTS cache (Plan T t-007 finding); this walk is milliseconds.
334pub 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        // Unindexed symbols are skipped, matching the per-qname path
341        // (which can only reach entries through an indexed qname).
342        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
351/// One-pass read of the whole ledger tree, grouped per symbol bucket.
352///
353/// Bulk analog of calling `LedgerStore::list_entries_with_superseded`
354/// for every symbol: ONE tree walk instead of one read per symbol
355/// (~54ms each on a cold FTS cache — minutes at 10k symbols while
356/// holding the engine mutex; Plan T scale finding). Entries are NOT
357/// supersede-filtered — pass each bucket through [`live_entries`].
358pub 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
381/// Per-bucket supersede filtering — the same rule as
382/// `LedgerStore::list_entries` (supersession never crosses symbols, so
383/// applying it per bucket is equivalent).
384pub 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
394/// Bulk analog of the "list every indexed symbol's ledger entries"
395/// loop (per-qname `get_symbol_by_qname` + per-symbol `list_entries`,
396/// which is minutes at 10k symbols on a cold FTS cache): ONE id-map
397/// build + ONE ledger-tree walk.
398///
399/// Returns `(qname, live entries)` per indexed symbol, sorted by qname
400/// with entries newest-first — the same visit order and per-symbol
401/// entry order as the per-qname loop it replaces. Symbols without
402/// ledger entries and ledger buckets for unindexed symbols are both
403/// skipped, matching the per-qname path.
404pub 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        // Newest first — same ordering contract as `list_entries`.
418        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
425/// Workspace-wide count of thinking entries across all symbols.
426/// Used as a fallback signal when prior_thinking finds nothing on the
427/// queried symbols.
428///
429/// Single ledger-tree walk. (Was: a per-qname symbol lookup plus a
430/// per-symbol ledger read — quadratic on big repos, and this function
431/// runs exactly when nothing matched, i.e. on the workspace-wide
432/// slow path. Plan T t-007 finding.)
433pub 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        // gather_prior_thinking_all must be output-identical to the
499        // per-qname form fed every indexed qname — it exists only as the
500        // O(entries) fast path (Plan T t-007 quadratic-listing finding).
501        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        // Non-thinking entries must not surface in either form.
550        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        // Entry arrays must match as sets (bucket iteration order may
579        // differ from the caller's qname order).
580        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        // all_symbol_entries must be output-identical to the per-qname
603        // loop it replaces (get_symbol_by_qname + list_entries per
604        // symbol) — same qname order, same newest-first entries, same
605        // supersede filtering, symbols without entries skipped.
606        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        // Third symbol with no entries — must not appear in either form.
623        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        // Per-qname reference implementation.
663        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        // matched_for_query == 0 → entries_in_workspace populated
697        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        // matched > 0 → skip workspace count
724        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        // The load-bearing signal: dropped > 0 even though surfaced == 0
742        assert_eq!(
743            pt.summary.by_kind_dropped["hypothesis"], 1,
744            "filtered-out entries must increment by_kind_dropped"
745        );
746        // matched_for_query counts qnames that HAD an entry, even
747        // if dropped — the entry existed, it was filtered.
748        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        // Non-thinking kinds don't even register as a match
837        assert_eq!(pt.summary.matched_for_query, 0);
838    }
839
840    #[test]
841    fn workspace_count_lazy_only_when_query_matched_nothing() {
842        // Seed a hypothesis on a DIFFERENT symbol than we query.
843        // matched_for_query stays 0, so entries_in_workspace must
844        // be populated AND > 0.
845        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        // Query a non-existent qname.
855        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}