use std::sync::Arc;
use panproto_expr::{Env, Literal, eval};
use panproto_gat::{CoercionClass, ValueKind};
pub mod witness;
pub use witness::{SortLensWitness, WitnessLibrary, default_witness_library};
pub type WitnessSample = Literal;
pub fn witness_satisfies_lens_laws(
witness: &SortLensWitness,
source_samples: &[WitnessSample],
target_samples: &[WitnessSample],
) -> Result<(), String> {
let config = panproto_expr::EvalConfig::default();
for s in source_samples {
let forward_env = Env::new().extend(Arc::clone(&witness.forward_param), s.clone());
let forward_result = eval(&witness.forward, &forward_env, &config)
.map_err(|e| format!("forward eval on {s:?} failed: {e}"))?;
let inverse = witness
.inverse
.as_ref()
.ok_or_else(|| "witness has no inverse; cannot verify GetPut".to_owned())?;
let inverse_param = witness
.inverse_param
.as_ref()
.ok_or_else(|| "witness inverse_param is missing".to_owned())?;
let inverse_env = Env::new().extend(Arc::clone(inverse_param), forward_result);
let round_trip = eval(inverse, &inverse_env, &config)
.map_err(|e| format!("inverse eval round-trip on {s:?} failed: {e}"))?;
if !literal_equal(&round_trip, s) {
return Err(format!(
"GetPut violation for sample {s:?}: inverse(forward(s)) = {round_trip:?}"
));
}
}
if witness.class == CoercionClass::Iso {
let inverse = witness
.inverse
.as_ref()
.ok_or_else(|| "Iso witness must have an inverse".to_owned())?;
let inverse_param = witness
.inverse_param
.as_ref()
.ok_or_else(|| "Iso witness inverse_param is missing".to_owned())?;
if target_samples.is_empty() {
return Err(
"Iso witness requires at least one target sample to verify PutGet; \
an empty target slice would vacuously pass and hide bugs in the \
inverse direction"
.to_owned(),
);
}
for t in target_samples {
let inverse_env = Env::new().extend(Arc::clone(inverse_param), t.clone());
let back = eval(inverse, &inverse_env, &config)
.map_err(|e| format!("inverse eval on {t:?} failed: {e}"))?;
let forward_env = Env::new().extend(Arc::clone(&witness.forward_param), back);
let round_trip = eval(&witness.forward, &forward_env, &config)
.map_err(|e| format!("forward eval round-trip on {t:?} failed: {e}"))?;
if !literal_equal(&round_trip, t) {
return Err(format!(
"PutGet violation for target sample {t:?}: forward(inverse(t)) = {round_trip:?}"
));
}
}
}
Ok(())
}
pub fn witness_forward_fails_on(
witness: &SortLensWitness,
off_domain_target: &WitnessSample,
) -> Result<(), String> {
let config = panproto_expr::EvalConfig::default();
let inverse = witness
.inverse
.as_ref()
.ok_or_else(|| "witness has no inverse".to_owned())?;
let inverse_param = witness
.inverse_param
.as_ref()
.ok_or_else(|| "witness inverse_param is missing".to_owned())?;
let inverse_env = Env::new().extend(Arc::clone(inverse_param), off_domain_target.clone());
let Ok(back) = eval(inverse, &inverse_env, &config) else {
return Ok(()); };
let forward_env = Env::new().extend(Arc::clone(&witness.forward_param), back);
eval(&witness.forward, &forward_env, &config).map_or(Ok(()), |round_trip| {
if literal_equal(&round_trip, off_domain_target) {
Err(format!(
"expected off-domain target {off_domain_target:?} to NOT round-trip, \
but forward(inverse(t)) = {round_trip:?} matched"
))
} else {
Ok(())
}
})
}
fn literal_equal(a: &Literal, b: &Literal) -> bool {
match (a, b) {
(Literal::Float(x), Literal::Float(y)) => {
if x.is_nan() && y.is_nan() {
return true;
}
if x.is_infinite() || y.is_infinite() {
return x.to_bits() == y.to_bits();
}
let scale = x.abs().max(y.abs()).max(1.0);
(x - y).abs() <= 1e-12 * scale
}
(Literal::Record(ra), Literal::Record(rb)) => {
ra.len() == rb.len()
&& ra
.iter()
.zip(rb.iter())
.all(|((ka, va), (kb, vb))| ka == kb && literal_equal(va, vb))
}
(Literal::List(la), Literal::List(lb)) => {
la.len() == lb.len() && la.iter().zip(lb.iter()).all(|(x, y)| literal_equal(x, y))
}
_ => a == b,
}
}
#[must_use]
pub const fn value_kind_label(kind: ValueKind) -> &'static str {
match kind {
ValueKind::Bool => "bool",
ValueKind::Int => "int",
ValueKind::Float => "float",
ValueKind::Str => "str",
ValueKind::Bytes => "bytes",
ValueKind::Token => "token",
ValueKind::Null => "null",
ValueKind::Any => "any",
}
}
pub use panproto_gat::ValueKind as CarrierKind;
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
mod edge_case_tests {
use super::*;
#[test]
fn literal_equal_signed_zero() {
assert!(literal_equal(&Literal::Float(0.0), &Literal::Float(-0.0)));
assert!(literal_equal(&Literal::Float(-0.0), &Literal::Float(0.0)));
}
#[test]
fn literal_equal_nan_is_reflexive() {
assert!(literal_equal(
&Literal::Float(f64::NAN),
&Literal::Float(f64::NAN)
));
}
#[test]
fn literal_equal_distinct_infinities() {
assert!(literal_equal(
&Literal::Float(f64::INFINITY),
&Literal::Float(f64::INFINITY)
));
assert!(literal_equal(
&Literal::Float(f64::NEG_INFINITY),
&Literal::Float(f64::NEG_INFINITY)
));
assert!(!literal_equal(
&Literal::Float(f64::INFINITY),
&Literal::Float(f64::NEG_INFINITY)
));
assert!(!literal_equal(
&Literal::Float(f64::INFINITY),
&Literal::Float(f64::MAX)
));
}
#[test]
fn literal_equal_subnormals() {
let a = f64::MIN_POSITIVE / 2.0; let b = f64::MIN_POSITIVE / 4.0;
assert!(literal_equal(&Literal::Float(a), &Literal::Float(b)));
}
#[test]
fn literal_equal_near_f64_max() {
let x = f64::MAX;
let y = f64::MAX - 1.0; assert!(literal_equal(&Literal::Float(x), &Literal::Float(y)));
assert!(!literal_equal(
&Literal::Float(f64::MAX),
&Literal::Float(f64::MAX / 2.0)
));
}
#[test]
fn iso_witness_requires_target_samples() {
let v: std::sync::Arc<str> = std::sync::Arc::from("v");
let iso = SortLensWitness {
name: "id_int".to_owned(),
source_kind: ValueKind::Int,
target_kind: ValueKind::Int,
class: CoercionClass::Iso,
forward_param: std::sync::Arc::clone(&v),
forward: panproto_expr::Expr::Var(std::sync::Arc::clone(&v)),
inverse_param: Some(std::sync::Arc::clone(&v)),
inverse: Some(panproto_expr::Expr::Var(v)),
description: "identity on Int".to_owned(),
};
let err = witness_satisfies_lens_laws(&iso, &[Literal::Int(1), Literal::Int(2)], &[])
.unwrap_err();
assert!(
err.contains("requires at least one target sample"),
"expected empty-target rejection; got: {err}"
);
witness_satisfies_lens_laws(&iso, &[Literal::Int(1)], &[Literal::Int(7)])
.expect("identity iso must pass with a target sample");
}
mod witness_forward_fails_on_off_domain {
use super::{
Literal,
witness::{
bool_to_int_witness, int_to_bool_witness, int_to_str_witness, str_to_int_witness,
},
witness_forward_fails_on,
};
#[test]
fn str_to_int_rejects_non_numeric_strings() {
let w = str_to_int_witness();
for bad in ["abc", "", " 3", "1.5"] {
witness_forward_fails_on(&w, &Literal::Str(bad.to_owned()))
.expect("str_to_int forward must fail on non-numeric string");
}
}
#[test]
fn int_to_str_fails_when_inverse_domain_gaps_leak() {
let w = int_to_str_witness();
let result = witness_forward_fails_on(&w, &Literal::Str("3".to_owned()));
assert!(
result.is_err(),
"canonical decimal string must round-trip, but witness_forward_fails_on accepted it"
);
}
#[test]
fn bool_to_int_fails_on_out_of_range_ints() {
let w = bool_to_int_witness();
witness_forward_fails_on(&w, &Literal::Int(2))
.expect("bool_to_int forward must fail on int outside {0,1}");
}
#[test]
fn int_to_bool_fails_on_non_boolean_ints() {
let w = int_to_bool_witness();
witness_forward_fails_on(&w, &Literal::Int(7))
.expect("int_to_bool forward must fail on int outside {0,1}");
}
}
}