Skip to main content

scema_sim/
lib.rs

1//! # scema-sim — the counterfactual layer
2//!
3//! Between "the agent has ideas" and "the agent acts" there is supposed to be a step where
4//! the ideas are made to compete. This crate is that step. It takes a [`WorldState`], a
5//! [`Goal`] and a set of [`Hypothesis`] branches and returns a [`Projection`] per branch:
6//! expected gain, risk, cost, uncertainty and reversibility, each as a [`Term`] that says
7//! whether anybody measured it.
8//!
9//! ## The rule that shapes everything here
10//!
11//! > **A projection may not invent a number.**
12//!
13//! A simulator that outputs `+31% predicted performance` for a refactor nobody has
14//! benchmarked has produced a hallucination with a decimal point on it, and the decimal
15//! point is what makes it dangerous — it survives into a ranking, a report and a decision
16//! record looking exactly like a measurement. So [`StructuralSimulator`] scores an
17//! expected gain **only** for a hypothesis grounded in a measured signal that the observer
18//! actually counted. Everything else is [`Term::absent`] with `0.0` and a note saying what
19//! would have to exist.
20//!
21//! The consequence is uncomfortable and correct: on a world that was barely perceived,
22//! most branches project a gain of exactly zero and the agent abstains. That is the true
23//! answer. The alternative — a plausible ranking over invented gains — is the failure this
24//! whole workspace is built to avoid.
25//!
26//! ## What a structural simulator can and cannot know
27//!
28//! It knows what the *plan* declares (how many steps, of what risk class, how reversible)
29//! and what the *world* recorded (which signals exist, how legible it was, what could not
30//! be read). Those are real observations about the decision, and they are enough to rank
31//! branches by hazard and by ignorance. They are not enough to predict an outcome, and
32//! this crate never claims to. Predicting outcomes requires either a domain model or an
33//! executed experiment; both are [`Simulator`] implementations somebody can add, which is
34//! why this is a trait and not a function.
35
36use scema_world::{
37    Coverage, Goal, Hypothesis, Polarity, Reversibility, Signal, Term, WorldState,
38};
39use serde::{Deserialize, Serialize};
40
41/// Anything that can project a hypothesis forward.
42///
43/// Implementations range from the structural one below (no domain knowledge, no execution)
44/// through domain models to simulators that actually run the experiment in a sandbox. All
45/// of them must obey the module rule: an unmeasured dimension is [`Term::absent`].
46pub trait Simulator {
47    /// Stable name, recorded in the projection and hashed into the decision record.
48    fn name(&self) -> &str;
49
50    fn project(&self, world: &WorldState, goal: &Goal, hypothesis: &Hypothesis) -> Projection;
51
52    fn project_all(&self, world: &WorldState, goal: &Goal, hs: &[Hypothesis]) -> Vec<Projection> {
53        hs.iter().map(|h| self.project(world, goal, h)).collect()
54    }
55}
56
57/// A way this branch could go wrong.
58///
59/// `likelihood` is a [`Term`] like everything else, and it is usually unmeasured. A named
60/// failure mode with an honest "nobody has estimated this" is worth far more than a
61/// number: it is the thing a human reads before approving.
62#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
63pub struct FailureMode {
64    pub label: String,
65    pub detail: String,
66    pub likelihood: Term,
67}
68
69/// What the world would look like afterwards, to the extent that is knowable.
70///
71/// Not a predicted `WorldState` — producing one would mean fabricating attribute values
72/// for every object the plan touches. It is the *delta the plan claims*: what it would
73/// touch, which observed signals it would address, and which it would leave standing.
74/// `unaddressed_risks` is the most useful field on the struct and the one a plan author
75/// never volunteers.
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
77pub struct ShadowDelta {
78    pub touched_objects: Vec<String>,
79    pub addresses_signals: Vec<String>,
80    pub unaddressed_risks: Vec<String>,
81}
82
83/// The projected consequences of one hypothesis.
84#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
85pub struct Projection {
86    pub hypothesis: String,
87    pub simulator: String,
88    /// `R` — expected gain, additive, neutral `0.0`. Measured only from counted signals.
89    pub expected_gain: Term,
90    /// `K` — hazard of acting, additive penalty, neutral `0.0`.
91    pub risk: Term,
92    /// `C` — cost proxy, additive penalty, neutral `0.0`.
93    pub cost: Term,
94    /// `U` — ignorance about the world this plan runs in, additive penalty, neutral `0.0`.
95    pub uncertainty: Term,
96    /// `V` — reversibility, additive bonus, neutral `0.0`.
97    pub reversibility: Term,
98    pub failure_modes: Vec<FailureMode>,
99    pub shadow: ShadowDelta,
100    /// Constraint violated by this branch, if any. A projection carrying this must never
101    /// be ranked — see `scema_policy::Decision`.
102    pub forbidden_by: Option<String>,
103    /// How many of the five terms were measured. Never separated from the numbers.
104    pub coverage: Coverage,
105}
106
107impl Projection {
108    /// The five terms in equation order.
109    pub fn terms(&self) -> [&Term; 5] {
110        [&self.expected_gain, &self.risk, &self.cost, &self.uncertainty, &self.reversibility]
111    }
112}
113
114/// Simulation from the structure of the plan and the legibility of the world.
115///
116/// Takes no domain knowledge and executes nothing. See the crate note for what that buys
117/// and what it costs.
118#[derive(Clone, Debug, Default)]
119pub struct StructuralSimulator;
120
121impl StructuralSimulator {
122    pub fn new() -> Self {
123        StructuralSimulator
124    }
125
126    /// Signals the hypothesis cites that actually exist in the world.
127    ///
128    /// A citation of a signal id that is not present is dropped rather than trusted. That
129    /// happens when a hypothesis outlives the world it was proposed against, and treating
130    /// a dangling citation as support is how a stale plan keeps its score.
131    fn cited<'a>(&self, world: &'a WorldState, h: &Hypothesis) -> Vec<&'a Signal> {
132        h.grounded_in
133            .iter()
134            .filter_map(|id| world.signals.iter().find(|s| &s.id == id))
135            .collect()
136    }
137
138    fn gain_term(&self, world: &WorldState, h: &Hypothesis) -> Term {
139        let cited = self.cited(world, h);
140        if cited.is_empty() {
141            return Term::absent(
142                "R",
143                "expected gain",
144                0.0,
145                "hypothesis cites no signal in this world; no observed basis for a gain",
146            );
147        }
148        let counted: Vec<&&Signal> = cited.iter().filter(|s| s.measured).collect();
149        if counted.is_empty() {
150            return Term::absent(
151                "R",
152                "expected gain",
153                0.0,
154                format!(
155                    "cites {} signal(s), all of them estimates rather than counts",
156                    cited.len()
157                ),
158            );
159        }
160        let mean = counted.iter().map(|s| s.magnitude).sum::<f64>() / counted.len() as f64;
161        Term::measured(
162            "R",
163            "expected gain",
164            mean,
165            format!(
166                "mean magnitude of {} counted signal(s): {}",
167                counted.len(),
168                counted.iter().map(|s| s.id.as_str()).collect::<Vec<_>>().join(", ")
169            ),
170        )
171        .clamped(0.0, 1.0)
172    }
173
174    fn risk_term(&self, world: &WorldState, h: &Hypothesis) -> Term {
175        let Some(worst) = h.worst_risk_class() else {
176            return Term::absent(
177                "K",
178                "hazard of acting",
179                0.0,
180                "hypothesis declares no actions; nothing to be hazardous",
181            );
182        };
183        let base = worst.base_hazard();
184        // A plan that reaches into objects the observer flagged as risky is more hazardous
185        // than the same plan elsewhere. Only counted risk signals escalate — an estimated
186        // one would let a rule of thumb veto by arithmetic.
187        let overlap: Vec<&Signal> = world
188            .risks()
189            .filter(|s| s.measured && self.touches(h, &s.targets))
190            .collect();
191        let escalation = overlap.iter().map(|s| s.magnitude).fold(0.0_f64, f64::max) * 0.5;
192        let note = if overlap.is_empty() {
193            format!("worst declared action class {worst:?}; no counted risk signal on its targets")
194        } else {
195            format!(
196                "worst declared action class {worst:?}, escalated by counted risk(s): {}",
197                overlap.iter().map(|s| s.id.as_str()).collect::<Vec<_>>().join(", ")
198            )
199        };
200        Term::measured("K", "hazard of acting", base + escalation, note).clamped(0.0, 1.0)
201    }
202
203    fn cost_term(&self, h: &Hypothesis) -> Term {
204        if h.actions.is_empty() {
205            return Term::absent("C", "cost", 0.0, "no declared steps to cost");
206        }
207        // Deliberately a step count and nothing more. It is a real property of the plan;
208        // it is not an effort estimate, and the note has to say so or a reader will take
209        // 0.4 for "40% of a day".
210        let steps = h.actions.len() as f64;
211        Term::measured(
212            "C",
213            "cost",
214            (steps / 10.0).min(1.0),
215            format!("{steps} declared step(s), normalised at 10; no effort or spend estimate exists"),
216        )
217    }
218
219    fn uncertainty_term(&self, world: &WorldState) -> Term {
220        if world.objects.is_empty() && world.blind_spots.is_empty() {
221            return Term::absent(
222                "U",
223                "uncertainty",
224                0.0,
225                "observer returned no objects and reported no blind spots; nothing to reason about",
226            );
227        }
228        let illegible = 1.0 - world.legibility();
229        // A blind spot is a thing the observer *tried* to read and could not, so it is
230        // evidence of ignorance rather than a guess about it. Saturating at five keeps one
231        // unreadable directory from pinning uncertainty at maximum.
232        let blind = (world.blind_spots.len() as f64 / 5.0).min(1.0);
233        // Unknown extent is its own penalty: a depth-limited walk can be perfectly legible
234        // about a small fraction of a large thing.
235        let unbounded = if world.extent.fraction().is_none() { 0.2 } else { 0.0 };
236        let value = (0.5 * illegible + 0.3 * blind + unbounded).min(1.0);
237        Term::measured(
238            "U",
239            "uncertainty",
240            value,
241            format!(
242                "{:.0}% of observed objects unreadable or stale, {} blind spot(s), extent {}",
243                illegible * 100.0,
244                world.blind_spots.len(),
245                if world.extent.fraction().is_none() { "unbounded" } else { "bounded" }
246            ),
247        )
248    }
249
250    fn reversibility_term(&self, h: &Hypothesis) -> Term {
251        match h.worst_reversibility() {
252            None => Term::absent(
253                "V",
254                "reversibility",
255                0.0,
256                "hypothesis declares no actions; nothing to reverse",
257            ),
258            Some(Reversibility::Unknown) => Term::absent(
259                "V",
260                "reversibility",
261                0.0,
262                "at least one step is unclassified; the plan cannot be called reversible",
263            ),
264            Some(r) => Term::measured(
265                "V",
266                "reversibility",
267                r.score().unwrap_or(0.0),
268                format!("least reversible declared step is {r:?}"),
269            ),
270        }
271    }
272
273    fn touches(&self, h: &Hypothesis, targets: &[String]) -> bool {
274        if targets.is_empty() {
275            // A signal about the entity as a whole is on every plan's path.
276            return true;
277        }
278        h.actions.iter().any(|a| {
279            targets
280                .iter()
281                .any(|t| a.target.contains(t.as_str()) || t.contains(a.target.as_str()))
282        })
283    }
284
285    fn failure_modes(&self, world: &WorldState, h: &Hypothesis) -> Vec<FailureMode> {
286        let mut out = Vec::new();
287
288        if matches!(h.worst_reversibility(), Some(Reversibility::Irreversible)) {
289            out.push(FailureMode {
290                label: "irreversible step".into(),
291                detail: "at least one declared step cannot be undone; a wrong branch is permanent"
292                    .into(),
293                likelihood: Term::absent(
294                    "p",
295                    "likelihood",
296                    0.0,
297                    "no base rate exists for this plan; severity is known, probability is not",
298                ),
299            });
300        }
301        if matches!(h.worst_reversibility(), Some(Reversibility::Unknown)) {
302            out.push(FailureMode {
303                label: "unclassified step".into(),
304                detail: "a step nobody has classified may be the irreversible one".into(),
305                likelihood: Term::absent("p", "likelihood", 0.0, "unclassified by construction"),
306            });
307        }
308        for s in world.risks().filter(|s| self.touches(h, &s.targets)) {
309            out.push(FailureMode {
310                label: s.label.clone(),
311                detail: s.detail.clone(),
312                likelihood: if s.measured {
313                    Term::measured("p", "likelihood", s.magnitude, format!("counted signal {}", s.id))
314                } else {
315                    Term::absent(
316                        "p",
317                        "likelihood",
318                        0.0,
319                        format!("signal {} is an estimate, not a count", s.id),
320                    )
321                },
322            });
323        }
324        if !world.blind_spots.is_empty() {
325            out.push(FailureMode {
326                label: "acting on a partly-unseen world".into(),
327                detail: format!(
328                    "the observer could not read: {}",
329                    world.blind_spots.join("; ")
330                ),
331                likelihood: Term::absent(
332                    "p",
333                    "likelihood",
334                    0.0,
335                    "unknowable by definition — the point is that it was not seen",
336                ),
337            });
338        }
339        out
340    }
341
342    fn shadow(&self, world: &WorldState, h: &Hypothesis) -> ShadowDelta {
343        let touched: Vec<String> = h.actions.iter().map(|a| a.target.clone()).collect();
344        let addresses: Vec<String> = h.grounded_in.clone();
345        let unaddressed: Vec<String> = world
346            .signals
347            .iter()
348            .filter(|s| s.polarity == Polarity::Risk && !addresses.contains(&s.id))
349            .map(|s| s.id.clone())
350            .collect();
351        ShadowDelta { touched_objects: touched, addresses_signals: addresses, unaddressed_risks: unaddressed }
352    }
353}
354
355impl Simulator for StructuralSimulator {
356    fn name(&self) -> &str {
357        "structural"
358    }
359
360    fn project(&self, world: &WorldState, goal: &Goal, h: &Hypothesis) -> Projection {
361        // Constraint checking happens here rather than in the policy layer so that a
362        // forbidden branch is still *projected* and still shows up in the record. An agent
363        // that silently drops the branch it was not allowed to take cannot explain the
364        // shape of its own choice afterwards.
365        let forbidden_by = h.actions.iter().find_map(|a| {
366            goal.violated_by(&a.target)
367                .or_else(|| goal.violated_by(&a.detail))
368                .map(|c| format!("{:?} {}: {}", c.kind, c.subject, c.detail))
369        });
370
371        let expected_gain = self.gain_term(world, h);
372        let risk = self.risk_term(world, h);
373        let cost = self.cost_term(h);
374        let uncertainty = self.uncertainty_term(world);
375        let reversibility = self.reversibility_term(h);
376        let coverage = Coverage::of(&[&expected_gain, &risk, &cost, &uncertainty, &reversibility]);
377
378        Projection {
379            hypothesis: h.id.clone(),
380            simulator: self.name().to_string(),
381            expected_gain,
382            risk,
383            cost,
384            uncertainty,
385            reversibility,
386            failure_modes: self.failure_modes(world, h),
387            shadow: self.shadow(world, h),
388            forbidden_by,
389            coverage,
390        }
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use scema_world::{
398        Action, Constraint, Domain, Entity, EntityKind, Extent, HypothesisOrigin, Object,
399        Provenance, RiskClass,
400    };
401
402    fn sig(id: &str, polarity: Polarity, magnitude: f64, measured: bool, targets: &[&str]) -> Signal {
403        Signal {
404            id: id.into(),
405            polarity,
406            label: id.into(),
407            detail: String::new(),
408            magnitude,
409            measured,
410            targets: targets.iter().map(|s| s.to_string()).collect(),
411            evidence: vec![],
412        }
413    }
414
415    fn world(signals: Vec<Signal>, blind: Vec<String>) -> WorldState {
416        WorldState {
417            observer: "test".into(),
418            entity: Entity { kind: EntityKind::Repository, locator: ".".into(), label: "t".into() },
419            domain: Domain::Software,
420            observed_at: 0,
421            objects: vec![Object::new("o1", "file", "o1", Provenance::Live { age_secs: 0 })],
422            facts: vec![],
423            signals,
424            extent: Extent::complete(1, "walked"),
425            blind_spots: blind,
426        }
427    }
428
429    fn hyp(id: &str) -> Hypothesis {
430        Hypothesis::new(id, "do a thing", HypothesisOrigin::Heuristic { rule: "t".into() })
431    }
432
433    #[test]
434    fn an_ungrounded_hypothesis_gets_no_expected_gain() {
435        // The headline rule. A plausible-sounding branch with nothing behind it must not
436        // score, however good it sounds.
437        let w = world(vec![], vec![]);
438        let g = Goal::new("g", "improve");
439        let p = StructuralSimulator.project(&w, &g, &hyp("h1"));
440        assert_eq!(p.expected_gain.value, 0.0);
441        assert!(!p.expected_gain.measured);
442        assert!(p.expected_gain.note.contains("no signal"));
443    }
444
445    #[test]
446    fn an_estimated_signal_does_not_become_a_measured_gain() {
447        let w = world(vec![sig("s1", Polarity::Opportunity, 0.9, false, &[])], vec![]);
448        let g = Goal::new("g", "improve");
449        let h = hyp("h1").grounded("s1");
450        let p = StructuralSimulator.project(&w, &g, &h);
451        assert!(!p.expected_gain.measured, "a guessed magnitude must not launder into a measurement");
452        assert_eq!(p.expected_gain.value, 0.0);
453    }
454
455    #[test]
456    fn a_counted_signal_does_produce_a_measured_gain() {
457        let w = world(vec![sig("s1", Polarity::Opportunity, 0.6, true, &[])], vec![]);
458        let g = Goal::new("g", "improve");
459        let p = StructuralSimulator.project(&w, &g, &hyp("h1").grounded("s1"));
460        assert!(p.expected_gain.measured);
461        assert!((p.expected_gain.value - 0.6).abs() < 1e-9);
462    }
463
464    #[test]
465    fn a_dangling_citation_is_dropped_not_trusted() {
466        let w = world(vec![], vec![]);
467        let g = Goal::new("g", "improve");
468        let p = StructuralSimulator.project(&w, &g, &hyp("h1").grounded("s-does-not-exist"));
469        assert!(!p.expected_gain.measured);
470    }
471
472    #[test]
473    fn a_forbidden_branch_is_projected_but_marked() {
474        let w = world(vec![], vec![]);
475        let g = Goal::new("g", "improve").with_constraint(Constraint::must_not("config.toml", "no"));
476        let h = hyp("h1").doing(Action::new(
477            "a1",
478            RiskClass::Write,
479            "crates/x/config.toml",
480            "edit",
481            Reversibility::Trivial,
482        ));
483        let p = StructuralSimulator.project(&w, &g, &h);
484        assert!(p.forbidden_by.is_some(), "the branch must still appear in the record");
485    }
486
487    #[test]
488    fn unknown_reversibility_is_absent_rather_than_zero_scored() {
489        let w = world(vec![], vec![]);
490        let g = Goal::new("g", "improve");
491        let h = hyp("h1").doing(Action::new(
492            "a1",
493            RiskClass::Write,
494            "x",
495            "y",
496            Reversibility::Unknown,
497        ));
498        let p = StructuralSimulator.project(&w, &g, &h);
499        assert!(!p.reversibility.measured);
500        assert_eq!(p.reversibility.value, 0.0);
501        assert!(p.failure_modes.iter().any(|f| f.label == "unclassified step"));
502    }
503
504    #[test]
505    fn blind_spots_raise_uncertainty_and_add_a_named_failure_mode() {
506        let clean = StructuralSimulator.project(&world(vec![], vec![]), &Goal::new("g", "x"), &hyp("h"));
507        let blind = StructuralSimulator.project(
508            &world(vec![], vec!["target/ (permission denied)".into()]),
509            &Goal::new("g", "x"),
510            &hyp("h"),
511        );
512        assert!(blind.uncertainty.value > clean.uncertainty.value);
513        assert!(blind.failure_modes.iter().any(|f| f.label.contains("unseen")));
514    }
515
516    #[test]
517    fn coverage_reports_how_many_of_the_five_terms_were_real() {
518        let w = world(vec![], vec![]);
519        let p = StructuralSimulator.project(&w, &Goal::new("g", "x"), &hyp("h"));
520        // No actions and no citations: only uncertainty is measurable.
521        assert_eq!(p.coverage.label(), "1/5");
522    }
523
524    #[test]
525    fn unaddressed_risks_are_reported_even_when_the_plan_ignores_them() {
526        let w = world(vec![sig("r1", Polarity::Risk, 0.8, true, &[])], vec![]);
527        let p = StructuralSimulator.project(&w, &Goal::new("g", "x"), &hyp("h"));
528        assert_eq!(p.shadow.unaddressed_risks, vec!["r1".to_string()]);
529    }
530}