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};
27
28/// Configuration for simulation strictness.
29#[derive(Debug, Clone)]
30pub struct SimulationConfig {
31    /// Require Intent block with at least Outcome.
32    pub require_intent: bool,
33    /// Require Authority block with at least Actor.
34    pub require_authority: bool,
35    /// Require Evidence block with at least one Requires field.
36    pub require_evidence: bool,
37    /// Require at least one scenario with a Then step.
38    pub require_assertions: bool,
39    /// Require scenario Given steps to reference resources declared in Evidence.
40    pub check_resource_availability: bool,
41    /// Enable vendor-selection-specific pre-flight checks when the spec
42    /// appears to describe a vendor/procurement evaluation.
43    pub check_vendor_selection: bool,
44}
45
46impl Default for SimulationConfig {
47    fn default() -> Self {
48        Self {
49            require_intent: true,
50            require_authority: true,
51            require_evidence: true,
52            require_assertions: true,
53            check_resource_availability: true,
54            check_vendor_selection: true,
55        }
56    }
57}
58
59/// Overall simulation verdict.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Verdict {
62    /// The spec looks complete enough to converge.
63    Ready,
64    /// The spec has warnings but might converge.
65    Risky,
66    /// The spec is underspecified and will not converge.
67    WillNotConverge,
68}
69
70impl std::fmt::Display for Verdict {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::Ready => write!(f, "ready"),
74            Self::Risky => write!(f, "risky"),
75            Self::WillNotConverge => write!(f, "will-not-converge"),
76        }
77    }
78}
79
80/// Severity of a simulation finding.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum FindingSeverity {
83    Info,
84    Warning,
85    Error,
86}
87
88impl std::fmt::Display for FindingSeverity {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            Self::Info => write!(f, "info"),
92            Self::Warning => write!(f, "warning"),
93            Self::Error => write!(f, "error"),
94        }
95    }
96}
97
98/// A single simulation finding.
99#[derive(Debug, Clone)]
100pub struct SimulationFinding {
101    pub severity: FindingSeverity,
102    pub category: &'static str,
103    pub message: String,
104    pub suggestion: Option<String>,
105}
106
107/// Vendor-selection-specific pre-flight coverage.
108#[derive(Debug, Clone, Default)]
109pub struct VendorSelectionCoverage {
110    /// Whether the spec appears to describe vendor/procurement evaluation.
111    pub detected: bool,
112    /// Number of distinct evaluation dimensions found (compliance, cost, risk, etc.).
113    pub evaluation_dimensions: usize,
114    /// Vendor names or references found in scenarios.
115    pub vendor_references: Vec<String>,
116    /// Whether a scoring/ranking criterion is present.
117    pub has_ranking_criterion: bool,
118    /// Whether a commitment/approval gate is present.
119    pub has_commitment_gate: bool,
120}
121
122/// The result of simulating a Truth spec.
123#[derive(Debug, Clone)]
124pub struct SimulationReport {
125    pub verdict: Verdict,
126    pub findings: Vec<SimulationFinding>,
127    pub governance_coverage: GovernanceCoverage,
128    pub scenario_count: usize,
129    pub resource_summary: ResourceSummary,
130    pub vendor_selection: VendorSelectionCoverage,
131}
132
133impl SimulationReport {
134    /// Whether the spec has a realistic chance of converging.
135    pub fn can_converge(&self) -> bool {
136        self.verdict != Verdict::WillNotConverge
137    }
138}
139
140/// Which governance blocks are present and how complete they are.
141#[derive(Debug, Clone, Default)]
142pub struct GovernanceCoverage {
143    pub has_intent: bool,
144    pub has_outcome: bool,
145    pub has_authority: bool,
146    pub has_actor: bool,
147    pub has_approval_gate: bool,
148    pub has_constraint: bool,
149    pub has_evidence: bool,
150    pub evidence_count: usize,
151    pub has_exception: bool,
152    pub has_escalation_path: bool,
153}
154
155/// Summary of resources required vs available.
156#[derive(Debug, Clone, Default)]
157pub struct ResourceSummary {
158    /// Resources declared in Evidence.Requires.
159    pub declared_evidence: Vec<String>,
160    /// Resources referenced in scenario steps.
161    pub referenced_in_scenarios: Vec<String>,
162    /// Resources referenced but not declared.
163    pub missing: Vec<String>,
164}
165
166/// Run a pre-flight simulation on a parsed Truth document.
167pub fn simulate(doc: &TruthDocument, config: &SimulationConfig) -> SimulationReport {
168    let mut findings = Vec::new();
169
170    let governance_coverage = check_governance(&doc.governance, config, &mut findings);
171    let scenario_count = check_scenarios(&doc.gherkin, config, &mut findings);
172    let resource_summary = check_resources(&doc.governance, &doc.gherkin, config, &mut findings);
173    let vendor_selection = if config.check_vendor_selection {
174        check_vendor_selection(&doc.governance, &doc.gherkin, &mut findings)
175    } else {
176        VendorSelectionCoverage::default()
177    };
178
179    let has_errors = findings
180        .iter()
181        .any(|f| matches!(f.severity, FindingSeverity::Error));
182    let has_warnings = findings
183        .iter()
184        .any(|f| matches!(f.severity, FindingSeverity::Warning));
185
186    let verdict = if has_errors {
187        Verdict::WillNotConverge
188    } else if has_warnings {
189        Verdict::Risky
190    } else {
191        Verdict::Ready
192    };
193
194    SimulationReport {
195        verdict,
196        findings,
197        governance_coverage,
198        scenario_count,
199        resource_summary,
200        vendor_selection,
201    }
202}
203
204/// Parse and simulate in one step.
205pub fn simulate_spec(
206    content: &str,
207    config: &SimulationConfig,
208) -> Result<SimulationReport, ValidationError> {
209    let doc = crate::truths::parse_truth_document(content)?;
210    Ok(simulate(&doc, config))
211}
212
213fn check_governance(
214    gov: &TruthGovernance,
215    config: &SimulationConfig,
216    findings: &mut Vec<SimulationFinding>,
217) -> GovernanceCoverage {
218    let mut coverage = GovernanceCoverage::default();
219
220    // Intent
221    if let Some(intent) = &gov.intent {
222        coverage.has_intent = true;
223        coverage.has_outcome = intent.outcome.is_some();
224        if intent.outcome.is_none() {
225            findings.push(SimulationFinding {
226                severity: FindingSeverity::Warning,
227                category: "governance",
228                message: "Intent block present but missing Outcome field.".into(),
229                suggestion: Some("Add `Outcome: <what should happen>` to the Intent block.".into()),
230            });
231        }
232    } else if config.require_intent {
233        findings.push(SimulationFinding {
234            severity: FindingSeverity::Error,
235            category: "governance",
236            message: "Missing Intent block — agents have no goal to converge toward.".into(),
237            suggestion: Some("Add an Intent block with Outcome and optionally Goal.".into()),
238        });
239    }
240
241    // Authority
242    if let Some(authority) = &gov.authority {
243        coverage.has_authority = true;
244        coverage.has_actor = authority.actor.is_some();
245        coverage.has_approval_gate = !authority.requires_approval.is_empty();
246        if authority.actor.is_none() {
247            findings.push(SimulationFinding {
248                severity: FindingSeverity::Warning,
249                category: "governance",
250                message: "Authority block present but missing Actor field.".into(),
251                suggestion: Some("Add `Actor: <who can approve>` to the Authority block.".into()),
252            });
253        }
254    } else if config.require_authority {
255        findings.push(SimulationFinding {
256            severity: FindingSeverity::Error,
257            category: "governance",
258            message: "Missing Authority block — no one is authorized to promote decisions.".into(),
259            suggestion: Some(
260                "Add an Authority block with Actor and optionally Requires Approval.".into(),
261            ),
262        });
263    }
264
265    // Constraint
266    if let Some(constraint) = &gov.constraint {
267        coverage.has_constraint = true;
268        if constraint.budget.is_empty()
269            && constraint.cost_limit.is_empty()
270            && constraint.must_not.is_empty()
271        {
272            findings.push(SimulationFinding {
273                severity: FindingSeverity::Info,
274                category: "governance",
275                message: "Constraint block is empty — agents have no guardrails.".into(),
276                suggestion: None,
277            });
278        }
279    }
280
281    // Evidence
282    if let Some(evidence) = &gov.evidence {
283        coverage.has_evidence = true;
284        coverage.evidence_count = evidence.requires.len();
285        if evidence.requires.is_empty() {
286            findings.push(SimulationFinding {
287                severity: FindingSeverity::Warning,
288                category: "governance",
289                message: "Evidence block present but no Requires fields — nothing to audit.".into(),
290                suggestion: Some("Add `Requires: <evidence_name>` fields.".into()),
291            });
292        }
293        if evidence.audit.is_empty() {
294            findings.push(SimulationFinding {
295                severity: FindingSeverity::Info,
296                category: "governance",
297                message: "No Audit field in Evidence — decision trail may be incomplete.".into(),
298                suggestion: Some("Add `Audit: <log_name>` for traceability.".into()),
299            });
300        }
301    } else if config.require_evidence {
302        findings.push(SimulationFinding {
303            severity: FindingSeverity::Error,
304            category: "governance",
305            message: "Missing Evidence block — no proof requirements declared.".into(),
306            suggestion: Some("Add an Evidence block with Requires and Audit fields.".into()),
307        });
308    }
309
310    // Exception
311    if let Some(exception) = &gov.exception {
312        coverage.has_exception = true;
313        coverage.has_escalation_path = !exception.escalates_to.is_empty();
314    }
315
316    // Cross-block coherence
317    if coverage.has_approval_gate && !coverage.has_evidence {
318        findings.push(SimulationFinding {
319            severity: FindingSeverity::Warning,
320            category: "coherence",
321            message:
322                "Authority requires approval but no Evidence block — approver has nothing to review."
323                    .into(),
324            suggestion: Some(
325                "Add Evidence.Requires fields so the approver has artifacts to evaluate.".into(),
326            ),
327        });
328    }
329
330    if coverage.has_constraint && !coverage.has_authority {
331        findings.push(SimulationFinding {
332            severity: FindingSeverity::Warning,
333            category: "coherence",
334            message: "Constraints declared but no Authority — who enforces the limits?".into(),
335            suggestion: Some("Add an Authority block with an Actor.".into()),
336        });
337    }
338
339    coverage
340}
341
342fn check_scenarios(
343    gherkin: &str,
344    config: &SimulationConfig,
345    findings: &mut Vec<SimulationFinding>,
346) -> usize {
347    let preprocessed = preprocess_truths(gherkin);
348    let metas = extract_all_metas(&preprocessed).unwrap_or_default();
349
350    if metas.is_empty() {
351        findings.push(SimulationFinding {
352            severity: FindingSeverity::Error,
353            category: "scenario",
354            message: "No scenarios found — nothing to execute.".into(),
355            suggestion: Some("Add at least one Scenario with Given/When/Then steps.".into()),
356        });
357        return 0;
358    }
359
360    // Check for Then steps (assertions)
361    if config.require_assertions {
362        let has_then = gherkin.lines().any(|line| line.trim().starts_with("Then "));
363        if !has_then {
364            findings.push(SimulationFinding {
365                severity: FindingSeverity::Error,
366                category: "scenario",
367                message: "No Then steps found — scenarios have no success criteria.".into(),
368                suggestion: Some("Add Then steps that assert expected outcomes.".into()),
369            });
370        }
371    }
372
373    // Check for Given steps (preconditions)
374    let has_given = gherkin
375        .lines()
376        .any(|line| line.trim().starts_with("Given "));
377    if !has_given {
378        findings.push(SimulationFinding {
379            severity: FindingSeverity::Warning,
380            category: "scenario",
381            message: "No Given steps — scenarios have no declared preconditions.".into(),
382            suggestion: Some("Add Given steps that establish the initial state.".into()),
383        });
384    }
385
386    // Check for When steps (actions)
387    let has_when = gherkin.lines().any(|line| line.trim().starts_with("When "));
388    if !has_when {
389        findings.push(SimulationFinding {
390            severity: FindingSeverity::Warning,
391            category: "scenario",
392            message: "No When steps — scenarios have no triggering action.".into(),
393            suggestion: Some("Add When steps that describe the action being governed.".into()),
394        });
395    }
396
397    metas.len()
398}
399
400fn check_resources(
401    gov: &TruthGovernance,
402    gherkin: &str,
403    config: &SimulationConfig,
404    findings: &mut Vec<SimulationFinding>,
405) -> ResourceSummary {
406    let mut summary = ResourceSummary::default();
407
408    // Collect declared evidence resources
409    if let Some(evidence) = &gov.evidence {
410        summary.declared_evidence.clone_from(&evidence.requires);
411    }
412
413    // Extract resource references from scenario steps
414    let resource_pattern = regex::Regex::new(r"[a-z][a-z0-9_]*(?:_[a-z0-9]+)+").ok();
415
416    if let Some(pattern) = &resource_pattern {
417        for line in gherkin.lines() {
418            let trimmed = line.trim();
419            if trimmed.starts_with("Given ")
420                || trimmed.starts_with("When ")
421                || trimmed.starts_with("Then ")
422                || trimmed.starts_with("And ")
423            {
424                for m in pattern.find_iter(trimmed) {
425                    let resource = m.as_str().to_string();
426                    if !summary.referenced_in_scenarios.contains(&resource) {
427                        summary.referenced_in_scenarios.push(resource);
428                    }
429                }
430            }
431        }
432    }
433
434    // Find references that match evidence naming patterns but aren't declared
435    if config.check_resource_availability && !summary.declared_evidence.is_empty() {
436        for referenced in &summary.referenced_in_scenarios {
437            let looks_like_evidence = referenced.ends_with("_assessment")
438                || referenced.ends_with("_analysis")
439                || referenced.ends_with("_report")
440                || referenced.ends_with("_review")
441                || referenced.ends_with("_log")
442                || referenced.ends_with("_record")
443                || referenced.ends_with("_bundle");
444
445            if looks_like_evidence && !summary.declared_evidence.contains(referenced) {
446                summary.missing.push(referenced.clone());
447            }
448        }
449
450        if !summary.missing.is_empty() {
451            findings.push(SimulationFinding {
452                severity: FindingSeverity::Warning,
453                category: "resources",
454                message: format!(
455                    "Scenario references evidence-like resources not declared in Evidence block: {}",
456                    summary.missing.join(", ")
457                ),
458                suggestion: Some(
459                    "Add these as `Requires:` fields in the Evidence block, or rename to avoid evidence naming patterns.".into(),
460                ),
461            });
462        }
463    }
464
465    // Check if authority actors are referenced in scenarios
466    if let Some(authority) = &gov.authority
467        && let Some(actor) = &authority.actor
468    {
469        let actor_referenced = gherkin.contains(actor);
470        if !actor_referenced {
471            findings.push(SimulationFinding {
472                severity: FindingSeverity::Info,
473                category: "resources",
474                message: format!(
475                    "Authority actor `{actor}` is declared but not referenced in any scenario."
476                ),
477                suggestion: Some(
478                    "Consider adding a scenario step that involves the authorized actor.".into(),
479                ),
480            });
481        }
482    }
483
484    summary
485}
486
487const VENDOR_KEYWORDS: &[&str] = &[
488    "vendor",
489    "procurement",
490    "supplier",
491    "rfp",
492    "shortlist",
493    "sourcing",
494];
495
496const EVALUATION_DIMENSIONS: &[&str] = &[
497    "compliance",
498    "cost",
499    "risk",
500    "security",
501    "capability",
502    "stability",
503    "performance",
504    "pricing",
505    "budget",
506    "certification",
507    "regulatory",
508    "timeline",
509    "delivery",
510];
511
512fn check_vendor_selection(
513    gov: &TruthGovernance,
514    gherkin: &str,
515    findings: &mut Vec<SimulationFinding>,
516) -> VendorSelectionCoverage {
517    let mut coverage = VendorSelectionCoverage::default();
518
519    let combined = format!(
520        "{} {}",
521        gov.intent
522            .as_ref()
523            .and_then(|i| i.outcome.as_deref())
524            .unwrap_or(""),
525        gherkin
526    )
527    .to_lowercase();
528
529    coverage.detected = VENDOR_KEYWORDS.iter().any(|kw| combined.contains(kw));
530    if !coverage.detected {
531        return coverage;
532    }
533
534    // Count evaluation dimensions mentioned
535    let dimensions: Vec<&str> = EVALUATION_DIMENSIONS
536        .iter()
537        .copied()
538        .filter(|d| combined.contains(d))
539        .collect();
540    coverage.evaluation_dimensions = dimensions.len();
541
542    if coverage.evaluation_dimensions < 3 {
543        findings.push(SimulationFinding {
544            severity: FindingSeverity::Warning,
545            category: "vendor-selection",
546            message: format!(
547                "Vendor selection spec mentions only {} evaluation dimension(s): {}. \
548                 At least 3 are recommended for meaningful differentiation.",
549                coverage.evaluation_dimensions,
550                if dimensions.is_empty() {
551                    "none".to_string()
552                } else {
553                    dimensions.join(", ")
554                }
555            ),
556            suggestion: Some(
557                "Add evaluation criteria such as compliance, cost, risk, security, capability."
558                    .into(),
559            ),
560        });
561    }
562
563    // Extract vendor references from Given steps
564    let vendor_pattern = regex::Regex::new(r#"(?i)(?:vendors?|suppliers?)\s+"([^"]+)""#).ok();
565    if let Some(pat) = &vendor_pattern {
566        for cap in pat.captures_iter(gherkin) {
567            if let Some(names) = cap.get(1) {
568                for name in names.as_str().split(',') {
569                    let trimmed = name.trim().to_string();
570                    if !trimmed.is_empty() && !coverage.vendor_references.contains(&trimmed) {
571                        coverage.vendor_references.push(trimmed);
572                    }
573                }
574            }
575        }
576    }
577
578    if coverage.vendor_references.len() < 3 {
579        findings.push(SimulationFinding {
580            severity: FindingSeverity::Info,
581            category: "vendor-selection",
582            message: format!(
583                "Only {} vendor(s) referenced in scenarios. \
584                 3+ vendors recommended for meaningful comparison.",
585                coverage.vendor_references.len()
586            ),
587            suggestion: Some(
588                "Add more vendors in Given steps: Given vendors \"Acme, Beta, Gamma\"".into(),
589            ),
590        });
591    }
592
593    // Check for ranking/shortlist criteria in scenario steps only
594    let scenario_text: String = gherkin
595        .lines()
596        .filter(|l| {
597            let t = l.trim();
598            t.starts_with("Then ") || t.starts_with("And ")
599        })
600        .collect::<Vec<_>>()
601        .join(" ")
602        .to_lowercase();
603
604    coverage.has_ranking_criterion = scenario_text.contains("rank")
605        || scenario_text.contains("shortlist")
606        || scenario_text.contains("scored")
607        || scenario_text.contains("recommendation");
608
609    if !coverage.has_ranking_criterion {
610        findings.push(SimulationFinding {
611            severity: FindingSeverity::Warning,
612            category: "vendor-selection",
613            message: "No ranking or shortlisting criterion detected.".into(),
614            suggestion: Some(
615                "Add a Then step asserting a ranked shortlist or recommendation is produced."
616                    .into(),
617            ),
618        });
619    }
620
621    // Check for commitment/approval gate
622    coverage.has_commitment_gate = gov
623        .authority
624        .as_ref()
625        .is_some_and(|a| !a.requires_approval.is_empty());
626
627    if !coverage.has_commitment_gate {
628        findings.push(SimulationFinding {
629            severity: FindingSeverity::Warning,
630            category: "vendor-selection",
631            message: "No commitment approval gate found. Vendor selections with financial \
632                      impact should require human approval."
633                .into(),
634            suggestion: Some(
635                "Add `Requires Approval: vendor_commitment` to the Authority block.".into(),
636            ),
637        });
638    }
639
640    coverage
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use crate::truths::{
647        AuthorityBlock, ConstraintBlock, EvidenceBlock, ExceptionBlock, IntentBlock,
648        parse_truth_document,
649    };
650
651    fn full_spec() -> &'static str {
652        r#"Truth: Vendor selection is governed
653
654Intent:
655  Outcome: Select a vendor with auditable rationale.
656  Goal: Evaluate candidates on cost, compliance, and risk.
657
658Authority:
659  Actor: governance_review_board
660  Requires Approval: final_vendor_selection
661
662Constraint:
663  Cost Limit: first-year spend must stay within budget.
664
665Evidence:
666  Requires: security_assessment
667  Requires: pricing_analysis
668  Audit: decision_log
669
670Scenario: Vendors produce traceable outcomes
671  Given candidate vendors "Acme AI, Beta ML, Gamma LLM"
672  And each vendor has a security_assessment and pricing_analysis
673  When the governance_review_board evaluates each vendor
674  Then each vendor should produce a compliance screening result
675  And a ranked shortlist is produced
676"#
677    }
678
679    fn minimal_valid_spec() -> &'static str {
680        r"Truth: Minimal
681
682Intent:
683  Outcome: Works.
684
685Authority:
686  Actor: admin
687
688Evidence:
689  Requires: proof
690
691Scenario: It works
692  Given something exists
693  When validated
694  Then it passes
695"
696    }
697
698    // ─── Verdict display ───
699
700    #[test]
701    fn verdict_display() {
702        assert_eq!(Verdict::Ready.to_string(), "ready");
703        assert_eq!(Verdict::Risky.to_string(), "risky");
704        assert_eq!(Verdict::WillNotConverge.to_string(), "will-not-converge");
705    }
706
707    #[test]
708    fn finding_severity_display() {
709        assert_eq!(FindingSeverity::Info.to_string(), "info");
710        assert_eq!(FindingSeverity::Warning.to_string(), "warning");
711        assert_eq!(FindingSeverity::Error.to_string(), "error");
712    }
713
714    // ─── SimulationReport::can_converge ───
715
716    #[test]
717    fn can_converge_ready() {
718        let report = SimulationReport {
719            verdict: Verdict::Ready,
720            findings: vec![],
721            governance_coverage: GovernanceCoverage::default(),
722            scenario_count: 1,
723            resource_summary: ResourceSummary::default(),
724            vendor_selection: VendorSelectionCoverage::default(),
725        };
726        assert!(report.can_converge());
727    }
728
729    #[test]
730    fn can_converge_risky() {
731        let report = SimulationReport {
732            verdict: Verdict::Risky,
733            findings: vec![],
734            governance_coverage: GovernanceCoverage::default(),
735            scenario_count: 1,
736            resource_summary: ResourceSummary::default(),
737            vendor_selection: VendorSelectionCoverage::default(),
738        };
739        assert!(report.can_converge());
740    }
741
742    #[test]
743    fn cannot_converge_will_not() {
744        let report = SimulationReport {
745            verdict: Verdict::WillNotConverge,
746            findings: vec![],
747            governance_coverage: GovernanceCoverage::default(),
748            scenario_count: 0,
749            resource_summary: ResourceSummary::default(),
750            vendor_selection: VendorSelectionCoverage::default(),
751        };
752        assert!(!report.can_converge());
753    }
754
755    // ─── SimulationFinding construction ───
756
757    #[test]
758    fn finding_with_suggestion() {
759        let f = SimulationFinding {
760            severity: FindingSeverity::Warning,
761            category: "test",
762            message: "something is off".into(),
763            suggestion: Some("fix it".into()),
764        };
765        assert_eq!(f.severity, FindingSeverity::Warning);
766        assert_eq!(f.category, "test");
767        assert!(f.suggestion.is_some());
768    }
769
770    #[test]
771    fn finding_without_suggestion() {
772        let f = SimulationFinding {
773            severity: FindingSeverity::Info,
774            category: "test",
775            message: "just info".into(),
776            suggestion: None,
777        };
778        assert!(f.suggestion.is_none());
779    }
780
781    // ─── simulate: complete spec ───
782
783    #[test]
784    fn complete_spec_is_ready() {
785        let doc = parse_truth_document(full_spec()).unwrap();
786        let report = simulate(&doc, &SimulationConfig::default());
787        assert_eq!(report.verdict, Verdict::Ready);
788        assert!(report.can_converge());
789    }
790
791    #[test]
792    fn complete_spec_governance_coverage() {
793        let doc = parse_truth_document(full_spec()).unwrap();
794        let report = simulate(&doc, &SimulationConfig::default());
795        assert!(report.governance_coverage.has_intent);
796        assert!(report.governance_coverage.has_outcome);
797        assert!(report.governance_coverage.has_authority);
798        assert!(report.governance_coverage.has_actor);
799        assert!(report.governance_coverage.has_constraint);
800        assert!(report.governance_coverage.has_evidence);
801        assert_eq!(report.governance_coverage.evidence_count, 2);
802    }
803
804    #[test]
805    fn complete_spec_scenario_count() {
806        let doc = parse_truth_document(full_spec()).unwrap();
807        let report = simulate(&doc, &SimulationConfig::default());
808        assert_eq!(report.scenario_count, 1);
809    }
810
811    #[test]
812    fn complete_spec_resource_summary() {
813        let doc = parse_truth_document(full_spec()).unwrap();
814        let report = simulate(&doc, &SimulationConfig::default());
815        assert_eq!(report.resource_summary.declared_evidence.len(), 2);
816        assert!(report.resource_summary.missing.is_empty());
817    }
818
819    // ─── simulate: missing governance blocks ───
820
821    #[test]
822    fn missing_intent_will_not_converge() {
823        let content = r"Truth: No intent
824
825Scenario: Something happens
826  Given a precondition
827  When an action occurs
828  Then a result is observed
829";
830        let doc = parse_truth_document(content).unwrap();
831        let report = simulate(&doc, &SimulationConfig::default());
832        assert_eq!(report.verdict, Verdict::WillNotConverge);
833        assert!(!report.can_converge());
834        assert!(
835            report
836                .findings
837                .iter()
838                .any(|f| f.message.contains("Missing Intent"))
839        );
840    }
841
842    #[test]
843    fn missing_authority_will_not_converge() {
844        let content = r"Truth: No authority
845
846Intent:
847  Outcome: Do a thing.
848
849Evidence:
850  Requires: proof
851
852Scenario: Action
853  Given precondition
854  When something happens
855  Then outcome
856";
857        let doc = parse_truth_document(content).unwrap();
858        let report = simulate(&doc, &SimulationConfig::default());
859        assert_eq!(report.verdict, Verdict::WillNotConverge);
860        assert!(
861            report
862                .findings
863                .iter()
864                .any(|f| f.message.contains("Missing Authority"))
865        );
866    }
867
868    #[test]
869    fn missing_evidence_will_not_converge() {
870        let content = r"Truth: No evidence
871
872Intent:
873  Outcome: Do a thing.
874
875Authority:
876  Actor: admin
877
878Scenario: Action
879  Given precondition
880  When something happens
881  Then outcome
882";
883        let doc = parse_truth_document(content).unwrap();
884        let report = simulate(&doc, &SimulationConfig::default());
885        assert_eq!(report.verdict, Verdict::WillNotConverge);
886        assert!(
887            report
888                .findings
889                .iter()
890                .any(|f| f.message.contains("Missing Evidence"))
891        );
892    }
893
894    // ─── simulate: scenario issues ───
895
896    #[test]
897    fn missing_then_steps_will_not_converge() {
898        let content = r"Truth: No assertions
899
900Intent:
901  Outcome: Do something.
902
903Authority:
904  Actor: admin
905
906Evidence:
907  Requires: report
908
909Scenario: Missing outcome
910  Given a shortlist of vendors
911  When the workflow ranks them
912";
913        let doc = parse_truth_document(content).unwrap();
914        let report = simulate(&doc, &SimulationConfig::default());
915        assert_eq!(report.verdict, Verdict::WillNotConverge);
916        assert!(
917            report
918                .findings
919                .iter()
920                .any(|f| f.message.contains("No Then steps"))
921        );
922    }
923
924    #[test]
925    fn missing_given_steps_produces_warning() {
926        let content = r"Truth: No given
927
928Intent:
929  Outcome: Do something.
930
931Authority:
932  Actor: admin
933
934Evidence:
935  Requires: report
936
937Scenario: No preconditions
938  When something happens
939  Then it works
940";
941        let doc = parse_truth_document(content).unwrap();
942        let report = simulate(&doc, &SimulationConfig::default());
943        assert!(
944            report
945                .findings
946                .iter()
947                .any(|f| f.message.contains("No Given steps"))
948        );
949    }
950
951    #[test]
952    fn missing_when_steps_produces_warning() {
953        let content = r"Truth: No when
954
955Intent:
956  Outcome: Do something.
957
958Authority:
959  Actor: admin
960
961Evidence:
962  Requires: report
963
964Scenario: No action
965  Given a state
966  Then it is fine
967";
968        let doc = parse_truth_document(content).unwrap();
969        let report = simulate(&doc, &SimulationConfig::default());
970        assert!(
971            report
972                .findings
973                .iter()
974                .any(|f| f.message.contains("No When steps"))
975        );
976    }
977
978    #[test]
979    fn no_scenarios_will_not_converge() {
980        let content = r"Truth: Empty
981
982Intent:
983  Outcome: Nothing to do.
984
985Authority:
986  Actor: admin
987
988Evidence:
989  Requires: proof
990";
991        let doc = parse_truth_document(content).unwrap();
992        let report = simulate(&doc, &SimulationConfig::default());
993        assert_eq!(report.verdict, Verdict::WillNotConverge);
994        assert!(
995            report
996                .findings
997                .iter()
998                .any(|f| f.message.contains("No scenarios found"))
999        );
1000        assert_eq!(report.scenario_count, 0);
1001    }
1002
1003    // ─── simulate: coherence checks ───
1004
1005    #[test]
1006    fn approval_without_evidence_is_risky() {
1007        let content = r"Truth: Approval gate without evidence
1008
1009Intent:
1010  Outcome: Approve a vendor.
1011
1012Authority:
1013  Actor: board
1014  Requires Approval: cfo_sign_off
1015
1016Scenario: Approval happens
1017  Given a vendor is shortlisted
1018  When the board reviews
1019  Then the vendor is approved
1020";
1021        let doc = parse_truth_document(content).unwrap();
1022        let report = simulate(&doc, &SimulationConfig::default());
1023        assert_eq!(report.verdict, Verdict::WillNotConverge);
1024        assert!(
1025            report
1026                .findings
1027                .iter()
1028                .any(|f| f.message.contains("approver has nothing to review"))
1029        );
1030    }
1031
1032    #[test]
1033    fn constraint_without_authority_warns() {
1034        let doc = TruthDocument {
1035            governance: TruthGovernance {
1036                intent: Some(IntentBlock {
1037                    outcome: Some("Do it".into()),
1038                    goal: None,
1039                }),
1040                authority: None,
1041                constraint: Some(ConstraintBlock {
1042                    budget: vec!["100k".into()],
1043                    cost_limit: vec![],
1044                    must_not: vec![],
1045                }),
1046                evidence: Some(EvidenceBlock {
1047                    requires: vec!["proof".into()],
1048                    provenance: vec![],
1049                    audit: vec!["log".into()],
1050                }),
1051                exception: None,
1052            },
1053            gherkin: "Scenario: Test\n  Given a state\n  When action\n  Then result".into(),
1054        };
1055        let config = SimulationConfig {
1056            require_authority: false,
1057            check_vendor_selection: false,
1058            ..SimulationConfig::default()
1059        };
1060        let report = simulate(&doc, &config);
1061        assert!(
1062            report
1063                .findings
1064                .iter()
1065                .any(|f| f.message.contains("who enforces"))
1066        );
1067    }
1068
1069    // ─── simulate: governance detail checks ───
1070
1071    #[test]
1072    fn intent_without_outcome_warns() {
1073        let doc = TruthDocument {
1074            governance: TruthGovernance {
1075                intent: Some(IntentBlock {
1076                    outcome: None,
1077                    goal: Some("A goal".into()),
1078                }),
1079                authority: Some(AuthorityBlock {
1080                    actor: Some("admin".into()),
1081                    ..AuthorityBlock::default()
1082                }),
1083                constraint: None,
1084                evidence: Some(EvidenceBlock {
1085                    requires: vec!["proof".into()],
1086                    provenance: vec![],
1087                    audit: vec!["log".into()],
1088                }),
1089                exception: None,
1090            },
1091            gherkin: "Scenario: Test\n  Given state\n  When admin acts\n  Then done".into(),
1092        };
1093        let report = simulate(&doc, &SimulationConfig::default());
1094        assert!(
1095            report
1096                .findings
1097                .iter()
1098                .any(|f| f.message.contains("missing Outcome"))
1099        );
1100    }
1101
1102    #[test]
1103    fn authority_without_actor_warns() {
1104        let doc = TruthDocument {
1105            governance: TruthGovernance {
1106                intent: Some(IntentBlock {
1107                    outcome: Some("Do it".into()),
1108                    goal: None,
1109                }),
1110                authority: Some(AuthorityBlock::default()),
1111                constraint: None,
1112                evidence: Some(EvidenceBlock {
1113                    requires: vec!["proof".into()],
1114                    provenance: vec![],
1115                    audit: vec!["log".into()],
1116                }),
1117                exception: None,
1118            },
1119            gherkin: "Scenario: Test\n  Given state\n  When action\n  Then done".into(),
1120        };
1121        let report = simulate(&doc, &SimulationConfig::default());
1122        assert!(
1123            report
1124                .findings
1125                .iter()
1126                .any(|f| f.message.contains("missing Actor"))
1127        );
1128    }
1129
1130    #[test]
1131    fn empty_evidence_requires_warns() {
1132        let doc = TruthDocument {
1133            governance: TruthGovernance {
1134                intent: Some(IntentBlock {
1135                    outcome: Some("Do it".into()),
1136                    goal: None,
1137                }),
1138                authority: Some(AuthorityBlock {
1139                    actor: Some("admin".into()),
1140                    ..AuthorityBlock::default()
1141                }),
1142                constraint: None,
1143                evidence: Some(EvidenceBlock {
1144                    requires: vec![],
1145                    provenance: vec![],
1146                    audit: vec!["log".into()],
1147                }),
1148                exception: None,
1149            },
1150            gherkin: "Scenario: Test\n  Given state\n  When admin acts\n  Then done".into(),
1151        };
1152        let report = simulate(&doc, &SimulationConfig::default());
1153        assert!(
1154            report
1155                .findings
1156                .iter()
1157                .any(|f| f.message.contains("no Requires fields"))
1158        );
1159    }
1160
1161    #[test]
1162    fn empty_constraint_block_info() {
1163        let doc = TruthDocument {
1164            governance: TruthGovernance {
1165                intent: Some(IntentBlock {
1166                    outcome: Some("Do it".into()),
1167                    goal: None,
1168                }),
1169                authority: Some(AuthorityBlock {
1170                    actor: Some("admin".into()),
1171                    ..AuthorityBlock::default()
1172                }),
1173                constraint: Some(ConstraintBlock::default()),
1174                evidence: Some(EvidenceBlock {
1175                    requires: vec!["proof".into()],
1176                    provenance: vec![],
1177                    audit: vec!["log".into()],
1178                }),
1179                exception: None,
1180            },
1181            gherkin: "Scenario: Test\n  Given state\n  When admin acts\n  Then done".into(),
1182        };
1183        let report = simulate(&doc, &SimulationConfig::default());
1184        assert!(report.findings.iter().any(|f| {
1185            f.severity == FindingSeverity::Info && f.message.contains("no guardrails")
1186        }));
1187    }
1188
1189    #[test]
1190    fn exception_with_escalation_path() {
1191        let doc = TruthDocument {
1192            governance: TruthGovernance {
1193                intent: Some(IntentBlock {
1194                    outcome: Some("Do it".into()),
1195                    goal: None,
1196                }),
1197                authority: Some(AuthorityBlock {
1198                    actor: Some("admin".into()),
1199                    ..AuthorityBlock::default()
1200                }),
1201                constraint: None,
1202                evidence: Some(EvidenceBlock {
1203                    requires: vec!["proof".into()],
1204                    provenance: vec![],
1205                    audit: vec!["log".into()],
1206                }),
1207                exception: Some(ExceptionBlock {
1208                    escalates_to: vec!["ceo".into()],
1209                    requires: vec![],
1210                }),
1211            },
1212            gherkin: "Scenario: Test\n  Given state\n  When admin acts\n  Then done".into(),
1213        };
1214        let report = simulate(&doc, &SimulationConfig::default());
1215        assert!(report.governance_coverage.has_exception);
1216        assert!(report.governance_coverage.has_escalation_path);
1217    }
1218
1219    // ─── SimulationConfig variations ───
1220
1221    #[test]
1222    fn relaxed_config_allows_missing_governance() {
1223        let content = r"Truth: Bare minimum
1224
1225Scenario: Just do it
1226  Given a state
1227  When an action
1228  Then a result
1229";
1230        let doc = parse_truth_document(content).unwrap();
1231        let config = SimulationConfig {
1232            require_intent: false,
1233            require_authority: false,
1234            require_evidence: false,
1235            require_assertions: false,
1236            check_resource_availability: false,
1237            check_vendor_selection: false,
1238        };
1239        let report = simulate(&doc, &config);
1240        let has_errors = report
1241            .findings
1242            .iter()
1243            .any(|f| f.severity == FindingSeverity::Error);
1244        assert!(!has_errors);
1245        assert_ne!(report.verdict, Verdict::WillNotConverge);
1246    }
1247
1248    #[test]
1249    fn default_config_is_strict() {
1250        let config = SimulationConfig::default();
1251        assert!(config.require_intent);
1252        assert!(config.require_authority);
1253        assert!(config.require_evidence);
1254        assert!(config.require_assertions);
1255        assert!(config.check_resource_availability);
1256    }
1257
1258    // ─── simulate_spec convenience ───
1259
1260    #[test]
1261    fn simulate_spec_convenience() {
1262        let report = simulate_spec(minimal_valid_spec(), &SimulationConfig::default()).unwrap();
1263        assert!(report.can_converge());
1264    }
1265
1266    #[test]
1267    fn simulate_spec_garbage_input() {
1268        let result = simulate_spec(
1269            "this is not a truth spec at all",
1270            &SimulationConfig::default(),
1271        );
1272        // Parser may be lenient; either an error or a WillNotConverge verdict is acceptable
1273        match result {
1274            Err(_) => {}
1275            Ok(report) => assert_eq!(report.verdict, Verdict::WillNotConverge),
1276        }
1277    }
1278
1279    #[test]
1280    fn simulate_spec_empty_string() {
1281        let result = simulate_spec("", &SimulationConfig::default());
1282        match result {
1283            Err(_) => {}
1284            Ok(report) => assert_eq!(report.verdict, Verdict::WillNotConverge),
1285        }
1286    }
1287
1288    // ─── resource checks ───
1289
1290    #[test]
1291    fn undeclared_evidence_like_resource_warns() {
1292        let content = r"Truth: Resource mismatch
1293
1294Intent:
1295  Outcome: Check resources.
1296
1297Authority:
1298  Actor: admin
1299
1300Evidence:
1301  Requires: security_assessment
1302  Audit: decision_log
1303
1304Scenario: Uses undeclared evidence
1305  Given a vendor
1306  When admin reviews the compliance_report
1307  Then the security_assessment is valid
1308";
1309        let doc = parse_truth_document(content).unwrap();
1310        let report = simulate(&doc, &SimulationConfig::default());
1311        assert!(
1312            report
1313                .resource_summary
1314                .missing
1315                .contains(&"compliance_report".to_string())
1316        );
1317    }
1318
1319    #[test]
1320    fn actor_not_referenced_in_scenarios_info() {
1321        let content = r"Truth: Unused actor
1322
1323Intent:
1324  Outcome: Something.
1325
1326Authority:
1327  Actor: mysterious_committee
1328
1329Evidence:
1330  Requires: proof
1331
1332Scenario: Nobody calls the actor
1333  Given a state
1334  When something happens
1335  Then it works
1336";
1337        let doc = parse_truth_document(content).unwrap();
1338        let report = simulate(&doc, &SimulationConfig::default());
1339        assert!(report.findings.iter().any(|f| {
1340            f.severity == FindingSeverity::Info
1341                && f.message.contains("mysterious_committee")
1342                && f.message.contains("not referenced")
1343        }));
1344    }
1345
1346    // ─── multiple scenarios ───
1347
1348    #[test]
1349    fn multiple_scenarios_counted() {
1350        let content = r"Truth: Multi-scenario
1351
1352Intent:
1353  Outcome: Test multiple.
1354
1355Authority:
1356  Actor: admin
1357
1358Evidence:
1359  Requires: proof
1360
1361Scenario: First
1362  Given state
1363  When admin acts
1364  Then result
1365
1366Scenario: Second
1367  Given another state
1368  When admin acts again
1369  Then another result
1370";
1371        let doc = parse_truth_document(content).unwrap();
1372        let report = simulate(&doc, &SimulationConfig::default());
1373        assert_eq!(report.scenario_count, 2);
1374    }
1375
1376    // ─── only governance, no scenarios ───
1377
1378    #[test]
1379    fn spec_with_only_governance_no_scenarios() {
1380        let doc = TruthDocument {
1381            governance: TruthGovernance {
1382                intent: Some(IntentBlock {
1383                    outcome: Some("Goal".into()),
1384                    goal: None,
1385                }),
1386                authority: Some(AuthorityBlock {
1387                    actor: Some("admin".into()),
1388                    ..AuthorityBlock::default()
1389                }),
1390                constraint: None,
1391                evidence: Some(EvidenceBlock {
1392                    requires: vec!["proof".into()],
1393                    provenance: vec![],
1394                    audit: vec![],
1395                }),
1396                exception: None,
1397            },
1398            gherkin: String::new(),
1399        };
1400        let report = simulate(&doc, &SimulationConfig::default());
1401        assert_eq!(report.verdict, Verdict::WillNotConverge);
1402        assert_eq!(report.scenario_count, 0);
1403    }
1404
1405    // ─── nil governance (all None) ───
1406
1407    #[test]
1408    fn nil_governance_with_relaxed_config() {
1409        let content = r"Truth: Nil governance
1410
1411Scenario: Solo
1412  Given x
1413  When y
1414  Then z
1415";
1416        let doc = parse_truth_document(content).unwrap();
1417        let config = SimulationConfig {
1418            require_intent: false,
1419            require_authority: false,
1420            require_evidence: false,
1421            require_assertions: true,
1422            check_resource_availability: false,
1423            check_vendor_selection: false,
1424        };
1425        let report = simulate(&doc, &config);
1426        assert!(!report.governance_coverage.has_intent);
1427        assert!(!report.governance_coverage.has_authority);
1428        assert!(!report.governance_coverage.has_evidence);
1429        assert_eq!(report.verdict, Verdict::Ready);
1430    }
1431
1432    #[test]
1433    fn nil_governance_with_strict_config() {
1434        let content = r"Truth: Nil governance strict
1435
1436Scenario: Solo
1437  Given x
1438  When y
1439  Then z
1440";
1441        let doc = parse_truth_document(content).unwrap();
1442        let report = simulate(&doc, &SimulationConfig::default());
1443        assert_eq!(report.verdict, Verdict::WillNotConverge);
1444        let error_count = report
1445            .findings
1446            .iter()
1447            .filter(|f| f.severity == FindingSeverity::Error)
1448            .count();
1449        assert!(error_count >= 3); // intent, authority, evidence
1450    }
1451
1452    // ─── vendor selection checks ───
1453
1454    #[test]
1455    fn vendor_spec_detected() {
1456        let doc = parse_truth_document(full_spec()).unwrap();
1457        let report = simulate(&doc, &SimulationConfig::default());
1458        assert!(report.vendor_selection.detected);
1459    }
1460
1461    #[test]
1462    fn vendor_spec_extracts_vendor_names() {
1463        let doc = parse_truth_document(full_spec()).unwrap();
1464        let report = simulate(&doc, &SimulationConfig::default());
1465        assert!(
1466            report
1467                .vendor_selection
1468                .vendor_references
1469                .contains(&"Acme AI".to_string())
1470        );
1471        assert!(
1472            report
1473                .vendor_selection
1474                .vendor_references
1475                .contains(&"Beta ML".to_string())
1476        );
1477    }
1478
1479    #[test]
1480    fn vendor_spec_counts_evaluation_dimensions() {
1481        let doc = parse_truth_document(full_spec()).unwrap();
1482        let report = simulate(&doc, &SimulationConfig::default());
1483        assert!(report.vendor_selection.evaluation_dimensions >= 2);
1484    }
1485
1486    #[test]
1487    fn vendor_spec_detects_approval_gate() {
1488        let doc = parse_truth_document(full_spec()).unwrap();
1489        let report = simulate(&doc, &SimulationConfig::default());
1490        assert!(report.vendor_selection.has_commitment_gate);
1491    }
1492
1493    #[test]
1494    fn non_vendor_spec_not_detected() {
1495        let doc = parse_truth_document(minimal_valid_spec()).unwrap();
1496        let report = simulate(&doc, &SimulationConfig::default());
1497        assert!(!report.vendor_selection.detected);
1498    }
1499
1500    #[test]
1501    fn vendor_spec_few_dimensions_warns() {
1502        let content = r#"Truth: Thin vendor eval
1503
1504Intent:
1505  Outcome: Select a vendor.
1506
1507Authority:
1508  Actor: admin
1509  Requires Approval: commitment
1510
1511Evidence:
1512  Requires: proof
1513
1514Scenario: Pick a vendor
1515  Given vendors "Acme"
1516  When evaluated
1517  Then a recommendation is produced
1518"#;
1519        let doc = parse_truth_document(content).unwrap();
1520        let report = simulate(&doc, &SimulationConfig::default());
1521        assert!(report.vendor_selection.detected);
1522        assert!(report.findings.iter().any(|f| {
1523            f.category == "vendor-selection" && f.message.contains("evaluation dimension")
1524        }));
1525    }
1526
1527    #[test]
1528    fn vendor_spec_no_ranking_warns() {
1529        let content = r#"Truth: No ranking vendor eval
1530
1531Intent:
1532  Outcome: Select a vendor with compliance and cost and risk analysis.
1533
1534Authority:
1535  Actor: board
1536  Requires Approval: commitment
1537
1538Evidence:
1539  Requires: compliance_report
1540
1541Scenario: Evaluate vendors
1542  Given vendors "Acme, Beta, Gamma"
1543  When the board evaluates
1544  Then all vendors are screened
1545"#;
1546        let doc = parse_truth_document(content).unwrap();
1547        let report = simulate(&doc, &SimulationConfig::default());
1548        assert!(report.vendor_selection.detected);
1549        assert!(
1550            report
1551                .findings
1552                .iter()
1553                .any(|f| { f.category == "vendor-selection" && f.message.contains("ranking") })
1554        );
1555    }
1556
1557    #[test]
1558    fn vendor_spec_no_approval_gate_warns() {
1559        let content = r#"Truth: No approval vendor eval
1560
1561Intent:
1562  Outcome: Select a vendor with compliance and cost and risk.
1563
1564Authority:
1565  Actor: admin
1566
1567Evidence:
1568  Requires: report
1569
1570Scenario: Pick vendor
1571  Given vendors "Acme, Beta, Gamma"
1572  When evaluated
1573  Then a ranked shortlist is produced
1574"#;
1575        let doc = parse_truth_document(content).unwrap();
1576        let report = simulate(&doc, &SimulationConfig::default());
1577        assert!(report.vendor_selection.detected);
1578        assert!(report.findings.iter().any(|f| {
1579            f.category == "vendor-selection" && f.message.contains("commitment approval gate")
1580        }));
1581    }
1582
1583    #[test]
1584    fn vendor_check_disabled() {
1585        let content = r#"Truth: Vendor eval
1586
1587Intent:
1588  Outcome: Select a vendor.
1589
1590Authority:
1591  Actor: admin
1592
1593Evidence:
1594  Requires: proof
1595
1596Scenario: Quick
1597  Given vendors "A"
1598  When checked
1599  Then done
1600"#;
1601        let doc = parse_truth_document(content).unwrap();
1602        let config = SimulationConfig {
1603            check_vendor_selection: false,
1604            ..SimulationConfig::default()
1605        };
1606        let report = simulate(&doc, &config);
1607        assert!(!report.vendor_selection.detected);
1608        assert!(
1609            !report
1610                .findings
1611                .iter()
1612                .any(|f| f.category == "vendor-selection")
1613        );
1614    }
1615
1616    #[test]
1617    fn vendor_spec_complete_no_vendor_warnings() {
1618        let content = r#"Truth: Complete vendor selection
1619
1620Intent:
1621  Outcome: Select a vendor with compliance, cost, risk, security, and capability analysis.
1622
1623Authority:
1624  Actor: governance_review_board
1625  Requires Approval: vendor_commitment
1626
1627Constraint:
1628  Cost Limit: annual spend within budget.
1629
1630Evidence:
1631  Requires: compliance_assessment
1632  Requires: risk_assessment
1633  Requires: cost_analysis
1634  Audit: decision_log
1635
1636Scenario: Full evaluation
1637  Given vendors "Acme AI, Beta ML, Gamma LLM"
1638  And each vendor has compliance and risk data
1639  When the governance_review_board evaluates
1640  Then a ranked shortlist is produced
1641  And the recommendation has evidence from all criteria
1642"#;
1643        let doc = parse_truth_document(content).unwrap();
1644        let report = simulate(&doc, &SimulationConfig::default());
1645        assert!(report.vendor_selection.detected);
1646        assert!(report.vendor_selection.evaluation_dimensions >= 3);
1647        assert!(report.vendor_selection.has_ranking_criterion);
1648        assert!(report.vendor_selection.has_commitment_gate);
1649        assert_eq!(report.vendor_selection.vendor_references.len(), 3);
1650        assert!(!report.findings.iter().any(|f| {
1651            f.category == "vendor-selection" && f.severity == FindingSeverity::Warning
1652        }));
1653    }
1654}