1pub mod hypothesize;
47
48use anyhow::{anyhow, Result};
49use scema_memory::{MemoryBody, MemoryKind, MemoryRecord, MemoryStore, Outcome};
50use scema_policy::{decide, Decision, DecisionConfig, Evaluator};
51use scema_sim::{Projection, Simulator, StructuralSimulator};
52use scema_tools::{ImportObserver, Observer, RepoObserver};
53use scema_verify::{DecisionRecord, RecordStore};
54use scema_world::{now_secs, Goal, Hypothesis, WorldState};
55use std::path::PathBuf;
56
57use crate::hypothesize::{GoalHypothesizer, Hypothesizer, MemoryHypothesizer, SignalHypothesizer};
58
59pub const RUNTIME: &str = concat!("scema-omni/", env!("CARGO_PKG_VERSION"));
61
62pub struct Cycle {
64 pub world: WorldState,
65 pub hypotheses: Vec<Hypothesis>,
66 pub projections: Vec<Projection>,
67 pub decision: Decision,
68 pub record: DecisionRecord,
69 pub record_path: Option<PathBuf>,
71 pub remembered: usize,
73}
74
75pub struct Agent {
83 observers: Vec<Box<dyn Observer + Send + Sync>>,
84 simulator: Box<dyn Simulator + Send + Sync>,
85 evaluators: Vec<Box<dyn Evaluator + Send + Sync>>,
86 memory: MemoryStore,
87 records: RecordStore,
88 pub config: DecisionConfig,
89 pub persist: bool,
93}
94
95impl Agent {
96 pub fn new(root: impl Into<PathBuf>, dqstar_checkpoint: Option<String>) -> Self {
102 let root: PathBuf = root.into();
103 let mut evaluators: Vec<Box<dyn Evaluator + Send + Sync>> = Vec::new();
104
105 {
106 use scema_policy::dqstar::DqStarEvaluator;
107 evaluators.push(Box::new(match dqstar_checkpoint {
108 Some(p) => DqStarEvaluator::from_checkpoint(p),
109 None => DqStarEvaluator::unloaded(),
110 }));
111 }
112
113 Agent {
114 observers: vec![Box::new(ImportObserver::new()), Box::new(RepoObserver::new())],
119 simulator: Box::new(StructuralSimulator::new()),
120 evaluators,
121 memory: MemoryStore::new(root.clone()),
122 records: RecordStore::new(root),
123 config: DecisionConfig::default(),
124 persist: true,
125 }
126 }
127
128 pub fn memory(&self) -> &MemoryStore {
129 &self.memory
130 }
131
132 pub fn records(&self) -> &RecordStore {
133 &self.records
134 }
135
136 pub fn observers(&self) -> &[Box<dyn Observer + Send + Sync>] {
137 &self.observers
138 }
139
140 pub fn evaluators(&self) -> &[Box<dyn Evaluator + Send + Sync>] {
141 &self.evaluators
142 }
143
144 pub fn observe(&self, locator: &str) -> Result<WorldState> {
146 let observer = self
147 .observers
148 .iter()
149 .find(|o| o.handles(locator))
150 .ok_or_else(|| anyhow!("no observer in this build handles `{locator}`"))?;
151 observer.observe(locator)
152 }
153
154 pub fn hypothesize(&self, world: &WorldState, goal: &Goal) -> Vec<Hypothesis> {
160 let memory_hypothesizer = MemoryHypothesizer::new(&self.memory);
161 let sources: Vec<&dyn Hypothesizer> = vec![
162 &GoalHypothesizer,
163 &SignalHypothesizer,
164 &memory_hypothesizer,
165 ];
166 let mut out: Vec<Hypothesis> = Vec::new();
167 for s in sources {
168 for h in s.propose(world, goal) {
169 if !out.iter().any(|existing| existing.id == h.id) {
170 out.push(h);
171 }
172 }
173 }
174 out
175 }
176
177 pub fn cycle(&self, locator: &str, goal: Goal) -> Result<Cycle> {
179 let world = self.observe(locator)?;
180 self.cycle_over(world, goal)
181 }
182
183 pub fn cycle_over(&self, world: WorldState, goal: Goal) -> Result<Cycle> {
189 let hypotheses = self.hypothesize(&world, &goal);
190 let projections = self.simulator.project_all(&world, &goal, &hypotheses);
191
192 let evaluator_refs: Vec<&dyn Evaluator> = self
195 .evaluators
196 .iter()
197 .map(|b| b.as_ref() as &dyn Evaluator)
198 .collect();
199 let decision = decide(
200 &world,
201 &goal,
202 &hypotheses,
203 &projections,
204 &evaluator_refs,
205 self.config,
206 );
207
208 let record = DecisionRecord::seal(
209 RUNTIME,
210 now_secs(),
211 world.clone(),
212 goal.clone(),
213 hypotheses.clone(),
214 projections.clone(),
215 decision.clone(),
216 );
217
218 let (record_path, remembered) = if self.persist {
219 let path = self.records.save(&record)?;
220 let n = self.write_memory(&record)?;
221 (Some(path), n)
222 } else {
223 (None, 0)
224 };
225
226 Ok(Cycle { world, hypotheses, projections, decision, record, record_path, remembered })
227 }
228
229 fn write_memory(&self, record: &DecisionRecord) -> Result<usize> {
231 let mut n = 0usize;
232 let d = &record.decision;
233 let subject = record.world.entity.locator.clone();
234
235 let (what, outcome) = match (&d.chosen, &d.abstention) {
236 (Some(id), _) => (format!("chose `{id}` for goal `{}`", record.goal.statement), Outcome::Unobserved),
237 (None, Some(a)) => (format!("abstained: {}", a.headline()), Outcome::Unobserved),
238 (None, None) => ("no decision and no stated reason".into(), Outcome::Unobserved),
239 };
240 self.memory.remember(
244 &MemoryRecord::new(
245 record.id.clone(),
246 MemoryKind::Episodic,
247 record.at,
248 subject.clone(),
249 MemoryBody::Episode {
250 what,
251 outcome,
252 evidence: vec![format!("decision record {}", record.id)],
253 },
254 RUNTIME,
255 )
256 .tagged("cycle"),
257 )?;
258 n += 1;
259
260 for (i, r) in d.ranked.iter().enumerate() {
261 if Some(&r.hypothesis) == d.chosen.as_ref() {
262 continue;
263 }
264 let reason = match &d.abstention {
265 Some(a) => a.headline(),
266 None => format!("ranked #{} of {}", i + 1, d.ranked.len()),
267 };
268 self.memory.remember(&MemoryRecord::new(
269 format!("{}-{}", record.id, r.hypothesis),
270 MemoryKind::Counterfactual,
271 record.at,
272 subject.clone(),
273 MemoryBody::Counterfactual {
274 decision: record.id.clone(),
275 hypothesis: r.hypothesis.clone(),
276 statement: r.statement.clone(),
277 projected: r.utility.value,
278 reason,
279 },
280 RUNTIME,
281 ))?;
282 n += 1;
283 }
284
285 for e in &d.excluded {
286 self.memory.remember(&MemoryRecord::new(
287 format!("{}-{}", record.id, e.hypothesis),
288 MemoryKind::Counterfactual,
289 record.at,
290 subject.clone(),
291 MemoryBody::Counterfactual {
292 decision: record.id.clone(),
293 hypothesis: e.hypothesis.clone(),
294 statement: e.statement.clone(),
295 projected: 0.0,
299 reason: format!("forbidden: {}", e.reason),
300 },
301 RUNTIME,
302 ))?;
303 n += 1;
304 }
305
306 Ok(n)
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use scema_world::{
314 Constraint, Domain, Entity, EntityKind, Extent, Object, Polarity, Provenance, Signal,
315 };
316 use std::fs;
317
318 fn scratch() -> PathBuf {
319 let p = std::env::temp_dir().join(format!(
320 "scema-omni-agent-{}-{}",
321 std::process::id(),
322 std::time::SystemTime::now()
323 .duration_since(std::time::UNIX_EPOCH)
324 .unwrap()
325 .as_nanos()
326 ));
327 fs::create_dir_all(&p).unwrap();
328 p
329 }
330
331 fn world_with(signals: Vec<Signal>) -> WorldState {
332 WorldState {
333 observer: "test".into(),
334 entity: Entity {
335 kind: EntityKind::Repository,
336 locator: "/repo".into(),
337 label: "repo".into(),
338 },
339 domain: Domain::Software,
340 observed_at: 1_700_000_000,
341 objects: vec![Object::new("o", "file", "o", Provenance::Live { age_secs: 0 })],
342 facts: vec![],
343 signals,
344 extent: Extent::complete(1, "walked"),
345 blind_spots: vec![],
346 }
347 }
348
349 fn untested() -> Signal {
350 Signal {
351 id: "untested:x".into(),
352 polarity: Polarity::Risk,
353 label: "`x` has no tests".into(),
354 detail: "3 files, 900 lines, zero test attributes".into(),
355 magnitude: 0.9,
356 measured: true,
357 targets: vec!["unit:crates/x".into()],
358 evidence: vec!["counted 0".into()],
359 }
360 }
361
362 #[test]
363 fn a_full_pass_produces_a_record_that_verifies() {
364 let dir = scratch();
365 let agent = Agent::new(&dir, None);
366 let c = agent
367 .cycle_over(world_with(vec![untested()]), Goal::new("g", "raise confidence"))
368 .unwrap();
369 assert!(scema_verify::verify(&c.record).valid);
370 assert!(c.record_path.unwrap().exists());
371 fs::remove_dir_all(&dir).ok();
372 }
373
374 #[test]
375 fn the_same_world_and_goal_produce_the_same_record_id() {
376 let dir = scratch();
378 let agent = Agent::new(&dir, None);
379 let w = world_with(vec![untested()]);
380 let g = Goal::new("g", "raise confidence");
381 let a = DecisionRecord::seal(
382 RUNTIME,
383 0,
384 w.clone(),
385 g.clone(),
386 agent.hypothesize(&w, &g),
387 vec![],
388 decide(&w, &g, &[], &[], &[], agent.config),
389 );
390 let b = DecisionRecord::seal(
391 RUNTIME,
392 0,
393 w.clone(),
394 g.clone(),
395 agent.hypothesize(&w, &g),
396 vec![],
397 decide(&w, &g, &[], &[], &[], agent.config),
398 );
399 assert_eq!(a.id, b.id);
400 fs::remove_dir_all(&dir).ok();
401 }
402
403 #[test]
404 fn rejected_branches_become_unresolved_counterfactuals() {
405 let dir = scratch();
406 let agent = Agent::new(&dir, None);
407 agent
408 .cycle_over(world_with(vec![untested()]), Goal::new("g", "raise confidence"))
409 .unwrap();
410 let cal = agent.memory().calibration().unwrap();
411 assert!(cal.recorded > 0, "the branches not taken must be remembered");
412 assert_eq!(cal.resolved, 0);
413 assert_eq!(cal.mean_abs_error, None);
414 fs::remove_dir_all(&dir).ok();
415 }
416
417 #[test]
418 fn a_record_from_a_real_observation_survives_the_json_transport() {
419 let dir = scratch();
425 fs::write(dir.join("Cargo.toml"), "[package]
426name = \"t\"
427").unwrap();
428 fs::create_dir_all(dir.join("src")).unwrap();
429 fs::write(dir.join("src/lib.rs"), "fn a() {}
430// TODO: x
431// FIXME: y
432").unwrap();
433
434 let agent = Agent::new(&dir, None);
435 let c = agent.cycle(dir.to_str().unwrap(), Goal::new("g", "tidy up")).unwrap();
436 assert!(scema_verify::verify(&c.record).valid, "sealed record must verify in memory");
437
438 let text = serde_json::to_string(&c.record).unwrap();
439 let back: DecisionRecord = serde_json::from_str(&text).unwrap();
440 let v = scema_verify::verify(&back);
441 assert!(v.valid, "after JSON transport: {:?}", v.mismatches);
442
443 let reloaded = agent.records().load(&c.record.id).unwrap();
445 assert!(scema_verify::verify(&reloaded).valid);
446 fs::remove_dir_all(&dir).ok();
447 }
448
449 #[test]
450 fn a_dry_run_writes_nothing() {
451 let dir = scratch();
454 let mut agent = Agent::new(&dir, None);
455 agent.persist = false;
456 let c = agent
457 .cycle_over(world_with(vec![untested()]), Goal::new("g", "raise confidence"))
458 .unwrap();
459 assert!(c.record_path.is_none());
460 assert_eq!(c.remembered, 0);
461 assert!(agent.records().ids().unwrap().is_empty());
462 assert_eq!(agent.memory().calibration().unwrap().recorded, 0);
463 fs::remove_dir_all(&dir).ok();
464 }
465
466 #[test]
467 fn a_world_with_nothing_counted_abstains() {
468 let dir = scratch();
469 let agent = Agent::new(&dir, None);
470 let c = agent
471 .cycle_over(world_with(vec![]), Goal::new("g", "make it better somehow"))
472 .unwrap();
473 assert!(c.decision.chosen.is_none());
474 assert!(c.decision.abstention.is_some());
475 fs::remove_dir_all(&dir).ok();
476 }
477
478 #[test]
479 fn a_constraint_removes_a_branch_and_the_record_still_shows_it() {
480 let dir = scratch();
481 let agent = Agent::new(&dir, None);
482 let goal = Goal::new("g", "raise confidence")
483 .with_constraint(Constraint::must_not("unit:crates/x", "frozen for the release"));
484 let c = agent.cycle_over(world_with(vec![untested()]), goal).unwrap();
485 assert!(!c.decision.excluded.is_empty());
486 assert!(c.decision.excluded.iter().any(|e| e.reason.contains("frozen")));
487 fs::remove_dir_all(&dir).ok();
488 }
489
490 #[test]
491 fn duplicate_branch_ids_are_proposed_once() {
492 let dir = scratch();
493 let agent = Agent::new(&dir, None);
494 let w = world_with(vec![untested()]);
495 let hs = agent.hypothesize(&w, &Goal::new("g", "x"));
496 let mut ids: Vec<&str> = hs.iter().map(|h| h.id.as_str()).collect();
497 ids.sort_unstable();
498 let before = ids.len();
499 ids.dedup();
500 assert_eq!(before, ids.len(), "a branch counted twice looks twice as supported");
501 fs::remove_dir_all(&dir).ok();
502 }
503
504 #[test]
505 fn the_dqstar_evaluator_declines_on_a_software_world() {
506 let dir = scratch();
509 let agent = Agent::new(&dir, None);
510 let c = agent
511 .cycle_over(world_with(vec![untested()]), Goal::new("g", "raise confidence"))
512 .unwrap();
513 let status = c
514 .decision
515 .evaluator_status
516 .iter()
517 .find(|s| s.evaluator == "dqstar")
518 .expect("the evaluator must be listed even when it declines");
519 assert!(!status.applicability.is_applicable());
520 assert!(c.decision.ranked.iter().all(|r| r.evaluations.is_empty()));
521 fs::remove_dir_all(&dir).ok();
522 }
523}