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 schema: Some(scema_world::WORLD_SCHEMA.into()),
334 observer: "test".into(),
335 entity: Entity {
336 kind: EntityKind::Repository,
337 locator: "/repo".into(),
338 label: "repo".into(),
339 },
340 domain: Domain::Software,
341 observed_at: 1_700_000_000,
342 objects: vec![Object::new("o", "file", "o", Provenance::Live { age_secs: 0 })],
343 facts: vec![],
344 signals,
345 extent: Extent::complete(1, "walked"),
346 blind_spots: vec![],
347 }
348 }
349
350 fn untested() -> Signal {
351 Signal {
352 id: "untested:x".into(),
353 polarity: Polarity::Risk,
354 label: "`x` has no tests".into(),
355 detail: "3 files, 900 lines, zero test attributes".into(),
356 magnitude: 0.9,
357 measured: true,
358 targets: vec!["unit:crates/x".into()],
359 evidence: vec!["counted 0".into()],
360 }
361 }
362
363 #[test]
364 fn a_full_pass_produces_a_record_that_verifies() {
365 let dir = scratch();
366 let agent = Agent::new(&dir, None);
367 let c = agent
368 .cycle_over(world_with(vec![untested()]), Goal::new("g", "raise confidence"))
369 .unwrap();
370 assert!(scema_verify::verify(&c.record).valid);
371 assert!(c.record_path.unwrap().exists());
372 fs::remove_dir_all(&dir).ok();
373 }
374
375 #[test]
376 fn the_same_world_and_goal_produce_the_same_record_id() {
377 let dir = scratch();
379 let agent = Agent::new(&dir, None);
380 let w = world_with(vec![untested()]);
381 let g = Goal::new("g", "raise confidence");
382 let a = DecisionRecord::seal(
383 RUNTIME,
384 0,
385 w.clone(),
386 g.clone(),
387 agent.hypothesize(&w, &g),
388 vec![],
389 decide(&w, &g, &[], &[], &[], agent.config),
390 );
391 let b = DecisionRecord::seal(
392 RUNTIME,
393 0,
394 w.clone(),
395 g.clone(),
396 agent.hypothesize(&w, &g),
397 vec![],
398 decide(&w, &g, &[], &[], &[], agent.config),
399 );
400 assert_eq!(a.id, b.id);
401 fs::remove_dir_all(&dir).ok();
402 }
403
404 #[test]
405 fn rejected_branches_become_unresolved_counterfactuals() {
406 let dir = scratch();
407 let agent = Agent::new(&dir, None);
408 agent
409 .cycle_over(world_with(vec![untested()]), Goal::new("g", "raise confidence"))
410 .unwrap();
411 let cal = agent.memory().calibration().unwrap();
412 assert!(cal.recorded > 0, "the branches not taken must be remembered");
413 assert_eq!(cal.resolved, 0);
414 assert_eq!(cal.mean_abs_error, None);
415 fs::remove_dir_all(&dir).ok();
416 }
417
418 #[test]
419 fn a_record_from_a_real_observation_survives_the_json_transport() {
420 let dir = scratch();
426 fs::write(dir.join("Cargo.toml"), "[package]
427name = \"t\"
428").unwrap();
429 fs::create_dir_all(dir.join("src")).unwrap();
430 fs::write(dir.join("src/lib.rs"), "fn a() {}
431// TODO: x
432// FIXME: y
433").unwrap();
434
435 let agent = Agent::new(&dir, None);
436 let c = agent.cycle(dir.to_str().unwrap(), Goal::new("g", "tidy up")).unwrap();
437 assert!(scema_verify::verify(&c.record).valid, "sealed record must verify in memory");
438
439 let text = serde_json::to_string(&c.record).unwrap();
440 let back: DecisionRecord = serde_json::from_str(&text).unwrap();
441 let v = scema_verify::verify(&back);
442 assert!(v.valid, "after JSON transport: {:?}", v.mismatches);
443
444 let reloaded = agent.records().load(&c.record.id).unwrap();
446 assert!(scema_verify::verify(&reloaded).valid);
447 fs::remove_dir_all(&dir).ok();
448 }
449
450 #[test]
451 fn a_dry_run_writes_nothing() {
452 let dir = scratch();
455 let mut agent = Agent::new(&dir, None);
456 agent.persist = false;
457 let c = agent
458 .cycle_over(world_with(vec![untested()]), Goal::new("g", "raise confidence"))
459 .unwrap();
460 assert!(c.record_path.is_none());
461 assert_eq!(c.remembered, 0);
462 assert!(agent.records().ids().unwrap().is_empty());
463 assert_eq!(agent.memory().calibration().unwrap().recorded, 0);
464 fs::remove_dir_all(&dir).ok();
465 }
466
467 #[test]
468 fn a_world_with_nothing_counted_abstains() {
469 let dir = scratch();
470 let agent = Agent::new(&dir, None);
471 let c = agent
472 .cycle_over(world_with(vec![]), Goal::new("g", "make it better somehow"))
473 .unwrap();
474 assert!(c.decision.chosen.is_none());
475 assert!(c.decision.abstention.is_some());
476 fs::remove_dir_all(&dir).ok();
477 }
478
479 #[test]
480 fn a_constraint_removes_a_branch_and_the_record_still_shows_it() {
481 let dir = scratch();
482 let agent = Agent::new(&dir, None);
483 let goal = Goal::new("g", "raise confidence")
484 .with_constraint(Constraint::must_not("unit:crates/x", "frozen for the release"));
485 let c = agent.cycle_over(world_with(vec![untested()]), goal).unwrap();
486 assert!(!c.decision.excluded.is_empty());
487 assert!(c.decision.excluded.iter().any(|e| e.reason.contains("frozen")));
488 fs::remove_dir_all(&dir).ok();
489 }
490
491 #[test]
492 fn duplicate_branch_ids_are_proposed_once() {
493 let dir = scratch();
494 let agent = Agent::new(&dir, None);
495 let w = world_with(vec![untested()]);
496 let hs = agent.hypothesize(&w, &Goal::new("g", "x"));
497 let mut ids: Vec<&str> = hs.iter().map(|h| h.id.as_str()).collect();
498 ids.sort_unstable();
499 let before = ids.len();
500 ids.dedup();
501 assert_eq!(before, ids.len(), "a branch counted twice looks twice as supported");
502 fs::remove_dir_all(&dir).ok();
503 }
504
505 #[test]
506 fn the_dqstar_evaluator_declines_on_a_software_world() {
507 let dir = scratch();
510 let agent = Agent::new(&dir, None);
511 let c = agent
512 .cycle_over(world_with(vec![untested()]), Goal::new("g", "raise confidence"))
513 .unwrap();
514 let status = c
515 .decision
516 .evaluator_status
517 .iter()
518 .find(|s| s.evaluator == "dqstar")
519 .expect("the evaluator must be listed even when it declines");
520 assert!(!status.applicability.is_applicable());
521 assert!(c.decision.ranked.iter().all(|r| r.evaluations.is_empty()));
522 fs::remove_dir_all(&dir).ok();
523 }
524}