1use scema_world::{
37 Coverage, Goal, Hypothesis, Polarity, Reversibility, Signal, Term, WorldState,
38};
39use serde::{Deserialize, Serialize};
40
41pub trait Simulator {
47 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
63pub struct FailureMode {
64 pub label: String,
65 pub detail: String,
66 pub likelihood: Term,
67}
68
69#[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
85pub struct Projection {
86 pub hypothesis: String,
87 pub simulator: String,
88 pub expected_gain: Term,
90 pub risk: Term,
92 pub cost: Term,
94 pub uncertainty: Term,
96 pub reversibility: Term,
98 pub failure_modes: Vec<FailureMode>,
99 pub shadow: ShadowDelta,
100 pub forbidden_by: Option<String>,
103 pub coverage: Coverage,
105}
106
107impl Projection {
108 pub fn terms(&self) -> [&Term; 5] {
110 [&self.expected_gain, &self.risk, &self.cost, &self.uncertainty, &self.reversibility]
111 }
112}
113
114#[derive(Clone, Debug, Default)]
119pub struct StructuralSimulator;
120
121impl StructuralSimulator {
122 pub fn new() -> Self {
123 StructuralSimulator
124 }
125
126 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 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 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 let blind = (world.blind_spots.len() as f64 / 5.0).min(1.0);
233 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 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 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 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 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}