behavior-contracts 0.6.0

Language-neutral IR runtime core (expression evaluation, template rendering, execution plan, canonical serialization) shared across DSL implementations. Passes the dsl-contracts conformance vectors byte-for-byte.
Documentation
//! fingerprint — deterministic fingerprint of a portable IR document (common
//! Generator, bc#13 — Rust port of `ts/src/generator/fingerprint.ts`).
//!
//! Generated modules recompute the fingerprint over their embedded IR literal on
//! first use and compare it against the constant baked in at generation time
//! (skew is a LOUD reject — graphddb #208 prepared-artifact discipline).
//!
//! Cross-language identity discipline (must match TS / Python / Go / PHP exactly):
//!  1. Lift the JSON document into the [`Value`] domain. Integral numbers become
//!     int; only non-integral numbers stay float. JSON's data model (RFC 8259)
//!     does not distinguish 1 from 1.0, so integral floats canonicalize to int
//!     (TS loses the distinction at JSON.parse time — this specifies that
//!     behavior so all languages agree). `-0.0` stays float (matches TS).
//!  2. Serialize with [`canonical_json`] (all-level code-point key sort —
//!     canonical-serialization.md §3).
//!  3. FNV-1a 64 over the UTF-8 bytes. Representation: `"fnv1a64:" + 16 hex`.
//!
//! Constraint (fail-closed): integral numbers with |n| >= 2^53 are rejected
//! (portable-IR big integers must use the Expression IR `{int:"..."}` wrapper —
//! expression-ir.md §2.3. TS loses precision at JSON.parse, so allowing them
//! here would let fingerprints diverge across languages).

use crate::canonical::canonical_json;
use crate::value::Value;
use serde_json::Value as J;

/// Fingerprint input invariant violation codes (fail-closed).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FingerprintFailureCode {
    UnsafeNumber,
    InvalidValue,
}

impl FingerprintFailureCode {
    pub fn as_str(self) -> &'static str {
        match self {
            FingerprintFailureCode::UnsafeNumber => "UNSAFE_NUMBER",
            FingerprintFailureCode::InvalidValue => "INVALID_VALUE",
        }
    }
}

/// A fingerprint input invariant violation (fail-closed).
#[derive(Debug, Clone)]
pub struct FingerprintFailure {
    pub code: FingerprintFailureCode,
    pub message: String,
}

impl std::fmt::Display for FingerprintFailure {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}: {}", self.code.as_str(), self.message)
    }
}

impl std::error::Error for FingerprintFailure {}

const FLOAT64_EXACT: f64 = 9_007_199_254_740_992.0; // 2^53
const I64_EXACT: i64 = 1 << 53;

fn unsafe_number(text: &str, path: &str) -> FingerprintFailure {
    FingerprintFailure {
        code: FingerprintFailureCode::UnsafeNumber,
        message: format!(
            "integral number {text} at {path} exceeds float64-exact range; portable IR must use {{int:\"...\"}} (fail-closed)"
        ),
    }
}

fn to_value_domain(doc: &J, path: &str) -> Result<Value, FingerprintFailure> {
    match doc {
        J::Null => Ok(Value::Null),
        J::Bool(b) => Ok(Value::Bool(*b)),
        J::String(s) => Ok(Value::Str(s.clone())),
        J::Number(n) => {
            if let Some(i) = n.as_i64() {
                if i <= -I64_EXACT || i >= I64_EXACT {
                    return Err(unsafe_number(&n.to_string(), path));
                }
                return Ok(Value::Int(i));
            }
            if n.as_u64().is_some() {
                // > i64::MAX, so necessarily >= 2^53.
                return Err(unsafe_number(&n.to_string(), path));
            }
            let f = n.as_f64().ok_or_else(|| FingerprintFailure {
                code: FingerprintFailureCode::InvalidValue,
                message: format!("bad number literal {n} at {path} (fail-closed)"),
            })?;
            // Integral floats canonicalize to int; -0.0 stays float (matches TS/Python).
            if f.fract() == 0.0 && f.is_finite() && !(f == 0.0 && f.is_sign_negative()) {
                if f.abs() >= FLOAT64_EXACT {
                    return Err(unsafe_number(&n.to_string(), path));
                }
                return Ok(Value::Int(f as i64));
            }
            Ok(Value::Float(f))
        }
        J::Array(items) => {
            let mut out = Vec::with_capacity(items.len());
            for (i, e) in items.iter().enumerate() {
                out.push(to_value_domain(e, &format!("{path}[{i}]"))?);
            }
            Ok(Value::Arr(out))
        }
        J::Object(map) => {
            let mut out = Vec::with_capacity(map.len());
            for (k, v) in map {
                out.push((k.clone(), to_value_domain(v, &format!("{path}.{k}"))?));
            }
            Ok(Value::Obj(out))
        }
    }
}

