Skip to main content

scema_agent/
hypothesize.rs

1//! [`Hypothesizer`]: where competing futures come from.
2//!
3//! The branches are as good as this layer, and this layer is deliberately dumb. Three
4//! implementations ship:
5//!
6//! * [`SignalHypothesizer`] — one branch per counted signal in the world. Every branch it
7//!   makes is grounded by construction, which is the only way a branch can earn a measured
8//!   expected gain downstream.
9//! * [`GoalHypothesizer`] — the branch that is simply *what was asked for*. It is grounded
10//!   only by [`Goal::grounded_in`], which the operator sets deliberately; it never infers
11//!   grounding from the wording. Ungrounded, it scores at or below zero and the agent
12//!   abstains. That is the honest outcome: **an instruction is not evidence.**
13//!
14//!   An earlier version did infer it, by keyword overlap, and the first run against this
15//!   repository grounded "add tests to the scema-cli crate" in a marker backlog in a
16//!   different crate — `scema` being a substring of every unit name here. The branch
17//!   inherited a measured expected gain from unrelated evidence, which is exactly the
18//!   laundering `scema-sim` refuses to do. See [`Goal::grounded_in`].
19//! * [`MemoryHypothesizer`] — procedures that worked on this subject before.
20//!
21//! ## The slot that is empty on purpose
22//!
23//! A model-backed hypothesiser — an LLM reading the world state and proposing branches — is
24//! the obvious fourth, and [`HypothesisOrigin::Model`] exists for it. It is not implemented
25//! here because it changes what the runtime is: every other component in this workspace is
26//! deterministic and reproducible, which is what makes a decision record verifiable at all.
27//! A model in this position is fine — it only *proposes*, and the simulator still refuses
28//! to score an ungrounded branch — but it needs its prompt, its model id and its raw output
29//! committed into the record, and that is a design step rather than a wiring step.
30
31use scema_memory::{MemoryBody, MemoryKind, MemoryStore, Recall};
32use scema_world::{
33    Action, Goal, Hypothesis, HypothesisOrigin, Polarity, RiskClass, Signal, WorldState,
34    GOAL_HYPOTHESIS_ID,
35};
36
37/// Something that proposes candidate futures.
38pub trait Hypothesizer {
39    fn name(&self) -> &str;
40    fn propose(&self, world: &WorldState, goal: &Goal) -> Vec<Hypothesis>;
41}
42
43// What reversing an edit costs is a property of the domain, so the table lives on
44// `Domain::edit_reversibility` rather than here. It moved when `Domain` became an open enum:
45// a `match` with a `_ => Unknown` arm in this file was fine while there were four domains
46// and quietly wrong once a producer could name its own, because every new domain would have
47// landed on the fallback without anyone reading this function again.
48
49/// One branch per counted signal.
50#[derive(Clone, Debug, Default)]
51pub struct SignalHypothesizer;
52
53impl SignalHypothesizer {
54    fn action_for(&self, world: &WorldState, s: &Signal, idx: usize) -> Action {
55        let target = s
56            .targets
57            .first()
58            .cloned()
59            .unwrap_or_else(|| world.entity.locator.clone());
60        Action::new(
61            format!("a{idx}"),
62            RiskClass::Write,
63            target,
64            format!("address `{}`", s.label),
65            world.domain.edit_reversibility(),
66        )
67    }
68}
69
70impl Hypothesizer for SignalHypothesizer {
71    fn name(&self) -> &str {
72        "signal"
73    }
74
75    fn propose(&self, world: &WorldState, _goal: &Goal) -> Vec<Hypothesis> {
76        world
77            .signals
78            .iter()
79            .enumerate()
80            // Only counted signals. An estimated one would produce a branch that looks
81            // grounded and is not, and `scema-sim` would then have to unpick it.
82            .filter(|(_, s)| s.measured)
83            .map(|(i, s)| {
84                let verb = match s.polarity {
85                    Polarity::Risk => "mitigate",
86                    Polarity::Opportunity => "take",
87                };
88                Hypothesis::new(
89                    format!("h-{}", s.id.replace([':', '/', ' '], "-")),
90                    format!("{verb}: {}", s.label),
91                    HypothesisOrigin::Heuristic { rule: format!("one branch per counted signal ({})", s.id) },
92                )
93                .because(format!("{} — {}", s.detail, s.evidence.join("; ")))
94                .grounded(s.id.clone())
95                .doing(self.action_for(world, s, i))
96            })
97            .collect()
98    }
99}
100
101/// The branch that is exactly what the operator asked for.
102#[derive(Clone, Debug, Default)]
103pub struct GoalHypothesizer;
104
105impl Hypothesizer for GoalHypothesizer {
106    fn name(&self) -> &str {
107        "goal"
108    }
109
110    fn propose(&self, world: &WorldState, goal: &Goal) -> Vec<Hypothesis> {
111        if goal.statement.trim().is_empty() {
112            return vec![];
113        }
114        let mut h =
115            Hypothesis::new(GOAL_HYPOTHESIS_ID, goal.statement.clone(), HypothesisOrigin::Human)
116            .because(if goal.grounded_in.is_empty() {
117                "the operator asked for this and cited no counted signal; an instruction is not evidence"
118                    .to_string()
119            } else {
120                format!(
121                    "the operator asserts this addresses: {}",
122                    goal.grounded_in.join(", ")
123                )
124            })
125            .doing(Action::new(
126                "a0",
127                RiskClass::Write,
128                world.entity.locator.clone(),
129                goal.statement.clone(),
130                world.domain.edit_reversibility(),
131            ));
132        for g in &goal.grounded_in {
133            h = h.grounded(g.clone());
134        }
135        vec![h]
136    }
137}
138
139/// Branches recalled from procedures that have worked on this subject.
140pub struct MemoryHypothesizer<'a> {
141    store: &'a MemoryStore,
142}
143
144impl<'a> MemoryHypothesizer<'a> {
145    pub fn new(store: &'a MemoryStore) -> Self {
146        MemoryHypothesizer { store }
147    }
148}
149
150impl Hypothesizer for MemoryHypothesizer<'_> {
151    fn name(&self) -> &str {
152        "memory"
153    }
154
155    fn propose(&self, world: &WorldState, _goal: &Goal) -> Vec<Hypothesis> {
156        let query = Recall::about(world.entity.locator.clone()).limit(5);
157        let Ok(hits) = self.store.recall(MemoryKind::Procedural, &query) else {
158            // A memory that cannot be read yields no branches. It must not be an error:
159            // the loop has to run on a machine with no history at all.
160            return vec![];
161        };
162        hits.iter()
163            .filter_map(|r| match &r.body {
164                MemoryBody::Procedure { name, steps, successes, failures } => {
165                    // A procedure that has failed more often than it has worked is recalled
166                    // but not proposed. Recording it and re-proposing it are different
167                    // things, and only the second is a recommendation.
168                    if failures > successes {
169                        return None;
170                    }
171                    let mut h = Hypothesis::new(
172                        format!("h-mem-{}", r.id),
173                        format!("apply the `{name}` procedure"),
174                        HypothesisOrigin::Memory { record: r.id.clone() },
175                    )
176                    .because(format!(
177                        "{successes} success(es), {failures} failure(s) recorded against this procedure"
178                    ));
179                    for (i, step) in steps.iter().enumerate() {
180                        h = h.doing(Action::new(
181                            format!("a{i}"),
182                            RiskClass::Write,
183                            world.entity.locator.clone(),
184                            step.clone(),
185                            world.domain.edit_reversibility(),
186                        ));
187                    }
188                    Some(h)
189                }
190                _ => None,
191            })
192            .collect()
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use scema_world::{Domain, Reversibility};
200    use scema_world::{Entity, EntityKind, Extent, Object, Provenance};
201
202    fn signal(id: &str, label: &str, measured: bool) -> Signal {
203        Signal {
204            id: id.into(),
205            polarity: Polarity::Risk,
206            label: label.into(),
207            detail: "d".into(),
208            magnitude: 0.5,
209            measured,
210            targets: vec!["unit:crates/x".into()],
211            evidence: vec!["counted".into()],
212        }
213    }
214
215    fn world(signals: Vec<Signal>, domain: Domain) -> WorldState {
216        WorldState {
217            schema: Some(scema_world::WORLD_SCHEMA.into()),
218            observer: "t".into(),
219            entity: Entity {
220                kind: EntityKind::Repository,
221                locator: "/repo".into(),
222                label: "repo".into(),
223            },
224            domain,
225            observed_at: 0,
226            objects: vec![Object::new("o", "file", "o", Provenance::Live { age_secs: 0 })],
227            facts: vec![],
228            signals,
229            extent: Extent::complete(1, "t"),
230            blind_spots: vec![],
231        }
232    }
233
234    #[test]
235    fn only_counted_signals_become_branches() {
236        let w = world(
237            vec![signal("s1", "counted thing", true), signal("s2", "guessed thing", false)],
238            Domain::Software,
239        );
240        let hs = SignalHypothesizer.propose(&w, &Goal::new("g", "x"));
241        assert_eq!(hs.len(), 1);
242        assert_eq!(hs[0].grounded_in, vec!["s1".to_string()]);
243    }
244
245    #[test]
246    fn every_signal_branch_is_grounded_by_construction() {
247        let w = world(vec![signal("s1", "a", true), signal("s2", "b", true)], Domain::Software);
248        let hs = SignalHypothesizer.propose(&w, &Goal::new("g", "x"));
249        assert!(hs.iter().all(|h| !h.grounded_in.is_empty()));
250    }
251
252    #[test]
253    fn a_goal_never_grounds_itself_from_its_own_wording() {
254        // The regression this rule exists for. The goal names the `x` crate and a signal
255        // about the `x` crate is right there, and it still must not be picked up: word
256        // overlap grounded an unrelated crate the first time it was tried, because every
257        // unit name in the host repository shares a prefix.
258        let w = world(vec![signal("untested:x", "`x` has no tests", true)], Domain::Software);
259        let hs = GoalHypothesizer.propose(&w, &Goal::new("g", "add tests to the x crate"));
260        assert_eq!(hs.len(), 1);
261        assert!(
262            hs[0].grounded_in.is_empty(),
263            "an instruction is not evidence; this branch must not borrow grounding"
264        );
265    }
266
267    #[test]
268    fn an_operator_can_ground_a_goal_deliberately() {
269        let w = world(vec![signal("untested:x", "`x` has no tests", true)], Domain::Software);
270        let g = Goal::new("g", "add tests to the x crate").grounded("untested:x");
271        let hs = GoalHypothesizer.propose(&w, &g);
272        assert_eq!(hs[0].grounded_in, vec!["untested:x".to_string()]);
273        assert!(hs[0].rationale.contains("operator asserts"));
274    }
275
276    #[test]
277    fn an_unknown_domain_yields_unclassified_reversibility() {
278        // The conservative default. Only a domain whose undo cost is actually understood
279        // may claim a reversibility, and everything else stays unmeasured downstream.
280        let w = world(vec![signal("s1", "a", true)], Domain::Unknown);
281        let hs = SignalHypothesizer.propose(&w, &Goal::new("g", "x"));
282        assert_eq!(hs[0].worst_reversibility(), Some(Reversibility::Unknown));
283    }
284
285    #[test]
286    fn an_empty_goal_proposes_nothing() {
287        let w = world(vec![], Domain::Software);
288        assert!(GoalHypothesizer.propose(&w, &Goal::new("g", "   ")).is_empty());
289    }
290}