1use 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#[derive(Debug, Error)]
52pub enum EquivalenceError {
53 #[error("Golden file not found: {0}")]
55 GoldenNotFound(PathBuf),
56
57 #[error("Failed to parse golden file: {0}")]
59 ParseError(String),
60
61 #[error("Lean runner error: {0}")]
63 LeanError(#[from] LeanRunnerError),
64
65 #[error("Import error: {0}")]
67 ImportError(#[from] ImportError),
68
69 #[error("Projection error: {0}")]
71 ProjectionError(#[from] ProjectionError),
72
73 #[error("IO error: {0}")]
75 IoError(#[from] std::io::Error),
76
77 #[error("JSON error: {0}")]
79 JsonError(#[from] serde_json::Error),
80
81 #[error("Lean runner not available - build with: cd lean && lake build telltale_validator")]
83 LeanNotAvailable,
84
85 #[error("Golden file drift detected: {path}")]
87 GoldenDrift { path: PathBuf },
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct EquivalenceResult {
93 #[serde(default = "crate::schema::default_schema_version")]
95 pub schema_version: String,
96 pub equivalent: bool,
98
99 pub rust_output: Value,
101
102 pub expected_output: Value,
104
105 pub diff: Option<String>,
107
108 pub role: String,
110}
111
112impl EquivalenceResult {
113 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct GoldenBundle {
146 #[serde(default = "crate::schema::default_schema_version")]
148 pub schema_version: String,
149 pub input: Value,
151
152 pub projections: HashMap<String, Value>,
154
155 pub coherence: Option<CoherenceBundle>,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct CoherenceBundle {
162 #[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#[derive(Debug, Clone)]
176pub struct EquivalenceConfig {
177 pub golden_dir: PathBuf,
179
180 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
193pub struct EquivalenceChecker {
195 config: EquivalenceConfig,
196 runner: Option<LeanRunner>,
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum Strictness {
202 Strict,
204 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 #[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 #[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 #[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 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 pub fn try_with_lean(golden_dir: impl AsRef<Path>) -> Option<Self> {
275 Self::with_lean(golden_dir).ok()
276 }
277
278 pub fn has_lean(&self) -> bool {
280 self.runner.is_some()
281 }
282
283 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 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 let global_json = global_to_json(global);
333
334 let lean_output = runner.export_projection(&global_json, role)?;
336
337 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 let rust_local = project(global, role)?;
356 let rust_output = local_to_json(&rust_local);
357
358 self.compare_local_types(role, &rust_output, &expected)
360 }
361
362 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 let global_json = global_to_json(global);
374
375 let lean_output = runner.export_all_projections(&global_json)?;
377
378 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 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 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 #[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 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 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 let c = serde_json::json!({"kind": "send"});
503 assert!(!checker.json_structurally_equal(&a, &c));
504
505 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 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}