Skip to main content

axiom_truth/
simulation.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Pre-flight simulation for Converge Truths.
5//!
6//! Analyzes a Truth spec **before** execution to determine whether it has
7//! a realistic chance of converging. Catches underspecification, missing
8//! resources, and governance gaps early — no agents need to run.
9//!
10//! # Example
11//!
12//! ```ignore
13//! use axiom_truth::simulation::{simulate, SimulationConfig};
14//! use axiom_truth::truths::parse_truth_document;
15//!
16//! let doc = parse_truth_document(spec)?;
17//! let report = simulate(&doc, &SimulationConfig::default());
18//! if !report.can_converge() {
19//!     for finding in &report.findings {
20//!         eprintln!("{}: {}", finding.severity, finding.message);
21//!     }
22//! }
23//! ```
24
25use crate::gherkin::{ValidationError, extract_all_metas, preprocess_truths};
26use crate::truths::{TruthDocument, TruthGovernance};
27use sha2::{Digest, Sha256};
28use std::collections::BTreeSet;
29
30/// Configuration for simulation strictness.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct SimulationConfig {
33    /// Require Intent block with at least Outcome.
34    pub require_intent: bool,
35    /// Require Authority block with at least Actor.
36    pub require_authority: bool,
37    /// Require Evidence block with at least one Requires field.
38    pub require_evidence: bool,
39    /// Require at least one scenario with a Then step.
40    pub require_assertions: bool,
41    /// Require scenario Given steps to reference resources declared in Evidence.
42    pub check_resource_availability: bool,
43    /// Optional downstream domain profiles for domain-specific pre-flight checks.
44    pub domain_profiles: Vec<DomainProfile>,
45}
46
47impl Default for SimulationConfig {
48    fn default() -> Self {
49        Self {
50            require_intent: true,
51            require_authority: true,
52            require_evidence: true,
53            require_assertions: true,
54            check_resource_availability: true,
55            domain_profiles: Vec::new(),
56        }
57    }
58}
59
60impl SimulationConfig {
61    /// Enable one domain-specific validation profile.
62    #[must_use]
63    pub fn with_domain_profile(mut self, profile: DomainProfile) -> Self {
64        if !self.domain_profiles.contains(&profile) {
65            self.domain_profiles.push(profile);
66        }
67        self.domain_profiles = normalized_domain_profiles(&self.domain_profiles);
68        self
69    }
70}
71
72/// Optional domain-specific simulation profile.
73///
74/// Core Axiom checks remain domain-neutral. Profiles let downstream layers add
75/// richer readiness checks without baking their semantics into every run.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
77pub enum DomainProfile {
78    /// Vendor/procurement evaluation coverage checks.
79    VendorSelection,
80}
81
82/// Overall simulation verdict.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum Verdict {
85    /// The spec looks complete enough to converge.
86    Ready,
87    /// The spec has warnings but might converge.
88    Risky,
89    /// The spec is underspecified and will not converge.
90    WillNotConverge,
91}
92
93impl std::fmt::Display for Verdict {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        match self {
96            Self::Ready => write!(f, "ready"),
97            Self::Risky => write!(f, "risky"),
98            Self::WillNotConverge => write!(f, "will-not-converge"),
99        }
100    }
101}
102
103/// Severity of a simulation finding.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum FindingSeverity {
106    Info,
107    Warning,
108    Error,
109}
110
111impl std::fmt::Display for FindingSeverity {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            Self::Info => write!(f, "info"),
115            Self::Warning => write!(f, "warning"),
116            Self::Error => write!(f, "error"),
117        }
118    }
119}
120
121/// A single simulation finding.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct SimulationFinding {
124    pub severity: FindingSeverity,
125    pub category: &'static str,
126    pub message: String,
127    pub suggestion: Option<String>,
128}
129
130/// Vendor-selection-specific pre-flight coverage.
131#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct VendorSelectionCoverage {
133    /// Whether the spec appears to describe vendor/procurement evaluation.
134    pub detected: bool,
135    /// Number of distinct evaluation dimensions found (compliance, cost, risk, etc.).
136    pub evaluation_dimensions: usize,
137    /// Vendor names or references found in scenarios.
138    pub vendor_references: Vec<String>,
139    /// Whether a scoring/ranking criterion is present.
140    pub has_ranking_criterion: bool,
141    /// Whether a commitment/approval gate is present.
142    pub has_commitment_gate: bool,
143}
144
145/// Domain-specific profile coverage.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum DomainProfileCoverage {
148    VendorSelection(VendorSelectionCoverage),
149}
150
151/// Result produced by one enabled domain profile.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct DomainProfileReport {
154    pub profile: DomainProfile,
155    pub findings: Vec<SimulationFinding>,
156    pub coverage: DomainProfileCoverage,
157}
158
159/// The result of simulating a Truth spec.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct SimulationReport {
162    pub verdict: Verdict,
163    pub findings: Vec<SimulationFinding>,
164    pub governance_coverage: GovernanceCoverage,
165    pub scenario_count: usize,
166    pub resource_summary: ResourceSummary,
167    pub deterministic_trace: DeterministicTrace,
168    pub domain_profiles: Vec<DomainProfileReport>,
169}
170
171impl SimulationReport {
172    /// Whether the spec has a realistic chance of converging.
173    pub fn can_converge(&self) -> bool {
174        self.verdict != Verdict::WillNotConverge
175    }
176
177    /// Return vendor-selection coverage when that domain profile was enabled.
178    #[must_use]
179    pub fn vendor_selection(&self) -> Option<&VendorSelectionCoverage> {
180        self.domain_profiles
181            .iter()
182            .map(|report| match &report.coverage {
183                DomainProfileCoverage::VendorSelection(coverage) => coverage,
184            })
185            .next()
186    }
187}
188
189/// Which governance blocks are present and how complete they are.
190#[derive(Debug, Clone, Default, PartialEq, Eq)]
191pub struct GovernanceCoverage {
192    pub has_intent: bool,
193    pub has_outcome: bool,
194    pub has_authority: bool,
195    pub has_actor: bool,
196    pub has_approval_gate: bool,
197    pub has_constraint: bool,
198    pub has_evidence: bool,
199    pub evidence_count: usize,
200    pub has_exception: bool,
201    pub has_escalation_path: bool,
202}
203
204/// Summary of resources required vs available.
205#[derive(Debug, Clone, Default, PartialEq, Eq)]
206pub struct ResourceSummary {
207    /// Resources declared in Evidence.Requires.
208    pub declared_evidence: Vec<String>,
209    /// Resources referenced in scenario steps.
210    pub referenced_in_scenarios: Vec<String>,
211    /// Resources referenced but not declared.
212    pub missing: Vec<String>,
213}
214
215/// Canonical replay trace for deterministic simulation.
216#[derive(Debug, Clone, Default, PartialEq, Eq)]
217pub struct DeterministicTrace {
218    /// Stable SHA-256 hash of the normalized governance, config, and scenario trace.
219    pub trace_hash: String,
220    /// Number of Gherkin steps included in the trace.
221    pub step_count: usize,
222    /// Whether the trace is eligible for deterministic replay.
223    pub replayable: bool,
224    /// Steps that reference non-replayable state.
225    pub non_replayable_steps: Vec<TraceStep>,
226}
227
228/// One normalized Gherkin step in the deterministic trace.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct TraceStep {
231    pub scenario_index: usize,
232    pub step_index: usize,
233    pub keyword: String,
234    pub text: String,
235}
236
237/// Run a pre-flight simulation on a parsed Truth document.
238pub fn simulate(doc: &TruthDocument, config: &SimulationConfig) -> SimulationReport {
239    let mut findings = Vec::new();
240
241    let governance_coverage = check_governance(&doc.governance, config, &mut findings);
242    let scenario_count = check_scenarios(&doc.gherkin, config, &mut findings);
243    let resource_summary = check_resources(&doc.governance, &doc.gherkin, config, &mut findings);
244    let deterministic_trace =
245        check_determinism(&doc.governance, &doc.gherkin, config, &mut findings);
246    let domain_profiles =
247        check_domain_profiles(&doc.governance, &doc.gherkin, &config.domain_profiles);
248    for profile_report in &domain_profiles {
249        findings.extend(profile_report.findings.iter().cloned());
250    }
251
252    let has_errors = findings
253        .iter()
254        .any(|f| matches!(f.severity, FindingSeverity::Error));
255    let has_warnings = findings
256        .iter()
257        .any(|f| matches!(f.severity, FindingSeverity::Warning));
258
259    let verdict = if has_errors {
260        Verdict::WillNotConverge
261    } else if has_warnings {
262        Verdict::Risky
263    } else {
264        Verdict::Ready
265    };
266
267    let mut report = SimulationReport {
268        verdict,
269        findings,
270        governance_coverage,
271        scenario_count,
272        resource_summary,
273        deterministic_trace,
274        domain_profiles,
275    };
276    canonicalize_report(&mut report);
277    report
278}
279
280/// Parse and simulate in one step.
281pub fn simulate_spec(
282    content: &str,
283    config: &SimulationConfig,
284) -> Result<SimulationReport, ValidationError> {
285    let doc = crate::truths::parse_truth_document(content)?;
286    Ok(simulate(&doc, config))
287}
288
289fn check_governance(
290    gov: &TruthGovernance,
291    config: &SimulationConfig,
292    findings: &mut Vec<SimulationFinding>,
293) -> GovernanceCoverage {
294    let mut coverage = GovernanceCoverage::default();
295
296    // Intent
297    if let Some(intent) = &gov.intent {
298        coverage.has_intent = true;
299        coverage.has_outcome = intent.outcome.is_some();
300        if intent.outcome.is_none() {
301            findings.push(SimulationFinding {
302                severity: FindingSeverity::Warning,
303                category: "governance",
304                message: "Intent block present but missing Outcome field.".into(),
305                suggestion: Some("Add `Outcome: <what should happen>` to the Intent block.".into()),
306            });
307        }
308    } else if config.require_intent {
309        findings.push(SimulationFinding {
310            severity: FindingSeverity::Error,
311            category: "governance",
312            message: "Missing Intent block — agents have no goal to converge toward.".into(),
313            suggestion: Some("Add an Intent block with Outcome and optionally Goal.".into()),
314        });
315    }
316
317    // Authority
318    if let Some(authority) = &gov.authority {
319        coverage.has_authority = true;
320        coverage.has_actor = authority.actor.is_some();
321        coverage.has_approval_gate = !authority.requires_approval.is_empty();
322        if authority.actor.is_none() {
323            findings.push(SimulationFinding {
324                severity: FindingSeverity::Warning,
325                category: "governance",
326                message: "Authority block present but missing Actor field.".into(),
327                suggestion: Some("Add `Actor: <who can approve>` to the Authority block.".into()),
328            });
329        }
330    } else if config.require_authority {
331        findings.push(SimulationFinding {
332            severity: FindingSeverity::Error,
333            category: "governance",
334            message: "Missing Authority block — no one is authorized to promote decisions.".into(),
335            suggestion: Some(
336                "Add an Authority block with Actor and optionally Requires Approval.".into(),
337            ),
338        });
339    }
340
341    // Constraint
342    if let Some(constraint) = &gov.constraint {
343        coverage.has_constraint = true;
344        if constraint.budget.is_empty()
345            && constraint.cost_limit.is_empty()
346            && constraint.must_not.is_empty()
347        {
348            findings.push(SimulationFinding {
349                severity: FindingSeverity::Info,
350                category: "governance",
351                message: "Constraint block is empty — agents have no guardrails.".into(),
352                suggestion: None,
353            });
354        }
355    }
356
357    // Evidence
358    if let Some(evidence) = &gov.evidence {
359        coverage.has_evidence = true;
360        coverage.evidence_count = evidence.requires.len();
361        if evidence.requires.is_empty() {
362            findings.push(SimulationFinding {
363                severity: FindingSeverity::Warning,
364                category: "governance",
365                message: "Evidence block present but no Requires fields — nothing to audit.".into(),
366                suggestion: Some("Add `Requires: <evidence_name>` fields.".into()),
367            });
368        }
369        if evidence.audit.is_empty() {
370            findings.push(SimulationFinding {
371                severity: FindingSeverity::Info,
372                category: "governance",
373                message: "No Audit field in Evidence — decision trail may be incomplete.".into(),
374                suggestion: Some("Add `Audit: <log_name>` for traceability.".into()),
375            });
376        }
377    } else if config.require_evidence {
378        findings.push(SimulationFinding {
379            severity: FindingSeverity::Error,
380            category: "governance",
381            message: "Missing Evidence block — no proof requirements declared.".into(),
382            suggestion: Some("Add an Evidence block with Requires and Audit fields.".into()),
383        });
384    }
385
386    // Exception
387    if let Some(exception) = &gov.exception {
388        coverage.has_exception = true;
389        coverage.has_escalation_path = !exception.escalates_to.is_empty();
390    }
391
392    // Cross-block coherence
393    if coverage.has_approval_gate && !coverage.has_evidence {
394        findings.push(SimulationFinding {
395            severity: FindingSeverity::Warning,
396            category: "coherence",
397            message:
398                "Authority requires approval but no Evidence block — approver has nothing to review."
399                    .into(),
400            suggestion: Some(
401                "Add Evidence.Requires fields so the approver has artifacts to evaluate.".into(),
402            ),
403        });
404    }
405
406    if coverage.has_constraint && !coverage.has_authority {
407        findings.push(SimulationFinding {
408            severity: FindingSeverity::Warning,
409            category: "coherence",
410            message: "Constraints declared but no Authority — who enforces the limits?".into(),
411            suggestion: Some("Add an Authority block with an Actor.".into()),
412        });
413    }
414
415    coverage
416}
417
418fn check_scenarios(
419    gherkin: &str,
420    config: &SimulationConfig,
421    findings: &mut Vec<SimulationFinding>,
422) -> usize {
423    let preprocessed = preprocess_truths(gherkin);
424    let metas = extract_all_metas(&preprocessed).unwrap_or_default();
425
426    if metas.is_empty() {
427        findings.push(SimulationFinding {
428            severity: FindingSeverity::Error,
429            category: "scenario",
430            message: "No scenarios found — nothing to execute.".into(),
431            suggestion: Some("Add at least one Scenario with Given/When/Then steps.".into()),
432        });
433        return 0;
434    }
435
436    // Check for Then steps (assertions)
437    if config.require_assertions {
438        let has_then = gherkin.lines().any(|line| line.trim().starts_with("Then "));
439        if !has_then {
440            findings.push(SimulationFinding {
441                severity: FindingSeverity::Error,
442                category: "scenario",
443                message: "No Then steps found — scenarios have no success criteria.".into(),
444                suggestion: Some("Add Then steps that assert expected outcomes.".into()),
445            });
446        }
447    }
448
449    // Check for Given steps (preconditions)
450    let has_given = gherkin
451        .lines()
452        .any(|line| line.trim().starts_with("Given "));
453    if !has_given {
454        findings.push(SimulationFinding {
455            severity: FindingSeverity::Warning,
456            category: "scenario",
457            message: "No Given steps — scenarios have no declared preconditions.".into(),
458            suggestion: Some("Add Given steps that establish the initial state.".into()),
459        });
460    }
461
462    // Check for When steps (actions)
463    let has_when = gherkin.lines().any(|line| line.trim().starts_with("When "));
464    if !has_when {
465        findings.push(SimulationFinding {
466            severity: FindingSeverity::Warning,
467            category: "scenario",
468            message: "No When steps — scenarios have no triggering action.".into(),
469            suggestion: Some("Add When steps that describe the action being governed.".into()),
470        });
471    }
472
473    metas.len()
474}
475
476fn check_resources(
477    gov: &TruthGovernance,
478    gherkin: &str,
479    config: &SimulationConfig,
480    findings: &mut Vec<SimulationFinding>,
481) -> ResourceSummary {
482    let mut summary = ResourceSummary::default();
483
484    // Collect declared evidence resources
485    if let Some(evidence) = &gov.evidence {
486        summary.declared_evidence.clone_from(&evidence.requires);
487    }
488
489    // Extract resource references from scenario steps
490    let resource_pattern = regex::Regex::new(r"[a-z][a-z0-9_]*(?:_[a-z0-9]+)+").ok();
491    let mut referenced = BTreeSet::new();
492
493    if let Some(pattern) = &resource_pattern {
494        for line in gherkin.lines() {
495            let trimmed = line.trim();
496            if trimmed.starts_with("Given ")
497                || trimmed.starts_with("When ")
498                || trimmed.starts_with("Then ")
499                || trimmed.starts_with("And ")
500            {
501                for m in pattern.find_iter(trimmed) {
502                    referenced.insert(m.as_str().to_string());
503                }
504            }
505        }
506    }
507    summary.referenced_in_scenarios = referenced.into_iter().collect();
508
509    // Find references that match evidence naming patterns but aren't declared
510    if config.check_resource_availability && !summary.declared_evidence.is_empty() {
511        for referenced in &summary.referenced_in_scenarios {
512            let looks_like_evidence = referenced.ends_with("_assessment")
513                || referenced.ends_with("_analysis")
514                || referenced.ends_with("_report")
515                || referenced.ends_with("_review")
516                || referenced.ends_with("_log")
517                || referenced.ends_with("_record")
518                || referenced.ends_with("_bundle");
519
520            if looks_like_evidence && !summary.declared_evidence.contains(referenced) {
521                summary.missing.push(referenced.clone());
522            }
523        }
524
525        if !summary.missing.is_empty() {
526            findings.push(SimulationFinding {
527                severity: FindingSeverity::Warning,
528                category: "resources",
529                message: format!(
530                    "Scenario references evidence-like resources not declared in Evidence block: {}",
531                    summary.missing.join(", ")
532                ),
533                suggestion: Some(
534                    "Add these as `Requires:` fields in the Evidence block, or rename to avoid evidence naming patterns.".into(),
535                ),
536            });
537        }
538    }
539
540    // Check if authority actors are referenced in scenarios
541    if let Some(authority) = &gov.authority
542        && let Some(actor) = &authority.actor
543    {
544        let actor_referenced = gherkin.contains(actor);
545        if !actor_referenced {
546            findings.push(SimulationFinding {
547                severity: FindingSeverity::Info,
548                category: "resources",
549                message: format!(
550                    "Authority actor `{actor}` is declared but not referenced in any scenario."
551                ),
552                suggestion: Some(
553                    "Consider adding a scenario step that involves the authorized actor.".into(),
554                ),
555            });
556        }
557    }
558
559    summary
560}
561
562fn check_determinism(
563    gov: &TruthGovernance,
564    gherkin: &str,
565    config: &SimulationConfig,
566    findings: &mut Vec<SimulationFinding>,
567) -> DeterministicTrace {
568    let steps = extract_trace_steps(gherkin);
569    let non_replayable_steps: Vec<TraceStep> = steps
570        .iter()
571        .filter(|step| contains_non_replayable_reference(&step.text))
572        .cloned()
573        .collect();
574
575    if !non_replayable_steps.is_empty() {
576        let labels = non_replayable_steps
577            .iter()
578            .map(|step| format!("scenario {} step {}", step.scenario_index, step.step_index))
579            .collect::<Vec<_>>()
580            .join(", ");
581        findings.push(SimulationFinding {
582            severity: FindingSeverity::Warning,
583            category: "determinism",
584            message: format!("Scenario language references non-replayable runtime state: {labels}."),
585            suggestion: Some(
586                "Use declared Evidence fields for snapshots, or rewrite the step to avoid current time, randomness, latest external state, or live network calls.".into(),
587            ),
588        });
589    }
590
591    let mut canonical = String::new();
592    canonical.push_str("simulation-trace-v1\n");
593    canonical.push_str(&canonical_config(config));
594    canonical.push('\n');
595    canonical.push_str(&canonical_governance(gov));
596    canonical.push('\n');
597    for step in &steps {
598        canonical.push_str(&format!(
599            "{}:{}:{}:{}\n",
600            step.scenario_index, step.step_index, step.keyword, step.text
601        ));
602    }
603
604    DeterministicTrace {
605        trace_hash: deterministic_hash(canonical.as_bytes()),
606        step_count: steps.len(),
607        replayable: non_replayable_steps.is_empty(),
608        non_replayable_steps,
609    }
610}
611
612fn extract_trace_steps(gherkin: &str) -> Vec<TraceStep> {
613    let mut steps = Vec::new();
614    let mut scenario_index = 0;
615    let mut step_index = 0;
616
617    for line in gherkin.lines() {
618        let trimmed = line.trim();
619        if starts_scenario(trimmed) {
620            scenario_index += 1;
621            step_index = 0;
622            continue;
623        }
624
625        let Some((keyword, text)) = parse_step_line(trimmed) else {
626            continue;
627        };
628
629        if scenario_index == 0 {
630            scenario_index = 1;
631        }
632        step_index += 1;
633        steps.push(TraceStep {
634            scenario_index,
635            step_index,
636            keyword: keyword.to_string(),
637            text: normalize_whitespace(text),
638        });
639    }
640
641    steps
642}
643
644fn starts_scenario(trimmed: &str) -> bool {
645    trimmed.starts_with("Scenario:")
646        || trimmed.starts_with("Scenario Outline:")
647        || trimmed.starts_with("Example:")
648}
649
650fn parse_step_line(trimmed: &str) -> Option<(&'static str, &str)> {
651    for keyword in ["Given", "When", "Then", "And", "But"] {
652        if let Some(text) = trimmed
653            .strip_prefix(keyword)
654            .and_then(|rest| rest.strip_prefix(' '))
655        {
656            return Some((keyword, text));
657        }
658    }
659    None
660}
661
662fn normalize_whitespace(value: &str) -> String {
663    value.split_whitespace().collect::<Vec<_>>().join(" ")
664}
665
666fn contains_non_replayable_reference(text: &str) -> bool {
667    let lower = text.to_ascii_lowercase();
668    lower.contains("current time")
669        || lower.contains("current date")
670        || lower.contains("external api")
671        || lower.contains("remote api")
672        || lower.contains("live api")
673        || lower.contains("live service")
674        || lower.contains("network call")
675        || lower.contains("webhook")
676        || lower.contains("real-time")
677        || lower.contains("realtime")
678        || contains_word(&lower, "today")
679        || contains_word(&lower, "now")
680        || contains_word(&lower, "latest")
681        || contains_word(&lower, "random")
682}
683
684fn contains_word(text: &str, word: &str) -> bool {
685    text.split(|ch: char| !ch.is_ascii_alphanumeric())
686        .any(|part| part == word)
687}
688
689fn check_domain_profiles(
690    gov: &TruthGovernance,
691    gherkin: &str,
692    profiles: &[DomainProfile],
693) -> Vec<DomainProfileReport> {
694    normalized_domain_profiles(profiles)
695        .into_iter()
696        .map(|profile| match profile {
697            DomainProfile::VendorSelection => {
698                let mut findings = Vec::new();
699                let coverage = check_vendor_selection(gov, gherkin, &mut findings);
700                DomainProfileReport {
701                    profile,
702                    findings,
703                    coverage: DomainProfileCoverage::VendorSelection(coverage),
704                }
705            }
706        })
707        .collect()
708}
709
710fn normalized_domain_profiles(profiles: &[DomainProfile]) -> Vec<DomainProfile> {
711    let mut profiles = profiles.to_vec();
712    profiles.sort_unstable();
713    profiles.dedup();
714    profiles
715}
716
717fn canonical_config(config: &SimulationConfig) -> String {
718    let profiles = normalized_domain_profiles(&config.domain_profiles)
719        .iter()
720        .map(|profile| match profile {
721            DomainProfile::VendorSelection => "vendor-selection",
722        })
723        .collect::<Vec<_>>()
724        .join(",");
725
726    format!(
727        "require_intent={};require_authority={};require_evidence={};require_assertions={};check_resource_availability={};domain_profiles={profiles}",
728        config.require_intent,
729        config.require_authority,
730        config.require_evidence,
731        config.require_assertions,
732        config.check_resource_availability
733    )
734}
735
736fn canonical_governance(gov: &TruthGovernance) -> String {
737    let mut lines = Vec::new();
738
739    if let Some(intent) = &gov.intent {
740        push_optional(&mut lines, "intent.outcome", intent.outcome.as_deref());
741        push_optional(&mut lines, "intent.goal", intent.goal.as_deref());
742    }
743    if let Some(authority) = &gov.authority {
744        push_optional(&mut lines, "authority.actor", authority.actor.as_deref());
745        push_values(&mut lines, "authority.may", &authority.may);
746        push_values(&mut lines, "authority.must_not", &authority.must_not);
747        push_values(
748            &mut lines,
749            "authority.requires_approval",
750            &authority.requires_approval,
751        );
752        push_optional(
753            &mut lines,
754            "authority.expires",
755            authority.expires.as_deref(),
756        );
757    }
758    if let Some(constraint) = &gov.constraint {
759        push_values(&mut lines, "constraint.budget", &constraint.budget);
760        push_values(&mut lines, "constraint.cost_limit", &constraint.cost_limit);
761        push_values(&mut lines, "constraint.must_not", &constraint.must_not);
762    }
763    if let Some(evidence) = &gov.evidence {
764        push_values(&mut lines, "evidence.requires", &evidence.requires);
765        push_values(&mut lines, "evidence.provenance", &evidence.provenance);
766        push_values(&mut lines, "evidence.audit", &evidence.audit);
767    }
768    if let Some(exception) = &gov.exception {
769        push_values(
770            &mut lines,
771            "exception.escalates_to",
772            &exception.escalates_to,
773        );
774        push_values(&mut lines, "exception.requires", &exception.requires);
775    }
776
777    lines.sort();
778    lines.join("\n")
779}
780
781fn push_optional(lines: &mut Vec<String>, key: &str, value: Option<&str>) {
782    if let Some(value) = value {
783        lines.push(format!("{key}={}", normalize_whitespace(value)));
784    }
785}
786
787fn push_values(lines: &mut Vec<String>, key: &str, values: &[String]) {
788    for value in sorted_unique(values) {
789        lines.push(format!("{key}={}", normalize_whitespace(&value)));
790    }
791}
792
793fn sorted_unique(values: &[String]) -> Vec<String> {
794    values
795        .iter()
796        .map(|value| normalize_whitespace(value))
797        .collect::<BTreeSet<_>>()
798        .into_iter()
799        .collect()
800}
801
802fn deterministic_hash(bytes: &[u8]) -> String {
803    const HEX: &[u8; 16] = b"0123456789abcdef";
804
805    let hash = Sha256::digest(bytes);
806    let hash_bytes: &[u8] = hash.as_ref();
807    let mut encoded = String::with_capacity("sha256:".len() + (hash_bytes.len() * 2));
808    encoded.push_str("sha256:");
809
810    for &byte in hash_bytes {
811        encoded.push(char::from(HEX[usize::from(byte >> 4)]));
812        encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
813    }
814
815    encoded
816}
817
818fn canonicalize_report(report: &mut SimulationReport) {
819    canonicalize_findings(&mut report.findings);
820    canonicalize_strings(&mut report.resource_summary.declared_evidence);
821    canonicalize_strings(&mut report.resource_summary.referenced_in_scenarios);
822    canonicalize_strings(&mut report.resource_summary.missing);
823    report.domain_profiles.sort_by_key(|domain| domain.profile);
824
825    for domain in &mut report.domain_profiles {
826        canonicalize_findings(&mut domain.findings);
827        match &mut domain.coverage {
828            DomainProfileCoverage::VendorSelection(coverage) => {
829                canonicalize_strings(&mut coverage.vendor_references);
830            }
831        }
832    }
833
834    report
835        .deterministic_trace
836        .non_replayable_steps
837        .sort_by(|left, right| {
838            left.scenario_index
839                .cmp(&right.scenario_index)
840                .then_with(|| left.step_index.cmp(&right.step_index))
841                .then_with(|| left.keyword.cmp(&right.keyword))
842                .then_with(|| left.text.cmp(&right.text))
843        });
844}
845
846fn canonicalize_strings(values: &mut Vec<String>) {
847    *values = values
848        .iter()
849        .map(|value| normalize_whitespace(value))
850        .collect::<BTreeSet<_>>()
851        .into_iter()
852        .collect();
853}
854
855fn canonicalize_findings(findings: &mut Vec<SimulationFinding>) {
856    findings.sort_by(|left, right| {
857        severity_rank(left.severity)
858            .cmp(&severity_rank(right.severity))
859            .then_with(|| left.category.cmp(right.category))
860            .then_with(|| left.message.cmp(&right.message))
861            .then_with(|| left.suggestion.cmp(&right.suggestion))
862    });
863    findings.dedup_by(|left, right| {
864        left.severity == right.severity
865            && left.category == right.category
866            && left.message == right.message
867            && left.suggestion == right.suggestion
868    });
869}
870
871fn severity_rank(severity: FindingSeverity) -> u8 {
872    match severity {
873        FindingSeverity::Error => 0,
874        FindingSeverity::Warning => 1,
875        FindingSeverity::Info => 2,
876    }
877}
878
879const VENDOR_KEYWORDS: &[&str] = &[
880    "vendor",
881    "procurement",
882    "supplier",
883    "rfp",
884    "shortlist",
885    "sourcing",
886];
887
888const EVALUATION_DIMENSIONS: &[&str] = &[
889    "compliance",
890    "cost",
891    "risk",
892    "security",
893    "capability",
894    "stability",
895    "performance",
896    "pricing",
897    "budget",
898    "certification",
899    "regulatory",
900    "timeline",
901    "delivery",
902];
903
904fn check_vendor_selection(
905    gov: &TruthGovernance,
906    gherkin: &str,
907    findings: &mut Vec<SimulationFinding>,
908) -> VendorSelectionCoverage {
909    let mut coverage = VendorSelectionCoverage::default();
910
911    let combined = format!(
912        "{} {}",
913        gov.intent
914            .as_ref()
915            .and_then(|i| i.outcome.as_deref())
916            .unwrap_or(""),
917        gherkin
918    )
919    .to_lowercase();
920
921    coverage.detected = VENDOR_KEYWORDS.iter().any(|kw| combined.contains(kw));
922    if !coverage.detected {
923        return coverage;
924    }
925
926    // Count evaluation dimensions mentioned
927    let dimensions: Vec<&str> = EVALUATION_DIMENSIONS
928        .iter()
929        .copied()
930        .filter(|d| combined.contains(d))
931        .collect();
932    coverage.evaluation_dimensions = dimensions.len();
933
934    if coverage.evaluation_dimensions < 3 {
935        findings.push(SimulationFinding {
936            severity: FindingSeverity::Warning,
937            category: "vendor-selection",
938            message: format!(
939                "Vendor selection spec mentions only {} evaluation dimension(s): {}. \
940                 At least 3 are recommended for meaningful differentiation.",
941                coverage.evaluation_dimensions,
942                if dimensions.is_empty() {
943                    "none".to_string()
944                } else {
945                    dimensions.join(", ")
946                }
947            ),
948            suggestion: Some(
949                "Add evaluation criteria such as compliance, cost, risk, security, capability."
950                    .into(),
951            ),
952        });
953    }
954
955    // Extract vendor references from Given steps
956    let vendor_pattern = regex::Regex::new(r#"(?i)(?:vendors?|suppliers?)\s+"([^"]+)""#).ok();
957    if let Some(pat) = &vendor_pattern {
958        for cap in pat.captures_iter(gherkin) {
959            if let Some(names) = cap.get(1) {
960                for name in names.as_str().split(',') {
961                    let trimmed = name.trim().to_string();
962                    if !trimmed.is_empty() && !coverage.vendor_references.contains(&trimmed) {
963                        coverage.vendor_references.push(trimmed);
964                    }
965                }
966            }
967        }
968    }
969
970    if coverage.vendor_references.len() < 3 {
971        findings.push(SimulationFinding {
972            severity: FindingSeverity::Info,
973            category: "vendor-selection",
974            message: format!(
975                "Only {} vendor(s) referenced in scenarios. \
976                 3+ vendors recommended for meaningful comparison.",
977                coverage.vendor_references.len()
978            ),
979            suggestion: Some(
980                "Add more vendors in Given steps: Given vendors \"Acme, Beta, Gamma\"".into(),
981            ),
982        });
983    }
984
985    // Check for ranking/shortlist criteria in scenario steps only
986    let scenario_text: String = gherkin
987        .lines()
988        .filter(|l| {
989            let t = l.trim();
990            t.starts_with("Then ") || t.starts_with("And ")
991        })
992        .collect::<Vec<_>>()
993        .join(" ")
994        .to_lowercase();
995
996    coverage.has_ranking_criterion = scenario_text.contains("rank")
997        || scenario_text.contains("shortlist")
998        || scenario_text.contains("scored")
999        || scenario_text.contains("recommendation");
1000
1001    if !coverage.has_ranking_criterion {
1002        findings.push(SimulationFinding {
1003            severity: FindingSeverity::Warning,
1004            category: "vendor-selection",
1005            message: "No ranking or shortlisting criterion detected.".into(),
1006            suggestion: Some(
1007                "Add a Then step asserting a ranked shortlist or recommendation is produced."
1008                    .into(),
1009            ),
1010        });
1011    }
1012
1013    // Check for commitment/approval gate
1014    coverage.has_commitment_gate = gov
1015        .authority
1016        .as_ref()
1017        .is_some_and(|a| !a.requires_approval.is_empty());
1018
1019    if !coverage.has_commitment_gate {
1020        findings.push(SimulationFinding {
1021            severity: FindingSeverity::Warning,
1022            category: "vendor-selection",
1023            message: "No commitment approval gate found. Vendor selections with financial \
1024                      impact should require human approval."
1025                .into(),
1026            suggestion: Some(
1027                "Add `Requires Approval: vendor_commitment` to the Authority block.".into(),
1028            ),
1029        });
1030    }
1031
1032    coverage
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037    use super::*;
1038    use crate::truths::{
1039        AuthorityBlock, ConstraintBlock, EvidenceBlock, ExceptionBlock, IntentBlock,
1040        parse_truth_document,
1041    };
1042
1043    fn full_spec() -> &'static str {
1044        r#"Truth: Vendor selection is governed
1045
1046Intent:
1047  Outcome: Select a vendor with auditable rationale.
1048  Goal: Evaluate candidates on cost, compliance, and risk.
1049
1050Authority:
1051  Actor: governance_review_board
1052  Requires Approval: final_vendor_selection
1053
1054Constraint:
1055  Cost Limit: first-year spend must stay within budget.
1056
1057Evidence:
1058  Requires: security_assessment
1059  Requires: pricing_analysis
1060  Audit: decision_log
1061
1062Scenario: Vendors produce traceable outcomes
1063  Given candidate vendors "Acme AI, Beta ML, Gamma LLM"
1064  And each vendor has a security_assessment and pricing_analysis
1065  When the governance_review_board evaluates each vendor
1066  Then each vendor should produce a compliance screening result
1067  And a ranked shortlist is produced
1068"#
1069    }
1070
1071    fn minimal_valid_spec() -> &'static str {
1072        r"Truth: Minimal
1073
1074Intent:
1075  Outcome: Works.
1076
1077Authority:
1078  Actor: admin
1079
1080Evidence:
1081  Requires: proof
1082
1083Scenario: It works
1084  Given something exists
1085  When validated
1086  Then it passes
1087"
1088    }
1089
1090    fn vendor_config() -> SimulationConfig {
1091        SimulationConfig::default().with_domain_profile(DomainProfile::VendorSelection)
1092    }
1093
1094    fn vendor_selection(report: &SimulationReport) -> &VendorSelectionCoverage {
1095        report
1096            .vendor_selection()
1097            .expect("vendor selection profile should be enabled")
1098    }
1099
1100    // ─── Verdict display ───
1101
1102    #[test]
1103    fn verdict_display() {
1104        assert_eq!(Verdict::Ready.to_string(), "ready");
1105        assert_eq!(Verdict::Risky.to_string(), "risky");
1106        assert_eq!(Verdict::WillNotConverge.to_string(), "will-not-converge");
1107    }
1108
1109    #[test]
1110    fn finding_severity_display() {
1111        assert_eq!(FindingSeverity::Info.to_string(), "info");
1112        assert_eq!(FindingSeverity::Warning.to_string(), "warning");
1113        assert_eq!(FindingSeverity::Error.to_string(), "error");
1114    }
1115
1116    // ─── SimulationReport::can_converge ───
1117
1118    #[test]
1119    fn can_converge_ready() {
1120        let report = SimulationReport {
1121            verdict: Verdict::Ready,
1122            findings: vec![],
1123            governance_coverage: GovernanceCoverage::default(),
1124            scenario_count: 1,
1125            resource_summary: ResourceSummary::default(),
1126            deterministic_trace: DeterministicTrace::default(),
1127            domain_profiles: vec![],
1128        };
1129        assert!(report.can_converge());
1130    }
1131
1132    #[test]
1133    fn can_converge_risky() {
1134        let report = SimulationReport {
1135            verdict: Verdict::Risky,
1136            findings: vec![],
1137            governance_coverage: GovernanceCoverage::default(),
1138            scenario_count: 1,
1139            resource_summary: ResourceSummary::default(),
1140            deterministic_trace: DeterministicTrace::default(),
1141            domain_profiles: vec![],
1142        };
1143        assert!(report.can_converge());
1144    }
1145
1146    #[test]
1147    fn cannot_converge_will_not() {
1148        let report = SimulationReport {
1149            verdict: Verdict::WillNotConverge,
1150            findings: vec![],
1151            governance_coverage: GovernanceCoverage::default(),
1152            scenario_count: 0,
1153            resource_summary: ResourceSummary::default(),
1154            deterministic_trace: DeterministicTrace::default(),
1155            domain_profiles: vec![],
1156        };
1157        assert!(!report.can_converge());
1158    }
1159
1160    // ─── SimulationFinding construction ───
1161
1162    #[test]
1163    fn finding_with_suggestion() {
1164        let f = SimulationFinding {
1165            severity: FindingSeverity::Warning,
1166            category: "test",
1167            message: "something is off".into(),
1168            suggestion: Some("fix it".into()),
1169        };
1170        assert_eq!(f.severity, FindingSeverity::Warning);
1171        assert_eq!(f.category, "test");
1172        assert!(f.suggestion.is_some());
1173    }
1174
1175    #[test]
1176    fn finding_without_suggestion() {
1177        let f = SimulationFinding {
1178            severity: FindingSeverity::Info,
1179            category: "test",
1180            message: "just info".into(),
1181            suggestion: None,
1182        };
1183        assert!(f.suggestion.is_none());
1184    }
1185
1186    // ─── simulate: complete spec ───
1187
1188    #[test]
1189    fn complete_spec_is_ready() {
1190        let doc = parse_truth_document(full_spec()).unwrap();
1191        let report = simulate(&doc, &SimulationConfig::default());
1192        assert_eq!(report.verdict, Verdict::Ready);
1193        assert!(report.can_converge());
1194    }
1195
1196    #[test]
1197    fn complete_spec_governance_coverage() {
1198        let doc = parse_truth_document(full_spec()).unwrap();
1199        let report = simulate(&doc, &SimulationConfig::default());
1200        assert!(report.governance_coverage.has_intent);
1201        assert!(report.governance_coverage.has_outcome);
1202        assert!(report.governance_coverage.has_authority);
1203        assert!(report.governance_coverage.has_actor);
1204        assert!(report.governance_coverage.has_constraint);
1205        assert!(report.governance_coverage.has_evidence);
1206        assert_eq!(report.governance_coverage.evidence_count, 2);
1207    }
1208
1209    #[test]
1210    fn complete_spec_scenario_count() {
1211        let doc = parse_truth_document(full_spec()).unwrap();
1212        let report = simulate(&doc, &SimulationConfig::default());
1213        assert_eq!(report.scenario_count, 1);
1214    }
1215
1216    #[test]
1217    fn complete_spec_resource_summary() {
1218        let doc = parse_truth_document(full_spec()).unwrap();
1219        let report = simulate(&doc, &SimulationConfig::default());
1220        assert_eq!(report.resource_summary.declared_evidence.len(), 2);
1221        assert!(report.resource_summary.missing.is_empty());
1222    }
1223
1224    // ─── simulate: missing governance blocks ───
1225
1226    #[test]
1227    fn missing_intent_will_not_converge() {
1228        let content = r"Truth: No intent
1229
1230Scenario: Something happens
1231  Given a precondition
1232  When an action occurs
1233  Then a result is observed
1234";
1235        let doc = parse_truth_document(content).unwrap();
1236        let report = simulate(&doc, &SimulationConfig::default());
1237        assert_eq!(report.verdict, Verdict::WillNotConverge);
1238        assert!(!report.can_converge());
1239        assert!(
1240            report
1241                .findings
1242                .iter()
1243                .any(|f| f.message.contains("Missing Intent"))
1244        );
1245    }
1246
1247    #[test]
1248    fn missing_authority_will_not_converge() {
1249        let content = r"Truth: No authority
1250
1251Intent:
1252  Outcome: Do a thing.
1253
1254Evidence:
1255  Requires: proof
1256
1257Scenario: Action
1258  Given precondition
1259  When something happens
1260  Then outcome
1261";
1262        let doc = parse_truth_document(content).unwrap();
1263        let report = simulate(&doc, &SimulationConfig::default());
1264        assert_eq!(report.verdict, Verdict::WillNotConverge);
1265        assert!(
1266            report
1267                .findings
1268                .iter()
1269                .any(|f| f.message.contains("Missing Authority"))
1270        );
1271    }
1272
1273    #[test]
1274    fn missing_evidence_will_not_converge() {
1275        let content = r"Truth: No evidence
1276
1277Intent:
1278  Outcome: Do a thing.
1279
1280Authority:
1281  Actor: admin
1282
1283Scenario: Action
1284  Given precondition
1285  When something happens
1286  Then outcome
1287";
1288        let doc = parse_truth_document(content).unwrap();
1289        let report = simulate(&doc, &SimulationConfig::default());
1290        assert_eq!(report.verdict, Verdict::WillNotConverge);
1291        assert!(
1292            report
1293                .findings
1294                .iter()
1295                .any(|f| f.message.contains("Missing Evidence"))
1296        );
1297    }
1298
1299    // ─── simulate: scenario issues ───
1300
1301    #[test]
1302    fn missing_then_steps_will_not_converge() {
1303        let content = r"Truth: No assertions
1304
1305Intent:
1306  Outcome: Do something.
1307
1308Authority:
1309  Actor: admin
1310
1311Evidence:
1312  Requires: report
1313
1314Scenario: Missing outcome
1315  Given a shortlist of vendors
1316  When the workflow ranks them
1317";
1318        let doc = parse_truth_document(content).unwrap();
1319        let report = simulate(&doc, &SimulationConfig::default());
1320        assert_eq!(report.verdict, Verdict::WillNotConverge);
1321        assert!(
1322            report
1323                .findings
1324                .iter()
1325                .any(|f| f.message.contains("No Then steps"))
1326        );
1327    }
1328
1329    #[test]
1330    fn missing_given_steps_produces_warning() {
1331        let content = r"Truth: No given
1332
1333Intent:
1334  Outcome: Do something.
1335
1336Authority:
1337  Actor: admin
1338
1339Evidence:
1340  Requires: report
1341
1342Scenario: No preconditions
1343  When something happens
1344  Then it works
1345";
1346        let doc = parse_truth_document(content).unwrap();
1347        let report = simulate(&doc, &SimulationConfig::default());
1348        assert!(
1349            report
1350                .findings
1351                .iter()
1352                .any(|f| f.message.contains("No Given steps"))
1353        );
1354    }
1355
1356    #[test]
1357    fn missing_when_steps_produces_warning() {
1358        let content = r"Truth: No when
1359
1360Intent:
1361  Outcome: Do something.
1362
1363Authority:
1364  Actor: admin
1365
1366Evidence:
1367  Requires: report
1368
1369Scenario: No action
1370  Given a state
1371  Then it is fine
1372";
1373        let doc = parse_truth_document(content).unwrap();
1374        let report = simulate(&doc, &SimulationConfig::default());
1375        assert!(
1376            report
1377                .findings
1378                .iter()
1379                .any(|f| f.message.contains("No When steps"))
1380        );
1381    }
1382
1383    #[test]
1384    fn no_scenarios_will_not_converge() {
1385        let content = r"Truth: Empty
1386
1387Intent:
1388  Outcome: Nothing to do.
1389
1390Authority:
1391  Actor: admin
1392
1393Evidence:
1394  Requires: proof
1395";
1396        let doc = parse_truth_document(content).unwrap();
1397        let report = simulate(&doc, &SimulationConfig::default());
1398        assert_eq!(report.verdict, Verdict::WillNotConverge);
1399        assert!(
1400            report
1401                .findings
1402                .iter()
1403                .any(|f| f.message.contains("No scenarios found"))
1404        );
1405        assert_eq!(report.scenario_count, 0);
1406    }
1407
1408    // ─── simulate: coherence checks ───
1409
1410    #[test]
1411    fn approval_without_evidence_is_risky() {
1412        let content = r"Truth: Approval gate without evidence
1413
1414Intent:
1415  Outcome: Approve a vendor.
1416
1417Authority:
1418  Actor: board
1419  Requires Approval: cfo_sign_off
1420
1421Scenario: Approval happens
1422  Given a vendor is shortlisted
1423  When the board reviews
1424  Then the vendor is approved
1425";
1426        let doc = parse_truth_document(content).unwrap();
1427        let report = simulate(&doc, &SimulationConfig::default());
1428        assert_eq!(report.verdict, Verdict::WillNotConverge);
1429        assert!(
1430            report
1431                .findings
1432                .iter()
1433                .any(|f| f.message.contains("approver has nothing to review"))
1434        );
1435    }
1436
1437    #[test]
1438    fn constraint_without_authority_warns() {
1439        let doc = TruthDocument {
1440            governance: TruthGovernance {
1441                intent: Some(IntentBlock {
1442                    outcome: Some("Do it".into()),
1443                    goal: None,
1444                }),
1445                authority: None,
1446                constraint: Some(ConstraintBlock {
1447                    budget: vec!["100k".into()],
1448                    cost_limit: vec![],
1449                    must_not: vec![],
1450                }),
1451                evidence: Some(EvidenceBlock {
1452                    requires: vec!["proof".into()],
1453                    provenance: vec![],
1454                    audit: vec!["log".into()],
1455                }),
1456                exception: None,
1457            },
1458            gherkin: "Scenario: Test\n  Given a state\n  When action\n  Then result".into(),
1459        };
1460        let config = SimulationConfig {
1461            require_authority: false,
1462            ..SimulationConfig::default()
1463        };
1464        let report = simulate(&doc, &config);
1465        assert!(
1466            report
1467                .findings
1468                .iter()
1469                .any(|f| f.message.contains("who enforces"))
1470        );
1471    }
1472
1473    // ─── simulate: governance detail checks ───
1474
1475    #[test]
1476    fn intent_without_outcome_warns() {
1477        let doc = TruthDocument {
1478            governance: TruthGovernance {
1479                intent: Some(IntentBlock {
1480                    outcome: None,
1481                    goal: Some("A goal".into()),
1482                }),
1483                authority: Some(AuthorityBlock {
1484                    actor: Some("admin".into()),
1485                    ..AuthorityBlock::default()
1486                }),
1487                constraint: None,
1488                evidence: Some(EvidenceBlock {
1489                    requires: vec!["proof".into()],
1490                    provenance: vec![],
1491                    audit: vec!["log".into()],
1492                }),
1493                exception: None,
1494            },
1495            gherkin: "Scenario: Test\n  Given state\n  When admin acts\n  Then done".into(),
1496        };
1497        let report = simulate(&doc, &SimulationConfig::default());
1498        assert!(
1499            report
1500                .findings
1501                .iter()
1502                .any(|f| f.message.contains("missing Outcome"))
1503        );
1504    }
1505
1506    #[test]
1507    fn authority_without_actor_warns() {
1508        let doc = TruthDocument {
1509            governance: TruthGovernance {
1510                intent: Some(IntentBlock {
1511                    outcome: Some("Do it".into()),
1512                    goal: None,
1513                }),
1514                authority: Some(AuthorityBlock::default()),
1515                constraint: None,
1516                evidence: Some(EvidenceBlock {
1517                    requires: vec!["proof".into()],
1518                    provenance: vec![],
1519                    audit: vec!["log".into()],
1520                }),
1521                exception: None,
1522            },
1523            gherkin: "Scenario: Test\n  Given state\n  When action\n  Then done".into(),
1524        };
1525        let report = simulate(&doc, &SimulationConfig::default());
1526        assert!(
1527            report
1528                .findings
1529                .iter()
1530                .any(|f| f.message.contains("missing Actor"))
1531        );
1532    }
1533
1534    #[test]
1535    fn empty_evidence_requires_warns() {
1536        let doc = TruthDocument {
1537            governance: TruthGovernance {
1538                intent: Some(IntentBlock {
1539                    outcome: Some("Do it".into()),
1540                    goal: None,
1541                }),
1542                authority: Some(AuthorityBlock {
1543                    actor: Some("admin".into()),
1544                    ..AuthorityBlock::default()
1545                }),
1546                constraint: None,
1547                evidence: Some(EvidenceBlock {
1548                    requires: vec![],
1549                    provenance: vec![],
1550                    audit: vec!["log".into()],
1551                }),
1552                exception: None,
1553            },
1554            gherkin: "Scenario: Test\n  Given state\n  When admin acts\n  Then done".into(),
1555        };
1556        let report = simulate(&doc, &SimulationConfig::default());
1557        assert!(
1558            report
1559                .findings
1560                .iter()
1561                .any(|f| f.message.contains("no Requires fields"))
1562        );
1563    }
1564
1565    #[test]
1566    fn empty_constraint_block_info() {
1567        let doc = TruthDocument {
1568            governance: TruthGovernance {
1569                intent: Some(IntentBlock {
1570                    outcome: Some("Do it".into()),
1571                    goal: None,
1572                }),
1573                authority: Some(AuthorityBlock {
1574                    actor: Some("admin".into()),
1575                    ..AuthorityBlock::default()
1576                }),
1577                constraint: Some(ConstraintBlock::default()),
1578                evidence: Some(EvidenceBlock {
1579                    requires: vec!["proof".into()],
1580                    provenance: vec![],
1581                    audit: vec!["log".into()],
1582                }),
1583                exception: None,
1584            },
1585            gherkin: "Scenario: Test\n  Given state\n  When admin acts\n  Then done".into(),
1586        };
1587        let report = simulate(&doc, &SimulationConfig::default());
1588        assert!(report.findings.iter().any(|f| {
1589            f.severity == FindingSeverity::Info && f.message.contains("no guardrails")
1590        }));
1591    }
1592
1593    #[test]
1594    fn exception_with_escalation_path() {
1595        let doc = TruthDocument {
1596            governance: TruthGovernance {
1597                intent: Some(IntentBlock {
1598                    outcome: Some("Do it".into()),
1599                    goal: None,
1600                }),
1601                authority: Some(AuthorityBlock {
1602                    actor: Some("admin".into()),
1603                    ..AuthorityBlock::default()
1604                }),
1605                constraint: None,
1606                evidence: Some(EvidenceBlock {
1607                    requires: vec!["proof".into()],
1608                    provenance: vec![],
1609                    audit: vec!["log".into()],
1610                }),
1611                exception: Some(ExceptionBlock {
1612                    escalates_to: vec!["ceo".into()],
1613                    requires: vec![],
1614                }),
1615            },
1616            gherkin: "Scenario: Test\n  Given state\n  When admin acts\n  Then done".into(),
1617        };
1618        let report = simulate(&doc, &SimulationConfig::default());
1619        assert!(report.governance_coverage.has_exception);
1620        assert!(report.governance_coverage.has_escalation_path);
1621    }
1622
1623    // ─── SimulationConfig variations ───
1624
1625    #[test]
1626    fn relaxed_config_allows_missing_governance() {
1627        let content = r"Truth: Bare minimum
1628
1629Scenario: Just do it
1630  Given a state
1631  When an action
1632  Then a result
1633";
1634        let doc = parse_truth_document(content).unwrap();
1635        let config = SimulationConfig {
1636            require_intent: false,
1637            require_authority: false,
1638            require_evidence: false,
1639            require_assertions: false,
1640            check_resource_availability: false,
1641            domain_profiles: vec![],
1642        };
1643        let report = simulate(&doc, &config);
1644        let has_errors = report
1645            .findings
1646            .iter()
1647            .any(|f| f.severity == FindingSeverity::Error);
1648        assert!(!has_errors);
1649        assert_ne!(report.verdict, Verdict::WillNotConverge);
1650    }
1651
1652    #[test]
1653    fn default_config_is_strict() {
1654        let config = SimulationConfig::default();
1655        assert!(config.require_intent);
1656        assert!(config.require_authority);
1657        assert!(config.require_evidence);
1658        assert!(config.require_assertions);
1659        assert!(config.check_resource_availability);
1660        assert!(config.domain_profiles.is_empty());
1661    }
1662
1663    // ─── simulate_spec convenience ───
1664
1665    #[test]
1666    fn simulate_spec_convenience() {
1667        let report = simulate_spec(minimal_valid_spec(), &SimulationConfig::default()).unwrap();
1668        assert!(report.can_converge());
1669    }
1670
1671    #[test]
1672    fn simulate_spec_garbage_input() {
1673        let result = simulate_spec(
1674            "this is not a truth spec at all",
1675            &SimulationConfig::default(),
1676        );
1677        // Parser may be lenient; either an error or a WillNotConverge verdict is acceptable
1678        match result {
1679            Err(_) => {}
1680            Ok(report) => assert_eq!(report.verdict, Verdict::WillNotConverge),
1681        }
1682    }
1683
1684    #[test]
1685    fn simulate_spec_empty_string() {
1686        let result = simulate_spec("", &SimulationConfig::default());
1687        match result {
1688            Err(_) => {}
1689            Ok(report) => assert_eq!(report.verdict, Verdict::WillNotConverge),
1690        }
1691    }
1692
1693    // ─── resource checks ───
1694
1695    #[test]
1696    fn undeclared_evidence_like_resource_warns() {
1697        let content = r"Truth: Resource mismatch
1698
1699Intent:
1700  Outcome: Check resources.
1701
1702Authority:
1703  Actor: admin
1704
1705Evidence:
1706  Requires: security_assessment
1707  Audit: decision_log
1708
1709Scenario: Uses undeclared evidence
1710  Given a vendor
1711  When admin reviews the compliance_report
1712  Then the security_assessment is valid
1713";
1714        let doc = parse_truth_document(content).unwrap();
1715        let report = simulate(&doc, &SimulationConfig::default());
1716        assert!(
1717            report
1718                .resource_summary
1719                .missing
1720                .contains(&"compliance_report".to_string())
1721        );
1722    }
1723
1724    #[test]
1725    fn actor_not_referenced_in_scenarios_info() {
1726        let content = r"Truth: Unused actor
1727
1728Intent:
1729  Outcome: Something.
1730
1731Authority:
1732  Actor: mysterious_committee
1733
1734Evidence:
1735  Requires: proof
1736
1737Scenario: Nobody calls the actor
1738  Given a state
1739  When something happens
1740  Then it works
1741";
1742        let doc = parse_truth_document(content).unwrap();
1743        let report = simulate(&doc, &SimulationConfig::default());
1744        assert!(report.findings.iter().any(|f| {
1745            f.severity == FindingSeverity::Info
1746                && f.message.contains("mysterious_committee")
1747                && f.message.contains("not referenced")
1748        }));
1749    }
1750
1751    #[test]
1752    fn simulation_report_is_reproducible_across_runs() {
1753        let doc = parse_truth_document(full_spec()).unwrap();
1754        let config = SimulationConfig {
1755            domain_profiles: vec![
1756                DomainProfile::VendorSelection,
1757                DomainProfile::VendorSelection,
1758            ],
1759            ..SimulationConfig::default()
1760        };
1761
1762        let first = simulate(&doc, &config);
1763        let second = simulate(&doc, &config);
1764
1765        assert_eq!(first, second);
1766        assert!(first.deterministic_trace.trace_hash.starts_with("sha256:"));
1767        assert_eq!(first.deterministic_trace.step_count, 5);
1768        assert!(first.deterministic_trace.replayable);
1769    }
1770
1771    #[test]
1772    fn resource_summary_is_canonicalized() {
1773        let content = r"Truth: Resource order
1774
1775Intent:
1776  Outcome: Check resources.
1777
1778Authority:
1779  Actor: admin
1780
1781Evidence:
1782  Requires: zeta_report
1783  Requires: alpha_report
1784  Requires: alpha_report
1785  Audit: decision_log
1786
1787Scenario: Uses resources
1788  Given zeta_report exists
1789  And alpha_report exists
1790  When admin reviews beta_assessment
1791  Then alpha_report passes
1792";
1793        let doc = parse_truth_document(content).unwrap();
1794        let report = simulate(&doc, &SimulationConfig::default());
1795
1796        assert_eq!(
1797            report.resource_summary.declared_evidence,
1798            vec!["alpha_report".to_string(), "zeta_report".to_string()]
1799        );
1800        assert_eq!(
1801            report.resource_summary.referenced_in_scenarios,
1802            vec![
1803                "alpha_report".to_string(),
1804                "beta_assessment".to_string(),
1805                "zeta_report".to_string()
1806            ]
1807        );
1808        assert_eq!(
1809            report.resource_summary.missing,
1810            vec!["beta_assessment".to_string()]
1811        );
1812    }
1813
1814    #[test]
1815    fn non_replayable_language_warns_and_marks_trace() {
1816        let content = r"Truth: Live data
1817
1818Intent:
1819  Outcome: Check latest state.
1820
1821Authority:
1822  Actor: admin
1823
1824Evidence:
1825  Requires: snapshot_report
1826
1827Scenario: Uses live data
1828  Given snapshot_report exists
1829  When admin calls the remote API for the latest value
1830  Then the value is accepted
1831";
1832        let doc = parse_truth_document(content).unwrap();
1833        let report = simulate(&doc, &SimulationConfig::default());
1834
1835        assert!(!report.deterministic_trace.replayable);
1836        assert_eq!(report.deterministic_trace.non_replayable_steps.len(), 1);
1837        assert!(report.findings.iter().any(|finding| {
1838            finding.category == "determinism" && finding.message.contains("non-replayable")
1839        }));
1840    }
1841
1842    // ─── multiple scenarios ───
1843
1844    #[test]
1845    fn multiple_scenarios_counted() {
1846        let content = r"Truth: Multi-scenario
1847
1848Intent:
1849  Outcome: Test multiple.
1850
1851Authority:
1852  Actor: admin
1853
1854Evidence:
1855  Requires: proof
1856
1857Scenario: First
1858  Given state
1859  When admin acts
1860  Then result
1861
1862Scenario: Second
1863  Given another state
1864  When admin acts again
1865  Then another result
1866";
1867        let doc = parse_truth_document(content).unwrap();
1868        let report = simulate(&doc, &SimulationConfig::default());
1869        assert_eq!(report.scenario_count, 2);
1870    }
1871
1872    // ─── only governance, no scenarios ───
1873
1874    #[test]
1875    fn spec_with_only_governance_no_scenarios() {
1876        let doc = TruthDocument {
1877            governance: TruthGovernance {
1878                intent: Some(IntentBlock {
1879                    outcome: Some("Goal".into()),
1880                    goal: None,
1881                }),
1882                authority: Some(AuthorityBlock {
1883                    actor: Some("admin".into()),
1884                    ..AuthorityBlock::default()
1885                }),
1886                constraint: None,
1887                evidence: Some(EvidenceBlock {
1888                    requires: vec!["proof".into()],
1889                    provenance: vec![],
1890                    audit: vec![],
1891                }),
1892                exception: None,
1893            },
1894            gherkin: String::new(),
1895        };
1896        let report = simulate(&doc, &SimulationConfig::default());
1897        assert_eq!(report.verdict, Verdict::WillNotConverge);
1898        assert_eq!(report.scenario_count, 0);
1899    }
1900
1901    // ─── nil governance (all None) ───
1902
1903    #[test]
1904    fn nil_governance_with_relaxed_config() {
1905        let content = r"Truth: Nil governance
1906
1907Scenario: Solo
1908  Given x
1909  When y
1910  Then z
1911";
1912        let doc = parse_truth_document(content).unwrap();
1913        let config = SimulationConfig {
1914            require_intent: false,
1915            require_authority: false,
1916            require_evidence: false,
1917            require_assertions: true,
1918            check_resource_availability: false,
1919            domain_profiles: vec![],
1920        };
1921        let report = simulate(&doc, &config);
1922        assert!(!report.governance_coverage.has_intent);
1923        assert!(!report.governance_coverage.has_authority);
1924        assert!(!report.governance_coverage.has_evidence);
1925        assert_eq!(report.verdict, Verdict::Ready);
1926    }
1927
1928    #[test]
1929    fn nil_governance_with_strict_config() {
1930        let content = r"Truth: Nil governance strict
1931
1932Scenario: Solo
1933  Given x
1934  When y
1935  Then z
1936";
1937        let doc = parse_truth_document(content).unwrap();
1938        let report = simulate(&doc, &SimulationConfig::default());
1939        assert_eq!(report.verdict, Verdict::WillNotConverge);
1940        let error_count = report
1941            .findings
1942            .iter()
1943            .filter(|f| f.severity == FindingSeverity::Error)
1944            .count();
1945        assert!(error_count >= 3); // intent, authority, evidence
1946    }
1947
1948    // ─── domain profile checks ───
1949
1950    #[test]
1951    fn default_config_does_not_run_domain_profiles() {
1952        let doc = parse_truth_document(full_spec()).unwrap();
1953        let report = simulate(&doc, &SimulationConfig::default());
1954        assert!(report.domain_profiles.is_empty());
1955        assert!(report.vendor_selection().is_none());
1956        assert!(
1957            !report
1958                .findings
1959                .iter()
1960                .any(|f| f.category == "vendor-selection")
1961        );
1962    }
1963
1964    #[test]
1965    fn domain_profile_deduplicates() {
1966        let config = SimulationConfig::default()
1967            .with_domain_profile(DomainProfile::VendorSelection)
1968            .with_domain_profile(DomainProfile::VendorSelection);
1969        assert_eq!(config.domain_profiles.len(), 1);
1970    }
1971
1972    #[test]
1973    fn vendor_spec_detected() {
1974        let doc = parse_truth_document(full_spec()).unwrap();
1975        let report = simulate(&doc, &vendor_config());
1976        assert!(vendor_selection(&report).detected);
1977    }
1978
1979    #[test]
1980    fn vendor_spec_extracts_vendor_names() {
1981        let doc = parse_truth_document(full_spec()).unwrap();
1982        let report = simulate(&doc, &vendor_config());
1983        assert!(
1984            vendor_selection(&report)
1985                .vendor_references
1986                .contains(&"Acme AI".to_string())
1987        );
1988        assert!(
1989            vendor_selection(&report)
1990                .vendor_references
1991                .contains(&"Beta ML".to_string())
1992        );
1993    }
1994
1995    #[test]
1996    fn vendor_spec_counts_evaluation_dimensions() {
1997        let doc = parse_truth_document(full_spec()).unwrap();
1998        let report = simulate(&doc, &vendor_config());
1999        assert!(vendor_selection(&report).evaluation_dimensions >= 2);
2000    }
2001
2002    #[test]
2003    fn vendor_spec_detects_approval_gate() {
2004        let doc = parse_truth_document(full_spec()).unwrap();
2005        let report = simulate(&doc, &vendor_config());
2006        assert!(vendor_selection(&report).has_commitment_gate);
2007    }
2008
2009    #[test]
2010    fn non_vendor_spec_not_detected() {
2011        let doc = parse_truth_document(minimal_valid_spec()).unwrap();
2012        let report = simulate(&doc, &vendor_config());
2013        assert!(!vendor_selection(&report).detected);
2014    }
2015
2016    #[test]
2017    fn vendor_spec_few_dimensions_warns() {
2018        let content = r#"Truth: Thin vendor eval
2019
2020Intent:
2021  Outcome: Select a vendor.
2022
2023Authority:
2024  Actor: admin
2025  Requires Approval: commitment
2026
2027Evidence:
2028  Requires: proof
2029
2030Scenario: Pick a vendor
2031  Given vendors "Acme"
2032  When evaluated
2033  Then a recommendation is produced
2034"#;
2035        let doc = parse_truth_document(content).unwrap();
2036        let report = simulate(&doc, &vendor_config());
2037        assert!(vendor_selection(&report).detected);
2038        assert!(report.findings.iter().any(|f| {
2039            f.category == "vendor-selection" && f.message.contains("evaluation dimension")
2040        }));
2041    }
2042
2043    #[test]
2044    fn vendor_spec_no_ranking_warns() {
2045        let content = r#"Truth: No ranking vendor eval
2046
2047Intent:
2048  Outcome: Select a vendor with compliance and cost and risk analysis.
2049
2050Authority:
2051  Actor: board
2052  Requires Approval: commitment
2053
2054Evidence:
2055  Requires: compliance_report
2056
2057Scenario: Evaluate vendors
2058  Given vendors "Acme, Beta, Gamma"
2059  When the board evaluates
2060  Then all vendors are screened
2061"#;
2062        let doc = parse_truth_document(content).unwrap();
2063        let report = simulate(&doc, &vendor_config());
2064        assert!(vendor_selection(&report).detected);
2065        assert!(
2066            report
2067                .findings
2068                .iter()
2069                .any(|f| { f.category == "vendor-selection" && f.message.contains("ranking") })
2070        );
2071    }
2072
2073    #[test]
2074    fn vendor_spec_no_approval_gate_warns() {
2075        let content = r#"Truth: No approval vendor eval
2076
2077Intent:
2078  Outcome: Select a vendor with compliance and cost and risk.
2079
2080Authority:
2081  Actor: admin
2082
2083Evidence:
2084  Requires: report
2085
2086Scenario: Pick vendor
2087  Given vendors "Acme, Beta, Gamma"
2088  When evaluated
2089  Then a ranked shortlist is produced
2090"#;
2091        let doc = parse_truth_document(content).unwrap();
2092        let report = simulate(&doc, &vendor_config());
2093        assert!(vendor_selection(&report).detected);
2094        assert!(report.findings.iter().any(|f| {
2095            f.category == "vendor-selection" && f.message.contains("commitment approval gate")
2096        }));
2097    }
2098
2099    #[test]
2100    fn vendor_profile_not_enabled() {
2101        let content = r#"Truth: Vendor eval
2102
2103Intent:
2104  Outcome: Select a vendor.
2105
2106Authority:
2107  Actor: admin
2108
2109Evidence:
2110  Requires: proof
2111
2112Scenario: Quick
2113  Given vendors "A"
2114  When checked
2115  Then done
2116"#;
2117        let doc = parse_truth_document(content).unwrap();
2118        let report = simulate(&doc, &SimulationConfig::default());
2119        assert!(report.vendor_selection().is_none());
2120        assert!(
2121            !report
2122                .findings
2123                .iter()
2124                .any(|f| f.category == "vendor-selection")
2125        );
2126    }
2127
2128    #[test]
2129    fn vendor_spec_complete_no_vendor_warnings() {
2130        let content = r#"Truth: Complete vendor selection
2131
2132Intent:
2133  Outcome: Select a vendor with compliance, cost, risk, security, and capability analysis.
2134
2135Authority:
2136  Actor: governance_review_board
2137  Requires Approval: vendor_commitment
2138
2139Constraint:
2140  Cost Limit: annual spend within budget.
2141
2142Evidence:
2143  Requires: compliance_assessment
2144  Requires: risk_assessment
2145  Requires: cost_analysis
2146  Audit: decision_log
2147
2148Scenario: Full evaluation
2149  Given vendors "Acme AI, Beta ML, Gamma LLM"
2150  And each vendor has compliance and risk data
2151  When the governance_review_board evaluates
2152  Then a ranked shortlist is produced
2153  And the recommendation has evidence from all criteria
2154"#;
2155        let doc = parse_truth_document(content).unwrap();
2156        let report = simulate(&doc, &vendor_config());
2157        let coverage = vendor_selection(&report);
2158        assert!(coverage.detected);
2159        assert!(coverage.evaluation_dimensions >= 3);
2160        assert!(coverage.has_ranking_criterion);
2161        assert!(coverage.has_commitment_gate);
2162        assert_eq!(coverage.vendor_references.len(), 3);
2163        assert!(!report.findings.iter().any(|f| {
2164            f.category == "vendor-selection" && f.severity == FindingSeverity::Warning
2165        }));
2166    }
2167}