1use scema_memory::{MemoryBody, MemoryKind, MemoryStore, Recall};
32use scema_world::{
33 Action, Goal, Hypothesis, HypothesisOrigin, Polarity, RiskClass, Signal, WorldState,
34 GOAL_HYPOTHESIS_ID,
35};
36
37pub trait Hypothesizer {
39 fn name(&self) -> &str;
40 fn propose(&self, world: &WorldState, goal: &Goal) -> Vec<Hypothesis>;
41}
42
43#[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 .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#[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
139pub 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 return vec![];
161 };
162 hits.iter()
163 .filter_map(|r| match &r.body {
164 MemoryBody::Procedure { name, steps, successes, failures } => {
165 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 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 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}