1use crate::gherkin::{ValidationError, extract_all_metas, preprocess_truths};
26use crate::truths::{TruthDocument, TruthGovernance};
27use sha2::{Digest, Sha256};
28use std::collections::BTreeSet;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct SimulationConfig {
33 pub require_intent: bool,
35 pub require_authority: bool,
37 pub require_evidence: bool,
39 pub require_assertions: bool,
41 pub check_resource_availability: bool,
43 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
77pub enum DomainProfile {
78 VendorSelection,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum Verdict {
85 Ready,
87 Risky,
89 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#[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#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct VendorSelectionCoverage {
133 pub detected: bool,
135 pub evaluation_dimensions: usize,
137 pub vendor_references: Vec<String>,
139 pub has_ranking_criterion: bool,
141 pub has_commitment_gate: bool,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum DomainProfileCoverage {
148 VendorSelection(VendorSelectionCoverage),
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct DomainProfileReport {
154 pub profile: DomainProfile,
155 pub findings: Vec<SimulationFinding>,
156 pub coverage: DomainProfileCoverage,
157}
158
159#[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 pub fn can_converge(&self) -> bool {
174 self.verdict != Verdict::WillNotConverge
175 }
176
177 #[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#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
206pub struct ResourceSummary {
207 pub declared_evidence: Vec<String>,
209 pub referenced_in_scenarios: Vec<String>,
211 pub missing: Vec<String>,
213}
214
215#[derive(Debug, Clone, Default, PartialEq, Eq)]
217pub struct DeterministicTrace {
218 pub trace_hash: String,
220 pub step_count: usize,
222 pub replayable: bool,
224 pub non_replayable_steps: Vec<TraceStep>,
226}
227
228#[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
237pub 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
280pub 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 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 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 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 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 if let Some(exception) = &gov.exception {
388 coverage.has_exception = true;
389 coverage.has_escalation_path = !exception.escalates_to.is_empty();
390 }
391
392 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 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 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 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 if let Some(evidence) = &gov.evidence {
486 summary.declared_evidence.clone_from(&evidence.requires);
487 }
488
489 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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); }
1947
1948 #[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}