fn fnv1a64(data: &[u8]) -> String {
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
    const PRIME: u64 = 0x0000_0100_0000_01b3;
    for b in data {
        h ^= u64::from(*b);
        h = h.wrapping_mul(PRIME);
    }
    format!("{h:016x}")
}

/// Deterministic fingerprint (`"fnv1a64:<16hex>"`) of a portable IR document.
///
/// Structurally identical IR documents (modulo key order and int/integral-float
/// spelling) produce the same fingerprint across all runtime languages. Generated
/// modules use it for their first-use self-check; consumers can use it to compare
/// a live IR against a generated module (#208 prepared-loader pattern).
pub fn fingerprint_component_graph(doc: &J) -> Result<String, FingerprintFailure> {
    let lifted = to_value_domain(doc, "$")?;
    let s = canonical_json(&lifted).map_err(|e| FingerprintFailure {
        code: FingerprintFailureCode::InvalidValue,
        message: format!("canonical serialization failed: {e} (fail-closed)"),
    })?;
    Ok(format!("fnv1a64:{}", fnv1a64(s.as_bytes())))
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    // Pinned in ts/test/generator.test.ts — cross-language parity value.
    const MINI_IR_FP: &str = "fnv1a64:9421ef4d0840926a";

    fn mini_ir() -> J {
        json!({
            "irVersion": 1,
            "exprVersion": 2,
            "components": [
                {
                    "name": "getA",
                    "inputPorts": { "id": { "type": "string", "required": true } },
                    "body": [
                        { "id": "a", "component": "GetItem", "ports": { "PK": { "concat": ["A#", { "ref": ["id"] }] } } }
                    ],
                    "output": { "ref": ["a"] },
                    "plan": { "groups": [[0]], "concurrency": 16 }
                }
            ]
        })
    }

    // Pinned in ts/test/generator.test.ts — typed IR (bc#44 B0) cross-language parity.
    const TYPED_MINI_IR_FP: &str = "fnv1a64:c4d20ffa450dc313";

    fn typed_mini_ir() -> J {
        json!({
            "irVersion": 1,
            "exprVersion": 2,
            "components": [
                {
                    "name": "getA",
                    "inputPorts": { "id": { "type": "string", "required": true } },
                    "body": [
                        { "id": "a", "component": "GetItem", "ports": { "PK": { "concat": ["A#", { "ref": ["id"] }] } },
                          "outType": { "obj": { "id": "string", "n": "int", "tags": { "arr": "string" }, "note": { "opt": "string" } } } }
                    ],
                    "output": { "ref": ["a"] },
                    "outputType": { "obj": { "id": "string", "n": "int", "tags": { "arr": "string" }, "note": { "opt": "string" } } },
                    "plan": { "groups": [[0]], "concurrency": 16 }
                }
            ]
        })
    }

    #[test]
    fn pinned_cross_language_value() {
        assert_eq!(fingerprint_component_graph(&mini_ir()).unwrap(), MINI_IR_FP);
    }

    #[test]
    fn typed_ir_pinned_cross_language_value() {
        // bc#44 B0: type annotations are hashed by the canonical-JSON walk → the typed
        // IR pins a distinct, cross-language-stable fingerprint, and it differs from the
        // untyped MINI_IR (tamper detection).
        assert_eq!(
            fingerprint_component_graph(&typed_mini_ir()).unwrap(),
            TYPED_MINI_IR_FP
        );
        assert_ne!(
            fingerprint_component_graph(&typed_mini_ir()).unwrap(),
            fingerprint_component_graph(&mini_ir()).unwrap()
        );
    }

    #[test]
    fn key_order_insensitive_and_int_float_canonicalization() {
        let a = fingerprint_component_graph(&json!({"a": 1, "b": [1, 2]})).unwrap();
        let b = fingerprint_component_graph(&json!({"b": [1.0, 2.0], "a": 1.0})).unwrap();
        assert_eq!(a, b);
        let c = fingerprint_component_graph(&json!({"a": 1.5, "b": [1, 2]})).unwrap();
        assert_ne!(a, c);
    }

    #[test]
    fn unsafe_number_fail_closed() {
        for doc in [json!({"n": 9007199254740993_i64}), json!({"n": 1e300})] {
            let err = fingerprint_component_graph(&doc).unwrap_err();
            assert_eq!(err.code, FingerprintFailureCode::UnsafeNumber);
            assert!(err.message.contains("fail-closed"));
        }
    }

    #[test]
    fn negative_zero_stays_float() {
        let neg = fingerprint_component_graph(&json!({"n": -0.0})).unwrap();
        let zero = fingerprint_component_graph(&json!({"n": 0})).unwrap();
        assert_ne!(neg, zero);
    }
}