Skip to main content

telltale_lean_bridge/
equivalence.rs

1//! Equivalence Checking for Rust ↔ Lean Session Type Algorithms
2//!
3//! This module provides infrastructure for true equivalence testing between
4//! Rust and Lean implementations. Unlike conformance testing (which only verifies
5//! "Lean accepts Rust output"), equivalence testing verifies "Rust produces
6//! the same output as Lean".
7//!
8//! # Architecture
9//!
10//! The module supports two modes:
11//!
12//! 1. **Golden File Mode** - Fast comparison against pre-computed Lean outputs.
13//!    No Lean runtime required. Used for regular CI.
14//!
15//! 2. **Live Lean Mode** - Direct comparison against Lean runner output.
16//!    Requires built Lean binary. Used for verification and golden regeneration.
17//!
18//! # Example
19//!
20//! ```ignore
21//! use telltale_lean_bridge::equivalence::{EquivalenceChecker, GoldenDir};
22//!
23//! // Fast golden file testing
24//! let checker = EquivalenceChecker::with_golden_dir("golden");
25//! let result = checker.check_projection_against_golden(&global, "Alice")?;
26//! assert!(result.equivalent);
27//!
28//! // Live Lean testing (when available)
29//! if let Some(checker) = EquivalenceChecker::try_with_lean() {
30//!     let result = checker.check_projection_against_lean(&global, "Alice")?;
31//!     assert!(result.equivalent);
32//! }
33//! ```
34
35use crate::export::{global_to_json, local_to_json};
36use crate::import::ImportError;
37use crate::runner::{LeanRunner, LeanRunnerError};
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40use std::collections::BTreeSet;
41use std::collections::HashMap;
42use std::path::{Path, PathBuf};
43use telltale_theory::projection::{project, ProjectionError};
44use telltale_types::GlobalType;
45use thiserror::Error;
46
47#[path = "equivalence_golden_cases.rs"]
48mod golden;
49
50/// Errors from equivalence checking operations.
51#[derive(Debug, Error)]
52pub enum EquivalenceError {
53    /// Failed to read golden file.
54    #[error("Golden file not found: {0}")]
55    GoldenNotFound(PathBuf),
56
57    /// Failed to parse golden file.
58    #[error("Failed to parse golden file: {0}")]
59    ParseError(String),
60
61    /// Lean runner error.
62    #[error("Lean runner error: {0}")]
63    LeanError(#[from] LeanRunnerError),
64
65    /// Import error.
66    #[error("Import error: {0}")]
67    ImportError(#[from] ImportError),
68
69    /// Projection error.
70    #[error("Projection error: {0}")]
71    ProjectionError(#[from] ProjectionError),
72
73    /// IO error.
74    #[error("IO error: {0}")]
75    IoError(#[from] std::io::Error),
76
77    /// JSON error.
78    #[error("JSON error: {0}")]
79    JsonError(#[from] serde_json::Error),
80
81    /// The Lean runner is not available.
82    #[error("Lean runner not available - build with: cd lean && lake build telltale_validator")]
83    LeanNotAvailable,
84
85    /// Golden file mismatch detected.
86    #[error("Golden file drift detected: {path}")]
87    GoldenDrift { path: PathBuf },
88}
89
90/// Result of an equivalence check.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct EquivalenceResult {
93    /// Schema version for this payload.
94    #[serde(default = "crate::schema::default_schema_version")]
95    pub schema_version: String,
96    /// Whether the Rust output matches the expected output.
97    pub equivalent: bool,
98
99    /// The Rust-computed local type (serialized).
100    pub rust_output: Value,
101
102    /// The expected local type (from golden file or Lean).
103    pub expected_output: Value,
104
105    /// Human-readable diff if not equivalent.
106    pub diff: Option<String>,
107
108    /// The role this check was performed for.
109    pub role: String,
110}
111
112impl EquivalenceResult {
113    /// Create a successful (equivalent) result.
114    pub fn success(role: impl Into<String>, output: Value) -> Self {
115        Self {
116            schema_version: crate::schema::default_schema_version(),
117            equivalent: true,
118            rust_output: output.clone(),
119            expected_output: output,
120            diff: None,
121            role: role.into(),
122        }
123    }
124
125    /// Create a failed (non-equivalent) result with diff.
126    pub fn failure(
127        role: impl Into<String>,
128        rust_output: Value,
129        expected_output: Value,
130        diff: String,
131    ) -> Self {
132        Self {
133            schema_version: crate::schema::default_schema_version(),
134            equivalent: false,
135            rust_output,
136            expected_output,
137            diff: Some(diff),
138            role: role.into(),
139        }
140    }
141}
142
143/// A bundle of golden files for a single test case.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct GoldenBundle {
146    /// Schema version for this payload.
147    #[serde(default = "crate::schema::default_schema_version")]
148    pub schema_version: String,
149    /// The input GlobalType.
150    pub input: Value,
151
152    /// Map of role name to expected LocalTypeR.
153    pub projections: HashMap<String, Value>,
154
155    /// Optional coherence check result.
156    pub coherence: Option<CoherenceBundle>,
157}
158
159/// Coherence check results from Lean.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct CoherenceBundle {
162    /// Schema version for this payload.
163    #[serde(default = "crate::schema::default_schema_version")]
164    pub schema_version: String,
165    pub linear: bool,
166    pub size: bool,
167    pub action: bool,
168    pub uniq_labels: bool,
169    pub projectable: bool,
170    pub good: bool,
171    pub is_coherent: bool,
172}
173
174/// Configuration for the equivalence checker.
175#[derive(Debug, Clone)]
176pub struct EquivalenceConfig {
177    /// Path to the golden files directory.
178    pub golden_dir: PathBuf,
179
180    /// Whether to use strict comparison (exact JSON match vs structural).
181    pub strict: bool,
182}
183
184impl Default for EquivalenceConfig {
185    fn default() -> Self {
186        Self {
187            golden_dir: PathBuf::from("golden"),
188            strict: false,
189        }
190    }
191}
192
193/// Equivalence checker for comparing Rust and Lean outputs.
194pub struct EquivalenceChecker {
195    config: EquivalenceConfig,
196    runner: Option<LeanRunner>,
197}
198
199/// Strictness selection for equivalence comparisons.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum Strictness {
202    /// Exact JSON/trace matching.
203    Strict,
204    /// Structural matching with tolerated non-semantic differences.
205    Lenient,
206}
207
208impl Strictness {
209    const fn is_strict(self) -> bool {
210        matches!(self, Self::Strict)
211    }
212}
213
214impl From<bool> for Strictness {
215    fn from(value: bool) -> Self {
216        if value {
217            Self::Strict
218        } else {
219            Self::Lenient
220        }
221    }
222}
223
224impl EquivalenceChecker {
225    /// Create a checker with golden files only (no Lean runner).
226    #[must_use]
227    pub fn with_golden_dir(dir: impl AsRef<Path>) -> Self {
228        Self {
229            config: EquivalenceConfig {
230                golden_dir: dir.as_ref().to_path_buf(),
231                ..Default::default()
232            },
233            runner: None,
234        }
235    }
236
237    /// Create a checker with golden files only and explicit strictness mode.
238    #[must_use]
239    pub fn with_golden_dir_strict(
240        dir: impl AsRef<Path>,
241        strictness: impl Into<Strictness>,
242    ) -> Self {
243        Self {
244            config: EquivalenceConfig {
245                golden_dir: dir.as_ref().to_path_buf(),
246                strict: strictness.into().is_strict(),
247            },
248            runner: None,
249        }
250    }
251
252    /// Return a checker with strict mode enabled or disabled.
253    #[must_use]
254    pub fn with_strict_mode(mut self, strictness: impl Into<Strictness>) -> Self {
255        self.config.strict = strictness.into().is_strict();
256        self
257    }
258
259    /// Create a checker with both golden files and live Lean.
260    ///
261    /// Returns an error if the Lean runner is not available.
262    pub fn with_lean(golden_dir: impl AsRef<Path>) -> Result<Self, EquivalenceError> {
263        let runner = LeanRunner::new()?;
264        Ok(Self {
265            config: EquivalenceConfig {
266                golden_dir: golden_dir.as_ref().to_path_buf(),
267                ..Default::default()
268            },
269            runner: Some(runner),
270        })
271    }
272
273    /// Try to create a checker with live Lean, returning None if unavailable.
274    pub fn try_with_lean(golden_dir: impl AsRef<Path>) -> Option<Self> {
275        Self::with_lean(golden_dir).ok()
276    }
277
278    /// Check if the Lean runner is available.
279    pub fn has_lean(&self) -> bool {
280        self.runner.is_some()
281    }
282
283    /// Get the golden directory path.
284    pub fn golden_dir(&self) -> &Path {
285        &self.config.golden_dir
286    }
287
288    fn parse_projections_map(
289        lean_output: &Value,
290    ) -> Result<HashMap<String, Value>, EquivalenceError> {
291        crate::projection_payload::parse_projections_field(lean_output)
292            .map_err(EquivalenceError::ParseError)
293    }
294
295    fn ensure_projection_roles(
296        expected_roles: &[String],
297        projections: &HashMap<String, Value>,
298    ) -> Result<(), EquivalenceError> {
299        let expected: BTreeSet<String> = expected_roles.iter().cloned().collect();
300        let actual: BTreeSet<String> = projections.keys().cloned().collect();
301
302        let missing: Vec<String> = expected.difference(&actual).cloned().collect();
303        let unexpected: Vec<String> = actual.difference(&expected).cloned().collect();
304
305        if missing.is_empty() && unexpected.is_empty() {
306            return Ok(());
307        }
308
309        Err(EquivalenceError::ParseError(format!(
310            "projection role-set mismatch: missing={missing:?}, unexpected={unexpected:?}"
311        )))
312    }
313
314    // ========================================================================
315    // Live Lean Comparison
316    // ========================================================================
317
318    /// Check a Rust projection against live Lean output.
319    ///
320    /// Requires the Lean runner to be available.
321    pub fn check_projection_against_lean(
322        &self,
323        global: &GlobalType,
324        role: &str,
325    ) -> Result<EquivalenceResult, EquivalenceError> {
326        let runner = self
327            .runner
328            .as_ref()
329            .ok_or(EquivalenceError::LeanNotAvailable)?;
330
331        // Export GlobalType to JSON
332        let global_json = global_to_json(global);
333
334        // Invoke Lean to get projection
335        let lean_output = runner.export_projection(&global_json, role)?;
336
337        // Check if projection succeeded
338        if lean_output["success"].as_bool() != Some(true) {
339            let err = lean_output["error"].to_string();
340            return Err(EquivalenceError::ParseError(format!(
341                "Lean projection failed: {}",
342                err
343            )));
344        }
345
346        let expected = lean_output
347            .get("projection")
348            .or_else(|| lean_output.get("result"))
349            .ok_or_else(|| {
350                EquivalenceError::ParseError("Missing projection in Lean output".into())
351            })?
352            .clone();
353
354        // Compute Rust projection
355        let rust_local = project(global, role)?;
356        let rust_output = local_to_json(&rust_local);
357
358        // Compare
359        self.compare_local_types(role, &rust_output, &expected)
360    }
361
362    /// Check all Rust projections against live Lean outputs.
363    pub fn check_all_projections_against_lean(
364        &self,
365        global: &GlobalType,
366    ) -> Result<Vec<EquivalenceResult>, EquivalenceError> {
367        let runner = self
368            .runner
369            .as_ref()
370            .ok_or(EquivalenceError::LeanNotAvailable)?;
371
372        // Export GlobalType to JSON
373        let global_json = global_to_json(global);
374
375        // Invoke Lean to get all projections
376        let lean_output = runner.export_all_projections(&global_json)?;
377
378        // Check if projection succeeded
379        if lean_output["success"].as_bool() != Some(true) {
380            let err = lean_output["error"].to_string();
381            return Err(EquivalenceError::ParseError(format!(
382                "Lean projections failed: {}",
383                err
384            )));
385        }
386
387        let mut results = Vec::new();
388        let projections = Self::parse_projections_map(&lean_output)?;
389        Self::ensure_projection_roles(&global.roles(), &projections)?;
390
391        for (role, expected) in projections {
392            // Compute Rust projection
393            let rust_local = project(global, &role)?;
394            let rust_output = local_to_json(&rust_local);
395
396            let result = self.compare_local_types(&role, &rust_output, &expected)?;
397            results.push(result);
398        }
399
400        Ok(results)
401    }
402
403    // ========================================================================
404    // Internal Helpers
405    // ========================================================================
406
407    /// Compare two local type JSON values.
408    fn compare_local_types(
409        &self,
410        role: &str,
411        rust_output: &Value,
412        expected: &Value,
413    ) -> Result<EquivalenceResult, EquivalenceError> {
414        let equivalent = if self.config.strict {
415            serde_json::to_string(rust_output).ok() == serde_json::to_string(expected).ok()
416        } else {
417            self.json_structurally_equal(rust_output, expected)
418        };
419        if equivalent {
420            Ok(EquivalenceResult::success(role, rust_output.clone()))
421        } else {
422            let diff = self.compute_diff(rust_output, expected);
423            Ok(EquivalenceResult::failure(
424                role,
425                rust_output.clone(),
426                expected.clone(),
427                diff,
428            ))
429        }
430    }
431
432    /// Check if two JSON values are structurally equal (ignoring formatting).
433    #[allow(clippy::only_used_in_recursion)]
434    fn json_structurally_equal(&self, a: &Value, b: &Value) -> bool {
435        match (a, b) {
436            (Value::Null, Value::Null) => true,
437            (Value::Bool(a), Value::Bool(b)) => a == b,
438            (Value::Number(a), Value::Number(b)) => a == b,
439            (Value::String(a), Value::String(b)) => a == b,
440            (Value::Array(a), Value::Array(b)) => {
441                a.len() == b.len()
442                    && a.iter()
443                        .zip(b.iter())
444                        .all(|(x, y)| self.json_structurally_equal(x, y))
445            }
446            (Value::Object(a), Value::Object(b)) => {
447                a.len() == b.len()
448                    && a.iter().all(|(k, v)| {
449                        b.get(k)
450                            .map(|bv| self.json_structurally_equal(v, bv))
451                            .unwrap_or(false)
452                    })
453            }
454            _ => false,
455        }
456    }
457
458    /// Compute a human-readable diff between two JSON values.
459    fn compute_diff(&self, rust: &Value, expected: &Value) -> String {
460        format!(
461            "Rust:\n{}\n\nExpected (Lean):\n{}",
462            serde_json::to_string_pretty(rust).unwrap_or_default(),
463            serde_json::to_string_pretty(expected).unwrap_or_default()
464        )
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_equivalence_result_success() {
474        let result = EquivalenceResult::success("Alice", serde_json::json!({"kind": "end"}));
475        assert!(result.equivalent);
476        assert!(result.diff.is_none());
477        assert_eq!(result.role, "Alice");
478    }
479
480    #[test]
481    fn test_equivalence_result_failure() {
482        let result = EquivalenceResult::failure(
483            "Bob",
484            serde_json::json!({"kind": "end"}),
485            serde_json::json!({"kind": "send"}),
486            "Mismatch".to_string(),
487        );
488        assert!(!result.equivalent);
489        assert!(result.diff.is_some());
490    }
491
492    #[test]
493    fn test_json_structural_equality() {
494        let checker = EquivalenceChecker::with_golden_dir("golden");
495
496        // Equal objects
497        let a = serde_json::json!({"kind": "end"});
498        let b = serde_json::json!({"kind": "end"});
499        assert!(checker.json_structurally_equal(&a, &b));
500
501        // Different objects
502        let c = serde_json::json!({"kind": "send"});
503        assert!(!checker.json_structurally_equal(&a, &c));
504
505        // Nested objects
506        let d = serde_json::json!({
507            "kind": "comm",
508            "branches": [{"label": "msg"}]
509        });
510        let e = serde_json::json!({
511            "kind": "comm",
512            "branches": [{"label": "msg"}]
513        });
514        assert!(checker.json_structurally_equal(&d, &e));
515    }
516
517    #[test]
518    fn test_checker_has_lean() {
519        let checker = EquivalenceChecker::with_golden_dir("golden");
520        assert!(!checker.has_lean());
521
522        // Try to create with Lean - may or may not succeed depending on environment
523        if LeanRunner::is_available() {
524            let with_lean = EquivalenceChecker::with_lean("golden").unwrap();
525            assert!(with_lean.has_lean());
526        }
527    }
528
529    #[test]
530    fn test_strict_mode_is_wired_into_comparison() {
531        let non_strict = EquivalenceChecker::with_golden_dir_strict("golden", false);
532        let strict = EquivalenceChecker::with_golden_dir_strict("golden", true);
533
534        let left: Value = serde_json::from_str(r#"{"a":1,"b":2}"#).expect("left json");
535        let right: Value = serde_json::from_str(r#"{"a":1,"b":2}"#).expect("right json");
536        let mismatch: Value = serde_json::from_str(r#"{"a":1,"b":3}"#).expect("mismatch json");
537
538        let strict_result = strict
539            .compare_local_types("A", &left, &right)
540            .expect("strict comparison result");
541        let strict_mismatch = strict
542            .compare_local_types("A", &left, &mismatch)
543            .expect("strict mismatch comparison");
544        let non_strict_result = non_strict
545            .compare_local_types("A", &left, &right)
546            .expect("non-strict comparison result");
547        let non_strict_mismatch = non_strict
548            .compare_local_types("A", &left, &mismatch)
549            .expect("non-strict mismatch comparison");
550
551        assert!(strict_result.equivalent);
552        assert!(non_strict_result.equivalent);
553        assert!(!strict_mismatch.equivalent);
554        assert!(!non_strict_mismatch.equivalent);
555    }
556
557    #[test]
558    fn test_projection_role_set_check_rejects_missing_roles() {
559        let expected = vec!["A".to_string(), "B".to_string()];
560        let mut projections = HashMap::new();
561        projections.insert("A".to_string(), serde_json::json!({"kind": "end"}));
562
563        let err = EquivalenceChecker::ensure_projection_roles(&expected, &projections)
564            .expect_err("must reject missing role");
565        assert!(matches!(err, EquivalenceError::ParseError(_)));
566    }
567
568    #[test]
569    fn test_projection_role_set_check_rejects_unexpected_roles() {
570        let expected = vec!["A".to_string()];
571        let mut projections = HashMap::new();
572        projections.insert("A".to_string(), serde_json::json!({"kind": "end"}));
573        projections.insert("B".to_string(), serde_json::json!({"kind": "end"}));
574
575        let err = EquivalenceChecker::ensure_projection_roles(&expected, &projections)
576            .expect_err("must reject unexpected role");
577        assert!(matches!(err, EquivalenceError::ParseError(_)));
578    }
579
580    #[test]
581    fn test_projection_role_set_check_accepts_exact_match() {
582        let expected = vec!["A".to_string(), "B".to_string()];
583        let mut projections = HashMap::new();
584        projections.insert("A".to_string(), serde_json::json!({"kind": "end"}));
585        projections.insert("B".to_string(), serde_json::json!({"kind": "end"}));
586
587        EquivalenceChecker::ensure_projection_roles(&expected, &projections)
588            .expect("must accept exact role set");
589    }
590}