Skip to main content

telltale_bridge/
protocol_machine_runner.rs

1//! Lean protocol-machine runner wrapper.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::BTreeMap;
6use std::io::{Read, Write};
7use std::path::{Path, PathBuf};
8use std::process::{Child, Command, Output, Stdio};
9use std::thread;
10use std::time::{Duration, Instant};
11use thiserror::Error;
12
13use crate::protocol_machine_trace::{
14    normalize_semantic_audit, semantic_audits_equivalent, EffectTraceEvent,
15    OutputConditionTraceEvent,
16};
17use crate::runner::ChoreographyJson;
18use crate::semantic_objects::{ProtocolMachineSemanticObjects, TickedObsEvent};
19use crate::sim_reference::{
20    SimRunInput, SimRunOutput, SimTraceValidation, SimulationStructuredError,
21};
22use telltale_machine::{
23    EffectExchangeRecord, ProtocolMachineRefinementSlice, ReconfigurationEvent,
24    ReconfigurationPolicy,
25};
26
27#[path = "protocol_machine_runner_json_parsing.rs"]
28mod parsing;
29use parsing::{
30    parse_protocol_machine_run_output_strict, parse_required_valid, parse_sim_run_output,
31    parse_sim_trace_validation, parse_structured_errors, simulation_trace_payload,
32};
33
34/// Errors from Lean protocol-machine runner operations.
35#[derive(Debug, Error)]
36pub enum ProtocolMachineRunnerError {
37    /// The protocol-machine runner binary was not found at the expected path.
38    #[error("protocol-machine runner binary not found at {0}")]
39    BinaryNotFound(PathBuf),
40    /// Failed to create a temporary file for JSON exchange.
41    #[error("Failed to create temp file: {0}")]
42    TempFileError(#[from] std::io::Error),
43    /// The Lean process exited with a non-zero status.
44    #[error("protocol-machine runner failed with exit code {code}: {stderr}")]
45    ProcessFailed {
46        /// Exit code from the process.
47        code: i32,
48        /// Standard error output.
49        stderr: String,
50    },
51    /// Failed to parse Lean output or JSON.
52    #[error("Failed to parse protocol-machine runner output: {0}")]
53    ParseError(String),
54    /// Protocol-machine runner process exceeded the configured timeout.
55    #[error("protocol-machine runner operation '{operation}' timed out after {timeout_ms}ms")]
56    TimedOut {
57        /// Operation name associated with the process invocation.
58        operation: String,
59        /// Timeout in milliseconds.
60        timeout_ms: u64,
61    },
62}
63
64/// Input JSON for the protocol-machine runner.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ProtocolMachineRunInput {
67    /// Schema version for this payload.
68    #[serde(deserialize_with = "crate::schema::deserialize_schema_version")]
69    pub schema_version: String,
70    /// Choreographies to load.
71    pub choreographies: Vec<ChoreographyJson>,
72    /// Concurrency level.
73    pub concurrency: u64,
74    /// Maximum scheduler rounds.
75    pub max_steps: u64,
76}
77
78/// One session status entry from the protocol-machine runner.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub struct ProtocolMachineSessionStatus {
81    /// Schema version for this payload.
82    #[serde(deserialize_with = "crate::schema::deserialize_schema_version")]
83    pub schema_version: String,
84    /// Session id.
85    pub sid: u64,
86    /// Terminal flag.
87    pub terminal: bool,
88}
89
90/// One semantic-audit event from the protocol-machine runner.
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92pub struct ProtocolMachineTraceEvent {
93    /// Schema version for this payload.
94    #[serde(deserialize_with = "crate::schema::deserialize_schema_version")]
95    pub schema_version: String,
96    pub kind: String,
97    pub tick: u64,
98    #[serde(default)]
99    pub session: Option<u64>,
100    #[serde(default)]
101    pub sender: Option<String>,
102    #[serde(default)]
103    pub receiver: Option<String>,
104    #[serde(default)]
105    pub label: Option<String>,
106    #[serde(default)]
107    pub role: Option<String>,
108    #[serde(default)]
109    pub target: Option<String>,
110    #[serde(default)]
111    pub permitted: Option<bool>,
112    #[serde(default)]
113    pub epoch: Option<u64>,
114    #[serde(default)]
115    pub ghost: Option<u64>,
116    #[serde(default)]
117    pub from: Option<u64>,
118    #[serde(default)]
119    pub to: Option<u64>,
120    #[serde(default)]
121    pub predicate_ref: Option<String>,
122    #[serde(default)]
123    pub witness_ref: Option<String>,
124    #[serde(default)]
125    pub output_digest: Option<String>,
126    #[serde(default)]
127    pub passed: Option<bool>,
128    #[serde(default)]
129    pub reason: Option<String>,
130}
131
132/// One scheduler-step state entry from the protocol-machine runner.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ProtocolMachineStepState {
135    /// Step index in execution order.
136    #[serde(default)]
137    pub step_index: u64,
138    /// Concrete pre-step runtime state slice exported by the theorem-side runner.
139    #[serde(default)]
140    pub pre_state: Option<ProtocolMachineRefinementSlice>,
141    /// Concrete post-step runtime state slice exported by the theorem-side runner.
142    #[serde(default)]
143    pub post_state: Option<ProtocolMachineRefinementSlice>,
144    /// Coroutine selected for this step, when available.
145    #[serde(default)]
146    pub selected_coro: Option<u64>,
147    /// Program counter selected for this step, when available.
148    #[serde(default)]
149    pub selected_pc: Option<u64>,
150    /// Lean-side selected endpoint local type snapshot for this step.
151    #[serde(default)]
152    pub selected_type: Option<Value>,
153    /// Execution status tag for the selected step.
154    #[serde(default)]
155    pub exec_status: Option<String>,
156    /// Per-session local-type counts after this step.
157    #[serde(default)]
158    pub session_type_counts: BTreeMap<u64, u64>,
159    /// Per-session buffered message counts after this step.
160    #[serde(default)]
161    pub buffered_message_counts: BTreeMap<u64, u64>,
162    /// Global ready-queue order after this step.
163    #[serde(default)]
164    pub ready_queue: Vec<u64>,
165    /// Blocked coroutine ids mapped to coarse reason tags after this step.
166    #[serde(default)]
167    pub blocked: BTreeMap<u64, String>,
168    /// Optional event emitted by this scheduler step.
169    #[serde(default)]
170    pub event: Option<ProtocolMachineTraceEvent>,
171}
172
173/// Output from the protocol-machine runner.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ProtocolMachineRunOutput {
176    /// Schema version for this payload.
177    #[serde(deserialize_with = "crate::schema::deserialize_schema_version")]
178    pub schema_version: String,
179    /// Semantic audit emitted by the protocol machine.
180    pub trace: Vec<ProtocolMachineTraceEvent>,
181    /// Session statuses.
182    pub sessions: Vec<ProtocolMachineSessionStatus>,
183    /// Steps executed.
184    pub steps_executed: u64,
185    /// Concurrency level.
186    pub concurrency: u64,
187    /// Status string.
188    pub status: String,
189    /// Optional effect trace for replay/determinism checks.
190    #[serde(default)]
191    pub effect_trace: Vec<EffectTraceEvent>,
192    /// Canonical typed effect request/outcome exchanges.
193    #[serde(default)]
194    pub effect_exchanges: Vec<EffectExchangeRecord>,
195    /// Optional output-condition verification records.
196    #[serde(default)]
197    pub output_condition_trace: Vec<OutputConditionTraceEvent>,
198    /// Optional per-step scheduler state snapshots.
199    #[serde(default)]
200    pub step_states: Vec<ProtocolMachineStepState>,
201    /// Canonical semantic object export from the protocol machine runtime.
202    #[serde(default)]
203    pub semantic_objects: ProtocolMachineSemanticObjects,
204}
205
206/// Structured Lean-side validation error payload.
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208pub struct LeanStructuredError {
209    pub code: String,
210    #[serde(default)]
211    pub path: Option<String>,
212    pub message: String,
213}
214
215/// Result from Lean trace-validation entrypoint.
216#[derive(Debug, Clone, Serialize)]
217pub struct TraceValidation {
218    pub valid: bool,
219    #[serde(default)]
220    pub errors: Vec<LeanStructuredError>,
221}
222
223/// Result of comparing Rust and Lean protocol-machine executions.
224#[derive(Debug, Clone, Serialize)]
225pub struct ComparisonResult {
226    pub equivalent: bool,
227    pub semantic_audit_equivalent: bool,
228    pub session_statuses_equivalent: bool,
229    pub semantic_handoffs_equivalent: bool,
230    pub invalidation_artifacts_equivalent: bool,
231    pub rust_semantic_audit: Vec<TickedObsEvent<ProtocolMachineTraceEvent>>,
232    pub lean_semantic_audit: Vec<TickedObsEvent<ProtocolMachineTraceEvent>>,
233    #[serde(default)]
234    pub diff: Option<Value>,
235    pub lean_output: ProtocolMachineRunOutput,
236}
237
238/// Result from protocol-bundle invariant verification.
239#[derive(Debug, Clone, Serialize)]
240pub struct InvariantVerificationResult {
241    pub valid: bool,
242    #[serde(default)]
243    pub errors: Vec<LeanStructuredError>,
244    #[serde(default)]
245    pub artifacts: Value,
246}
247
248/// Result from Lean reconfiguration-transition validation entrypoint.
249#[derive(Debug, Clone, Serialize)]
250pub struct ReconfigurationValidationResult {
251    pub valid: bool,
252    #[serde(default)]
253    pub errors: Vec<LeanStructuredError>,
254    pub event: Option<ReconfigurationEvent>,
255}
256
257/// Runner for invoking the Lean protocol-machine runner binary.
258pub struct ProtocolMachineRunner {
259    binary_path: PathBuf,
260}
261
262impl ProtocolMachineRunner {
263    /// Default path to the protocol-machine runner binary (relative to workspace root).
264    pub const DEFAULT_BINARY_PATH: &'static str = "lean/.lake/build/bin/protocol_machine_runner";
265    /// Default path to the protocol-machine validator binary (relative to workspace root).
266    pub const VALIDATOR_BINARY_PATH: &'static str =
267        "lean/.lake/build/bin/protocol_machine_validator";
268    /// Fallback source-backed launcher for the protocol-machine runner.
269    pub const FALLBACK_SCRIPT_PATH: &'static str = "scripts/lean/protocol-machine-runner.sh";
270    /// Fallback source-backed launcher for the protocol-machine validator.
271    pub const VALIDATOR_FALLBACK_SCRIPT_PATH: &'static str =
272        "scripts/lean/protocol-machine-validator.sh";
273    /// Default timeout for protocol-machine runner process invocations.
274    pub const DEFAULT_TIMEOUT_MS: u64 = 300_000;
275
276    fn process_timeout() -> Duration {
277        let ms = std::env::var("TELLTALE_PROTOCOL_MACHINE_TIMEOUT_MS")
278            .ok()
279            .and_then(|raw| raw.parse::<u64>().ok())
280            .unwrap_or(Self::DEFAULT_TIMEOUT_MS);
281        Duration::from_millis(ms.max(1))
282    }
283
284    fn wait_with_timeout(
285        mut child: Child,
286        timeout: Duration,
287        operation: &str,
288    ) -> Result<Output, ProtocolMachineRunnerError> {
289        let stdout_handle = child.stdout.take().map(|mut stdout| {
290            thread::spawn(move || {
291                let mut buf = Vec::new();
292                let _ = stdout.read_to_end(&mut buf);
293                buf
294            })
295        });
296        let stderr_handle = child.stderr.take().map(|mut stderr| {
297            thread::spawn(move || {
298                let mut buf = Vec::new();
299                let _ = stderr.read_to_end(&mut buf);
300                buf
301            })
302        });
303        let start = Instant::now();
304        loop {
305            // bounded: exits on child completion or timeout
306            match child.try_wait()? {
307                Some(status) => {
308                    let stdout = stdout_handle
309                        .map(|handle| handle.join().unwrap_or_default())
310                        .unwrap_or_default();
311                    let stderr = stderr_handle
312                        .map(|handle| handle.join().unwrap_or_default())
313                        .unwrap_or_default();
314                    return Ok(Output {
315                        status,
316                        stdout,
317                        stderr,
318                    });
319                }
320                None => {
321                    if start.elapsed() >= timeout {
322                        if let Err(err) = child.kill() {
323                            eprintln!(
324                                "best-effort child.kill failed during timeout handling: {err}"
325                            );
326                        }
327                        if let Err(err) = child.wait() {
328                            eprintln!(
329                                "best-effort child.wait failed during timeout handling: {err}"
330                            );
331                        }
332                        if let Some(handle) = stdout_handle {
333                            let _ = handle.join();
334                        }
335                        if let Some(handle) = stderr_handle {
336                            let _ = handle.join();
337                        }
338                        return Err(ProtocolMachineRunnerError::TimedOut {
339                            operation: operation.to_string(),
340                            timeout_ms: u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX),
341                        });
342                    }
343                    thread::sleep(Duration::from_millis(10));
344                }
345            }
346        }
347    }
348
349    fn find_workspace_root() -> Option<PathBuf> {
350        let manifest_dir = env!("CARGO_MANIFEST_DIR");
351        let mut path = PathBuf::from(manifest_dir);
352        for _ in 0..5 {
353            if path.join("lean/.lake").is_dir() {
354                return Some(path);
355            }
356            if !path.pop() {
357                break;
358            }
359        }
360        None
361    }
362
363    fn get_binary_path() -> Option<PathBuf> {
364        Self::find_workspace_root().and_then(|root| {
365            let native = root.join(Self::DEFAULT_BINARY_PATH);
366            if native.is_file() {
367                return Some(native);
368            }
369            let fallback = root.join(Self::FALLBACK_SCRIPT_PATH);
370            if fallback.is_file() {
371                return Some(fallback);
372            }
373            None
374        })
375    }
376
377    fn uses_validator(operation: &str) -> bool {
378        matches!(
379            operation,
380            "verifyProtocolBundle" | "validateReconfigurationTransition" | "inspectCapabilityModel"
381        )
382    }
383
384    fn get_validator_path() -> Option<PathBuf> {
385        Self::find_workspace_root().and_then(|root| {
386            let native = root.join(Self::VALIDATOR_BINARY_PATH);
387            if native.is_file() {
388                return Some(native);
389            }
390            let fallback = root.join(Self::VALIDATOR_FALLBACK_SCRIPT_PATH);
391            if fallback.is_file() {
392                return Some(fallback);
393            }
394            None
395        })
396    }
397
398    /// Create a new protocol-machine runner with the default binary path.
399    ///
400    /// # Errors
401    ///
402    /// Returns [`ProtocolMachineRunnerError::BinaryNotFound`] if the binary doesn't exist.
403    pub fn new() -> Result<Self, ProtocolMachineRunnerError> {
404        match Self::get_binary_path() {
405            Some(path) => Ok(Self { binary_path: path }),
406            None => Err(ProtocolMachineRunnerError::BinaryNotFound(PathBuf::from(
407                Self::DEFAULT_BINARY_PATH,
408            ))),
409        }
410    }
411
412    /// Create a protocol-machine runner with a custom binary path.
413    ///
414    /// # Errors
415    ///
416    /// Returns [`ProtocolMachineRunnerError::BinaryNotFound`] if the binary doesn't exist.
417    pub fn with_binary_path(path: impl AsRef<Path>) -> Result<Self, ProtocolMachineRunnerError> {
418        let binary_path = PathBuf::from(path.as_ref());
419        if !binary_path.exists() || !binary_path.is_file() {
420            return Err(ProtocolMachineRunnerError::BinaryNotFound(binary_path));
421        }
422        Ok(Self { binary_path })
423    }
424
425    /// Try to create a runner, returning None if the binary is unavailable.
426    #[must_use]
427    pub fn try_new() -> Option<Self> {
428        Self::new().ok()
429    }
430
431    /// Check if the protocol-machine runner binary is available at the default path.
432    #[must_use]
433    pub fn is_available() -> bool {
434        Self::get_binary_path().is_some()
435    }
436
437    /// Require that the protocol-machine runner binary is available.
438    ///
439    /// # Panics
440    ///
441    /// Panics if the binary is not available.
442    pub fn require_available() {
443        if !Self::is_available() {
444            panic!(
445                "\n\
446                ╔══════════════════════════════════════════════════════════════════╗\n\
447                ║  LEAN PROTOCOL-MACHINE RUNNER REQUIRED                          ║\n\
448                ╠══════════════════════════════════════════════════════════════════╣\n\
449                ║  The Lean protocol-machine runner is required but not found.    ║\n\
450                ║                                                                  ║\n\
451                ║  To build Lean runners:                                          ║\n\
452                ║    cd lean && lake build protocol_machine_runner                 ║\n\
453                ║                                                                  ║\n\
454                ║  Fallback launcher:                                              ║\n\
455                ║    scripts/lean/protocol-machine-runner.sh                       ║\n\
456                ║                                                                  ║\n\
457                ║  Or with Nix:                                                    ║\n\
458                ║    nix develop --command bash -c \"cd lean && lake build protocol_machine_runner\" ║\n\
459                ║                                                                  ║\n\
460                ║  Expected native path: {path}   \n\
461                ╚══════════════════════════════════════════════════════════════════╝\n",
462                path = Self::DEFAULT_BINARY_PATH
463            );
464        }
465    }
466
467    /// Run the protocol-machine runner and return the parsed output.
468    ///
469    /// # Errors
470    ///
471    /// Returns a [`ProtocolMachineRunnerError`] if the process fails or output is invalid.
472    pub fn run(
473        &self,
474        input: &ProtocolMachineRunInput,
475    ) -> Result<ProtocolMachineRunOutput, ProtocolMachineRunnerError> {
476        crate::schema::ensure_supported_schema_version(
477            &input.schema_version,
478            "ProtocolMachineRunInput",
479        )
480        .map_err(ProtocolMachineRunnerError::ParseError)?;
481
482        let payload = serde_json::to_vec(input)
483            .map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))?;
484
485        let mut cmd = Command::new(&self.binary_path)
486            .stdin(Stdio::piped())
487            .stdout(Stdio::piped())
488            .stderr(Stdio::piped())
489            .spawn()
490            .map_err(ProtocolMachineRunnerError::TempFileError)?;
491
492        if let Some(mut stdin) = cmd.stdin.take() {
493            stdin.write_all(&payload)?;
494        }
495
496        let output = Self::wait_with_timeout(cmd, Self::process_timeout(), "run")?;
497
498        if !output.status.success() {
499            return Err(ProtocolMachineRunnerError::ProcessFailed {
500                code: output.status.code().unwrap_or(-1),
501                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
502            });
503        }
504
505        let out_value: Value = serde_json::from_slice(&output.stdout)
506            .map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))?;
507        parse_protocol_machine_run_output_strict(out_value)
508    }
509
510    /// Run the Lean protocol-machine execution entrypoint.
511    ///
512    /// # Errors
513    ///
514    /// Returns an error if the process fails or output is invalid.
515    pub fn run_protocol_machine(
516        &self,
517        input: &ProtocolMachineRunInput,
518    ) -> Result<ProtocolMachineRunOutput, ProtocolMachineRunnerError> {
519        self.run(input)
520    }
521
522    /// Run a generic Lean protocol-machine validation operation.
523    ///
524    /// # Errors
525    ///
526    /// Returns an error if the process fails or output is invalid.
527    pub fn run_validation_operation(
528        &self,
529        operation: &str,
530        payload: &Value,
531    ) -> Result<Value, ProtocolMachineRunnerError> {
532        let input = serde_json::json!({
533            "schema_version": crate::schema::canonical_schema_version(),
534            "operation": operation,
535            "payload": payload,
536        });
537        let bytes = serde_json::to_vec(&input)
538            .map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))?;
539
540        let operation_binary = if Self::uses_validator(operation) {
541            Self::get_validator_path().unwrap_or_else(|| self.binary_path.clone())
542        } else {
543            self.binary_path.clone()
544        };
545
546        let mut cmd = Command::new(&operation_binary)
547            .stdin(Stdio::piped())
548            .stdout(Stdio::piped())
549            .stderr(Stdio::piped())
550            .spawn()
551            .map_err(ProtocolMachineRunnerError::TempFileError)?;
552
553        if let Some(mut stdin) = cmd.stdin.take() {
554            stdin.write_all(&bytes)?;
555        }
556
557        let output = Self::wait_with_timeout(cmd, Self::process_timeout(), operation)?;
558        if !output.status.success() {
559            return Err(ProtocolMachineRunnerError::ProcessFailed {
560                code: output.status.code().unwrap_or(-1),
561                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
562            });
563        }
564        serde_json::from_slice(&output.stdout)
565            .map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))
566    }
567
568    /// Validate a semantic audit against Lean-side protocol-machine checks.
569    ///
570    /// # Errors
571    ///
572    /// Returns an error if Lean invocation fails.
573    pub fn validate_trace(
574        &self,
575        choreographies: &[ChoreographyJson],
576        rust_trace: &[ProtocolMachineTraceEvent],
577    ) -> Result<TraceValidation, ProtocolMachineRunnerError> {
578        let payload = serde_json::json!({
579            "choreographies": choreographies,
580            "trace": rust_trace,
581        });
582        let response = self.run_validation_operation("validateTrace", &payload)?;
583        Ok(TraceValidation {
584            valid: parse_required_valid(&response, "validateTrace")?,
585            errors: parse_structured_errors(&response),
586        })
587    }
588
589    /// Run the Lean reference simulator operation.
590    ///
591    /// # Errors
592    ///
593    /// Returns an error if Lean invocation fails or output cannot be decoded.
594    pub fn run_reference_simulation(
595        &self,
596        input: &SimRunInput,
597    ) -> Result<SimRunOutput, ProtocolMachineRunnerError> {
598        crate::schema::ensure_supported_schema_version(&input.schema_version, "SimRunInput")
599            .map_err(ProtocolMachineRunnerError::ParseError)?;
600        let payload = serde_json::to_value(input)
601            .map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))?;
602        let response = self.run_validation_operation("runSimulation", &payload)?;
603        parse_sim_run_output(response)
604    }
605
606    /// Validate simulator trace output against Lean reference rules.
607    ///
608    /// # Errors
609    ///
610    /// Returns an error if Lean invocation fails.
611    pub fn validate_simulation_trace(
612        &self,
613        input: &SimRunInput,
614        trace: &[ProtocolMachineTraceEvent],
615    ) -> Result<SimTraceValidation, ProtocolMachineRunnerError> {
616        let payload = simulation_trace_payload(input, trace);
617        let response = self.run_validation_operation("validateSimulationTrace", &payload)?;
618        parse_sim_trace_validation(&response)
619    }
620
621    /// Run the same choreography in Lean and compare normalized semantic audits.
622    ///
623    /// # Errors
624    ///
625    /// Returns an error if Lean invocation fails.
626    pub fn compare_execution(
627        &self,
628        choreography: &ChoreographyJson,
629        rust_output: &ProtocolMachineRunOutput,
630    ) -> Result<ComparisonResult, ProtocolMachineRunnerError> {
631        let input = ProtocolMachineRunInput {
632            schema_version: crate::schema::canonical_schema_version(),
633            choreographies: vec![choreography.clone()],
634            concurrency: rust_output.concurrency,
635            max_steps: rust_output.steps_executed.max(1),
636        };
637        let lean_output = self.run_protocol_machine(&input)?;
638
639        let rust_ticked: Vec<TickedObsEvent<ProtocolMachineTraceEvent>> = rust_output
640            .trace
641            .iter()
642            .cloned()
643            .map(|event| TickedObsEvent {
644                tick: event.tick,
645                event,
646            })
647            .collect();
648        let lean_ticked: Vec<TickedObsEvent<ProtocolMachineTraceEvent>> = lean_output
649            .trace
650            .iter()
651            .cloned()
652            .map(|event| TickedObsEvent {
653                tick: event.tick,
654                event,
655            })
656            .collect();
657
658        let rust_normalized = normalize_semantic_audit(&rust_ticked);
659        let lean_normalized = normalize_semantic_audit(&lean_ticked);
660        let semantic_audit_equivalent = semantic_audits_equivalent(&rust_ticked, &lean_ticked);
661        let session_statuses_equivalent = rust_output.sessions == lean_output.sessions;
662        let diff = compute_execution_diff(
663            &rust_normalized,
664            &lean_normalized,
665            &rust_output.sessions,
666            &lean_output.sessions,
667        );
668        let semantic_handoffs_equivalent = rust_output.semantic_objects.semantic_handoffs
669            == lean_output.semantic_objects.semantic_handoffs;
670        let rust_invalidated_effects: Vec<_> = rust_output
671            .semantic_objects
672            .outstanding_effects
673            .iter()
674            .filter(|effect| {
675                matches!(
676                    effect.status,
677                    crate::semantic_objects::OutstandingEffectStatus::Invalidated
678                )
679            })
680            .map(|effect| effect.effect_id)
681            .collect();
682        let lean_invalidated_effects: Vec<_> = lean_output
683            .semantic_objects
684            .outstanding_effects
685            .iter()
686            .filter(|effect| {
687                matches!(
688                    effect.status,
689                    crate::semantic_objects::OutstandingEffectStatus::Invalidated
690                )
691            })
692            .map(|effect| effect.effect_id)
693            .collect();
694        let invalidation_artifacts_equivalent = rust_invalidated_effects
695            == lean_invalidated_effects
696            && rust_output.semantic_objects.transformation_obligations
697                == lean_output.semantic_objects.transformation_obligations;
698
699        Ok(ComparisonResult {
700            equivalent: semantic_audit_equivalent && session_statuses_equivalent,
701            semantic_audit_equivalent,
702            session_statuses_equivalent,
703            semantic_handoffs_equivalent,
704            invalidation_artifacts_equivalent,
705            rust_semantic_audit: rust_normalized,
706            lean_semantic_audit: lean_normalized,
707            diff,
708            lean_output,
709        })
710    }
711
712    /// Verify a typed protocol bundle using Lean-side verification entrypoint.
713    ///
714    /// # Errors
715    ///
716    /// Returns an error if Lean invocation fails.
717    pub fn verify_invariants(
718        &self,
719        bundle: &crate::invariants::ProtocolBundle,
720    ) -> Result<InvariantVerificationResult, ProtocolMachineRunnerError> {
721        let payload = serde_json::to_value(bundle)
722            .map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))?;
723        let response = self.run_validation_operation("verifyProtocolBundle", &payload)?;
724
725        Ok(InvariantVerificationResult {
726            valid: parse_required_valid(&response, "verifyProtocolBundle")?,
727            errors: parse_structured_errors(&response),
728            artifacts: response.get("artifacts").cloned().unwrap_or(Value::Null),
729        })
730    }
731
732    /// Validate one deterministic reconfiguration transition against the Lean reference hook.
733    ///
734    /// # Errors
735    ///
736    /// Returns an error if Lean invocation fails or the returned event cannot be decoded.
737    pub fn validate_reconfiguration_transition(
738        &self,
739        artifact_id: &str,
740        policy: &ReconfigurationPolicy,
741        starting_epoch: u64,
742        previous_members: &[String],
743        next_members: &[String],
744    ) -> Result<ReconfigurationValidationResult, ProtocolMachineRunnerError> {
745        let payload = serde_json::json!({
746            "artifact_id": artifact_id,
747            "policy": policy,
748            "starting_epoch": starting_epoch,
749            "previous_members": previous_members,
750            "next_members": next_members,
751        });
752        let response =
753            self.run_validation_operation("validateReconfigurationTransition", &payload)?;
754        let event = response
755            .get("artifacts")
756            .and_then(|artifacts| artifacts.get("event"))
757            .cloned()
758            .map(serde_json::from_value)
759            .transpose()
760            .map_err(|err| ProtocolMachineRunnerError::ParseError(err.to_string()))?;
761
762        Ok(ReconfigurationValidationResult {
763            valid: parse_required_valid(&response, "validateReconfigurationTransition")?,
764            errors: parse_structured_errors(&response),
765            event,
766        })
767    }
768
769    /// Compare first-class capability/finalization/runtime-upgrade objects against the Lean facade.
770    ///
771    /// # Errors
772    ///
773    /// Returns an error if Lean invocation fails or output cannot be decoded.
774    pub fn inspect_capability_model(
775        &self,
776        payload: &Value,
777    ) -> Result<Value, ProtocolMachineRunnerError> {
778        self.run_validation_operation("inspectCapabilityModel", payload)
779    }
780}
781
782/// Compute a structured diff for two normalized semantic audits.
783#[must_use]
784pub fn compute_trace_diff(
785    rust_trace: &[TickedObsEvent<ProtocolMachineTraceEvent>],
786    lean_trace: &[TickedObsEvent<ProtocolMachineTraceEvent>],
787) -> Option<Value> {
788    if rust_trace == lean_trace {
789        return None;
790    }
791
792    let min_len = rust_trace.len().min(lean_trace.len());
793    for idx in 0..min_len {
794        if rust_trace[idx] != lean_trace[idx] {
795            return Some(serde_json::json!({
796                "kind": "event_mismatch",
797                "index": idx,
798                "rust": rust_trace[idx],
799                "lean": lean_trace[idx],
800                "rust_len": rust_trace.len(),
801                "lean_len": lean_trace.len(),
802            }));
803        }
804    }
805
806    Some(serde_json::json!({
807        "kind": "length_mismatch",
808        "rust_len": rust_trace.len(),
809        "lean_len": lean_trace.len(),
810    }))
811}
812
813fn compute_execution_diff(
814    rust_trace: &[TickedObsEvent<ProtocolMachineTraceEvent>],
815    lean_trace: &[TickedObsEvent<ProtocolMachineTraceEvent>],
816    rust_sessions: &[ProtocolMachineSessionStatus],
817    lean_sessions: &[ProtocolMachineSessionStatus],
818) -> Option<Value> {
819    if let Some(diff) = compute_trace_diff(rust_trace, lean_trace) {
820        return Some(diff);
821    }
822
823    if rust_sessions != lean_sessions {
824        return Some(serde_json::json!({
825            "kind": "session_status_mismatch",
826            "rust": rust_sessions,
827            "lean": lean_sessions,
828        }));
829    }
830
831    None
832}
833
834/// Helper to build a protocol-machine runner input from JSON values.
835///
836/// # Errors
837///
838/// Returns an error if any choreography value cannot be parsed.
839pub fn protocol_machine_input_from_values(
840    choreographies: Vec<Value>,
841    concurrency: u64,
842    max_steps: u64,
843) -> Result<ProtocolMachineRunInput, ProtocolMachineRunnerError> {
844    let mut choreos = Vec::new();
845    for value in choreographies {
846        let choreo: ChoreographyJson = serde_json::from_value(value)
847            .map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))?;
848        choreos.push(choreo);
849    }
850    Ok(ProtocolMachineRunInput {
851        schema_version: crate::schema::canonical_schema_version(),
852        choreographies: choreos,
853        concurrency,
854        max_steps,
855    })
856}
857
858/// Serialize a protocol-machine runner output to JSON for debugging.
859pub fn output_to_json(
860    output: &ProtocolMachineRunOutput,
861) -> Result<Value, ProtocolMachineRunnerError> {
862    serde_json::to_value(output).map_err(|e| ProtocolMachineRunnerError::ParseError(e.to_string()))
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868    use std::process::Command;
869    use std::time::Duration;
870
871    fn trace_event(kind: &str, tick: u64, session: Option<u64>) -> ProtocolMachineTraceEvent {
872        ProtocolMachineTraceEvent {
873            schema_version: crate::schema::canonical_schema_version(),
874            kind: kind.to_string(),
875            tick,
876            session,
877            sender: None,
878            receiver: None,
879            label: None,
880            role: None,
881            target: None,
882            permitted: None,
883            epoch: None,
884            ghost: None,
885            from: None,
886            to: None,
887            predicate_ref: None,
888            witness_ref: None,
889            output_digest: None,
890            passed: None,
891            reason: None,
892        }
893    }
894
895    #[test]
896    fn compute_trace_diff_none_for_equal_traces() {
897        let trace = vec![TickedObsEvent {
898            tick: 0,
899            event: trace_event("sent", 1, Some(0)),
900        }];
901        assert!(compute_trace_diff(&trace, &trace).is_none());
902    }
903
904    #[test]
905    fn compute_trace_diff_reports_event_mismatch() {
906        let rust_trace = vec![TickedObsEvent {
907            tick: 0,
908            event: trace_event("sent", 1, Some(0)),
909        }];
910        let lean_trace = vec![TickedObsEvent {
911            tick: 0,
912            event: trace_event("received", 1, Some(0)),
913        }];
914        let diff = compute_trace_diff(&rust_trace, &lean_trace).expect("expected diff");
915        assert_eq!(diff["kind"], "event_mismatch");
916        assert_eq!(diff["index"], 0);
917    }
918
919    #[test]
920    fn parse_structured_errors_reads_codes_and_paths() {
921        let response = serde_json::json!({
922            "errors": [
923                { "code": "trace.mismatch", "path": "trace[0]", "message": "mismatch" }
924            ]
925        });
926        let errors = parse_structured_errors(&response);
927        assert_eq!(errors.len(), 1);
928        assert_eq!(errors[0].code, "trace.mismatch");
929        assert_eq!(errors[0].path.as_deref(), Some("trace[0]"));
930    }
931
932    #[test]
933    fn parse_sim_trace_validation_reads_errors_and_artifacts() {
934        let response = serde_json::json!({
935            "valid": false,
936            "errors": [
937                { "code": "sim.trace.mismatch", "path": "trace[1]", "message": "mismatch" }
938            ],
939            "artifacts": { "kind": "diff" }
940        });
941        let parsed = parse_sim_trace_validation(&response).expect("parse simulation validation");
942        assert!(!parsed.valid);
943        assert_eq!(parsed.errors.len(), 1);
944        assert_eq!(parsed.errors[0].code, "sim.trace.mismatch");
945        assert_eq!(parsed.errors[0].path.as_deref(), Some("trace[1]"));
946        assert_eq!(parsed.artifacts["kind"], "diff");
947    }
948
949    #[test]
950    fn parse_required_valid_rejects_missing_or_non_boolean() {
951        let missing = serde_json::json!({
952            "errors": []
953        });
954        let missing_err =
955            parse_required_valid(&missing, "validateTrace").expect_err("missing valid must fail");
956        assert!(matches!(
957            missing_err,
958            ProtocolMachineRunnerError::ParseError(_)
959        ));
960
961        let wrong_type = serde_json::json!({
962            "valid": "true"
963        });
964        let wrong_type_err = parse_required_valid(&wrong_type, "validateTrace")
965            .expect_err("non-boolean valid must fail");
966        assert!(matches!(
967            wrong_type_err,
968            ProtocolMachineRunnerError::ParseError(_)
969        ));
970    }
971
972    #[test]
973    fn parse_sim_run_output_checks_schema_version() {
974        let payload = serde_json::json!({
975            "schema_version": crate::schema::canonical_schema_version(),
976            "trace": [],
977            "violations": [],
978            "artifacts": {}
979        });
980        let parsed = parse_sim_run_output(payload).expect("parse sim run output");
981        assert_eq!(
982            parsed.schema_version,
983            crate::schema::canonical_schema_version()
984        );
985    }
986
987    #[test]
988    fn simulation_trace_payload_has_expected_shape() {
989        let input = SimRunInput {
990            schema_version: crate::schema::canonical_schema_version(),
991            scenario: serde_json::json!({ "kind": "unit-test" }),
992            global_type: serde_json::json!({ "tag": "end" }),
993            local_types: std::collections::BTreeMap::new(),
994            initial_states: std::collections::BTreeMap::new(),
995        };
996        let trace = vec![trace_event("sent", 1, Some(0))];
997        let payload = simulation_trace_payload(&input, &trace);
998        assert_eq!(payload["input"]["schema_version"], input.schema_version);
999        assert!(payload["trace"].is_array());
1000        assert_eq!(payload["trace"][0]["kind"], "sent");
1001    }
1002
1003    #[test]
1004    fn compute_execution_diff_rejects_session_status_order_mismatch_exactly() {
1005        let rust_statuses = vec![
1006            ProtocolMachineSessionStatus {
1007                schema_version: crate::schema::canonical_schema_version(),
1008                sid: 3,
1009                terminal: true,
1010            },
1011            ProtocolMachineSessionStatus {
1012                schema_version: crate::schema::canonical_schema_version(),
1013                sid: 1,
1014                terminal: false,
1015            },
1016        ];
1017        let lean_statuses = vec![
1018            ProtocolMachineSessionStatus {
1019                schema_version: crate::schema::canonical_schema_version(),
1020                sid: 1,
1021                terminal: false,
1022            },
1023            ProtocolMachineSessionStatus {
1024                schema_version: crate::schema::canonical_schema_version(),
1025                sid: 3,
1026                terminal: true,
1027            },
1028        ];
1029
1030        let diff = compute_execution_diff(&[], &[], &rust_statuses, &lean_statuses)
1031            .expect("exact comparison must reject session-order drift");
1032        assert_eq!(diff["kind"], "session_status_mismatch");
1033    }
1034
1035    #[test]
1036    fn wait_with_timeout_returns_timeout_error() {
1037        let child = Command::new("sh")
1038            .arg("-c")
1039            .arg("sleep 1")
1040            .spawn()
1041            .expect("spawn sleep");
1042        let result = ProtocolMachineRunner::wait_with_timeout(
1043            child,
1044            Duration::from_millis(10),
1045            "test_sleep",
1046        );
1047        assert!(matches!(
1048            result,
1049            Err(ProtocolMachineRunnerError::TimedOut { .. })
1050        ));
1051    }
1052}