Skip to main content

scema_agent/
lib.rs

1//! # scema-agent — the loop
2//!
3//! ```text
4//!   observe ─▶ hypothesise ─▶ simulate ─▶ score ─▶ decide ─▶ record ─▶ remember
5//!      ▲                                                                  │
6//!      └──────────────────────────────────────────────────────────────────┘
7//! ```
8//!
9//! Every stage is a trait with at least one real implementation, and the whole pass is
10//! deterministic: the same world and goal produce the same [`DecisionRecord`] id. That is
11//! not an aesthetic property — it is what makes the record verifiable by somebody who was
12//! not there.
13//!
14//! ## One loop, four kinds of world
15//!
16//! `Agent::observe` resolves a locator against an ordered registry. `RepoObserver` walks a
17//! source tree in this process; `ImportObserver` reads a `WorldState` that something else
18//! produced — the browser extension, `scematica_mesh::omni`, `alchem_link.omni`. Nothing
19//! above perception can tell which it was, which is what "domain-agnostic" has to mean in
20//! order to be worth claiming. The one thing that *is* distinguishable is attribution: an
21//! imported world's `observer` field is stamped `imported:`, so a record can never claim a
22//! world that arrived as a file was observed here.
23//!
24//! ## What the loop does *not* do
25//!
26//! It does not execute. [`Cycle`] ends at a decision and a record; nothing in this
27//! workspace writes to the environment it observed. The [`scema_world::Action`] values in a
28//! chosen hypothesis are a *declaration of intent* that has been risk-classified and
29//! constraint-checked, and turning one into a side effect is a separate crate with a
30//! separate approval model — the one `alchem-link` already worked out, where risk class is
31//! declared per tool and no terminal means deny.
32//!
33//! Saying that plainly matters more than shipping it. An agent runtime that quietly gained
34//! a write path would invalidate every claim the rest of these crates make about being safe
35//! to point at a live system.
36//!
37//! ## Memory is written on every pass, including the abstentions
38//!
39//! The rejected branches are the interesting ones. Each becomes a
40//! [`scema_memory::MemoryBody::Counterfactual`] holding what was projected for it and why
41//! it lost — and, per that crate's rule, it stays unresolved forever unless somebody
42//! actually runs it. An abstention is recorded as an episode with
43//! [`scema_memory::Outcome::Unobserved`], because "the agent declined" is a fact about the
44//! agent and not an outcome in the world.
45
46pub 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
59/// Runtime identifier stamped into every record.
60pub const RUNTIME: &str = concat!("scema-omni/", env!("CARGO_PKG_VERSION"));
61
62/// One complete pass.
63pub 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    /// Where the record was written, when it was. `None` in dry-run.
70    pub record_path: Option<PathBuf>,
71    /// Memory records appended by this pass.
72    pub remembered: usize,
73}
74
75/// The orchestrator.
76///
77/// The trait objects carry `Send + Sync` because `scema-daemon` shares one `Arc<Agent>`
78/// across connection threads. Constructing an agent per request would reload the Deep Q*
79/// checkpoint every time, and a `Mutex` would serialise every observation behind whichever
80/// request is currently walking a large tree. Every implementation in this workspace is
81/// already thread-safe — they hold plain data and take no locks.
82pub 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    /// When false, `cycle` computes and returns everything but writes nothing. The default
90    /// for `scema simulate`, which is explicitly a counterfactual and must not leave a
91    /// trace that reads like a decision the agent made.
92    pub persist: bool,
93}
94
95impl Agent {
96    /// An agent rooted at a state directory, with the default observers and evaluators.
97    ///
98    /// `dqstar_checkpoint` is the sniper's `scematica-nn-agent.json` when there is one. A
99    /// missing file is not an error — the evaluator reports it through its applicability,
100    /// which is where an operator will actually see it.
101    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            // Ordered, not scored — `Agent::observe` takes the first that claims a locator.
115            // `ImportObserver` is ahead of `RepoObserver` because the latter accepts almost
116            // any string; the former's grammar is narrow (`-`, or a `.json` suffix) exactly
117            // because being first means anything it claims, the repo observer never sees.
118            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    /// Perceive an environment.
145    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    /// Propose branches from every hypothesiser, in a stable order.
155    ///
156    /// Duplicate ids are dropped, first proposer wins. Two hypothesisers arriving at the
157    /// same branch is a real occurrence — a memory of a procedure that a signal rule would
158    /// also propose — and ranking the same branch twice would inflate its apparent support.
159    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    /// Run one full pass.
178    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    /// Run a pass over a world that was already observed.
184    ///
185    /// Split out so the CLI can observe once and simulate several goals against the same
186    /// world, and so a test can drive the loop over a constructed world without a
187    /// filesystem.
188    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        // The explicit cast is load-bearing: `&(dyn Evaluator + Send + Sync)` coerces to
193        // `&dyn Evaluator`, but inference will not do it inside `collect`.
194        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    /// Append this pass to memory: one episode, one counterfactual per branch not taken.
230    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        // `Unobserved` in both arms and deliberately so. The agent knows what it decided; it
241        // does not know whether that was right, and `Succeeded` here would be the loop
242        // grading its own homework before anything happened.
243        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                    // A forbidden branch was never projected — it was removed before
296                    // ranking. `f64::NAN` would poison the calibration arithmetic, so it
297                    // is recorded as 0.0 with the reason carrying the truth.
298                    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        // Determinism is the precondition for a third party verifying anything.
377        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        // The regression behind `scema_verify::canonical`'s fixed-point float encoding. A
420        // record sealed here and verified after a `GET` reported INVALID on a byte nobody
421        // had touched, because `serde_json` parsed one of its own emitted floats one ULP
422        // low. Synthetic worlds never hit it — their magnitudes are round numbers — so the
423        // pin has to run against a real walk.
424        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        // And through the store, which is how `scema verify` reads it.
444        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        // `scema simulate` is a counterfactual and must not leave a trace that later reads
452        // like a decision the agent made.
453        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        // The relationship between this runtime and the trading bot, asserted rather than
507        // described: the DQN is a specialist that says nothing here.
508        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}