Skip to main content

soil_rt/
ops.rs

1//! The derived operations (design §3.7): structural `eq`, `compare`,
2//! and `hash` over `Value` + descriptor. `show` is the canonical JSON
3//! encoder (`json::encode`).
4//!
5//! At the Soil level the derived functions are monomorphic per-type
6//! definitions (`Foo::eq`, `Foo::compare` — design §3.7's chosen
7//! spelling); each of them *lowers to* a call into this one engine with
8//! its type's descriptor (plan 01 scope item 5: "one implementation
9//! each over `Value` + descriptor"). Static exclusion of closures and
10//! per-type applicability live in the checker, which only elaborates
11//! `T::eq` at types that derive it; the `DerivationError`s here are a
12//! defensive backstop behind that, not the primary enforcement. A
13//! compiler may later specialize per type; the semantics are fixed
14//! here.
15//!
16//! `eq` is defined as `compare == Equal`, so the agreement laws hold by
17//! construction. `hash` is FNV-1a 64-bit over the value's canonical
18//! JSON encoding — one byte-form of a value in the whole system — with
19//! the `opaque` strategy hashing the allocation address instead
20//! (resolved decisions, impl plan §1; recorded in design §3.7).
21
22use std::cmp::Ordering;
23
24use crate::descriptor::Strategy;
25use crate::error::{PanicKind, SoilError};
26use crate::json;
27use crate::value::Value;
28use crate::Runtime;
29
30/// Structural equality: `compare(a, b) == Equal`. NaN equals NaN;
31/// `-0.0` and `0.0` are distinct; `ignored` fields are skipped; opaque
32/// values and opaque-strategy types compare by allocation address.
33pub fn eq(runtime: &Runtime, a: &Value, b: &Value) -> Result<bool, SoilError> {
34    Ok(compare(runtime, a, b)? == Ordering::Equal)
35}
36
37/// The derived total order.
38///
39/// `F64` follows `total_cmp` with the NaN distinctions collapsed
40/// (micro-pin §8.3): all NaN bit patterns are one logical value that
41/// sorts after `+Inf`; `-0.0 < 0.0`. Records compare field-wise in
42/// declaration order (skipping `ignored`), sums by variant index then
43/// payload, lists and maps lexicographically then by length. Map
44/// comparators are structure, not content, and are excluded. Comparing
45/// values of different types — or reaching a closure — is an error the
46/// static checker normally rules out.
47pub fn compare(runtime: &Runtime, a: &Value, b: &Value) -> Result<Ordering, SoilError> {
48    match (a, b) {
49        (Value::I64(x), Value::I64(y)) => Ok(x.cmp(y)),
50        (Value::U64(x), Value::U64(y)) => Ok(x.cmp(y)),
51        (Value::I32(x), Value::I32(y)) => Ok(x.cmp(y)),
52        (Value::U32(x), Value::U32(y)) => Ok(x.cmp(y)),
53        (Value::I16(x), Value::I16(y)) => Ok(x.cmp(y)),
54        (Value::U16(x), Value::U16(y)) => Ok(x.cmp(y)),
55        (Value::I8(x), Value::I8(y)) => Ok(x.cmp(y)),
56        (Value::U8(x), Value::U8(y)) => Ok(x.cmp(y)),
57        (Value::F64(x), Value::F64(y)) => Ok(compare_f64(*x, *y)),
58        (Value::BigInt(x), Value::BigInt(y)) => Ok(x.cmp(y)),
59        (Value::Utf8(x), Value::Utf8(y)) => Ok(x.as_bytes().cmp(y.as_bytes())),
60        (Value::Bytes(x), Value::Bytes(y)) => Ok((**x).cmp(&**y)),
61        (Value::Unit, Value::Unit) => Ok(Ordering::Equal),
62        (Value::Record(x), Value::Record(y)) => {
63            if x.type_id != y.type_id {
64                return Err(type_mismatch());
65            }
66            let desc = runtime.registry.get(x.type_id)?;
67            if desc.strategy == Strategy::Opaque {
68                return Ok(x.addr().cmp(&y.addr()));
69            }
70            require_derivable(runtime, x.type_id)?;
71            let crate::descriptor::TypeBody::Record(fields) = &desc.body else {
72                return Err(type_mismatch());
73            };
74            for (i, field) in fields.iter().enumerate() {
75                if field.ignored.is_some() {
76                    continue;
77                }
78                match compare(runtime, &x.fields[i], &y.fields[i])? {
79                    Ordering::Equal => {}
80                    other => return Ok(other),
81                }
82            }
83            Ok(Ordering::Equal)
84        }
85        (Value::Sum(x), Value::Sum(y)) => {
86            if x.type_id != y.type_id {
87                return Err(type_mismatch());
88            }
89            let desc = runtime.registry.get(x.type_id)?;
90            if desc.strategy == Strategy::Opaque {
91                return Ok(x.addr().cmp(&y.addr()));
92            }
93            require_derivable(runtime, x.type_id)?;
94            match x.variant.cmp(&y.variant) {
95                Ordering::Equal => match (&x.payload, &y.payload) {
96                    (Some(p), Some(q)) => compare(runtime, p, q),
97                    (None, None) => Ok(Ordering::Equal),
98                    _ => Err(type_mismatch()),
99                },
100                other => Ok(other),
101            }
102        }
103        (Value::List(x), Value::List(y)) => compare_seq(runtime, x.iter(), y.iter()),
104        (Value::Map(x), Value::Map(y)) => {
105            // Entries are already in comparator order, so the sorted
106            // sequences compare entry-wise: key, then value.
107            let xs = x.entries().iter().flat_map(|(k, v)| [k, v]);
108            let ys = y.entries().iter().flat_map(|(k, v)| [k, v]);
109            compare_seq(runtime, xs, ys)
110        }
111        (Value::Opaque(x), Value::Opaque(y)) => {
112            if x.type_id != y.type_id {
113                return Err(type_mismatch());
114            }
115            Ok(x.addr().cmp(&y.addr()))
116        }
117        (Value::Closure(_), _) | (_, Value::Closure(_)) => Err(SoilError::new(
118            PanicKind::DerivationError,
119            "compare reached a function value",
120        )),
121        _ => Err(type_mismatch()),
122    }
123}
124
125/// FNV-1a 64-bit over the value's canonical JSON encoding; the `opaque`
126/// strategy (and opaque handles) hash the allocation address instead.
127/// Fixed and platform-independent: `hash` is language-observable.
128pub fn hash(runtime: &Runtime, value: &Value) -> Result<u64, SoilError> {
129    match value {
130        Value::Opaque(v) => Ok(fnv1a64(&v.addr().to_le_bytes())),
131        Value::Record(v) => {
132            if runtime.registry.get(v.type_id)?.strategy == Strategy::Opaque {
133                return Ok(fnv1a64(&v.addr().to_le_bytes()));
134            }
135            Ok(fnv1a64(json::encode(runtime, value)?.as_bytes()))
136        }
137        Value::Sum(v) => {
138            if runtime.registry.get(v.type_id)?.strategy == Strategy::Opaque {
139                return Ok(fnv1a64(&v.addr().to_le_bytes()));
140            }
141            Ok(fnv1a64(json::encode(runtime, value)?.as_bytes()))
142        }
143        Value::Closure(_) => Err(SoilError::new(
144            PanicKind::DerivationError,
145            "hash reached a function value",
146        )),
147        _ => Ok(fnv1a64(json::encode(runtime, value)?.as_bytes())),
148    }
149}
150
151/// FNV-1a with the standard 64-bit offset basis and prime.
152fn fnv1a64(bytes: &[u8]) -> u64 {
153    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
154    const PRIME: u64 = 0x0000_0100_0000_01b3;
155    let mut state = OFFSET_BASIS;
156    for &byte in bytes {
157        state ^= u64::from(byte);
158        state = state.wrapping_mul(PRIME);
159    }
160    state
161}
162
163/// `total_cmp` with the NaN payload/sign distinctions collapsed: one
164/// logical NaN, sorting last (after `+Inf`).
165fn compare_f64(x: f64, y: f64) -> Ordering {
166    match (x.is_nan(), y.is_nan()) {
167        (true, true) => Ordering::Equal,
168        (true, false) => Ordering::Greater,
169        (false, true) => Ordering::Less,
170        (false, false) => x.total_cmp(&y),
171    }
172}
173
174fn compare_seq<'a>(
175    runtime: &Runtime,
176    mut xs: impl Iterator<Item = &'a Value>,
177    mut ys: impl Iterator<Item = &'a Value>,
178) -> Result<Ordering, SoilError> {
179    loop {
180        match (xs.next(), ys.next()) {
181            (Some(x), Some(y)) => match compare(runtime, x, y)? {
182                Ordering::Equal => {}
183                other => return Ok(other),
184            },
185            (None, None) => return Ok(Ordering::Equal),
186            (Some(_), None) => return Ok(Ordering::Greater),
187            (None, Some(_)) => return Ok(Ordering::Less),
188        }
189    }
190}
191
192fn require_derivable(runtime: &Runtime, id: crate::descriptor::TypeId) -> Result<(), SoilError> {
193    if runtime.registry.derivable(id)? {
194        Ok(())
195    } else {
196        let name = runtime.registry.get(id)?.name.clone();
197        Err(SoilError::new(
198            PanicKind::DerivationError,
199            format!("type {name} contains a function type and derives nothing"),
200        ))
201    }
202}
203
204fn type_mismatch() -> SoilError {
205    SoilError::new(PanicKind::TypeError, "compared values have different types")
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::descriptor::{FieldDesc, TypeBody, TypeDesc, TypeShape};
212    use crate::value::{Closure, OpaqueVal, Record, Ref};
213
214    fn rt() -> Runtime {
215        Runtime::new()
216    }
217
218    #[test]
219    fn nan_is_one_value_sorting_last() {
220        let rt = rt();
221        let nan1 = Value::F64(f64::NAN);
222        let nan2 = Value::F64(-f64::NAN);
223        assert!(eq(&rt, &nan1, &nan2).unwrap());
224        assert_eq!(
225            compare(&rt, &nan1, &Value::F64(f64::INFINITY)).unwrap(),
226            Ordering::Greater
227        );
228        assert_eq!(
229            compare(&rt, &Value::F64(-0.0), &Value::F64(0.0)).unwrap(),
230            Ordering::Less
231        );
232        assert!(!eq(&rt, &Value::F64(-0.0), &Value::F64(0.0)).unwrap());
233    }
234
235    #[test]
236    fn lists_compare_lexicographically_then_by_length() {
237        let rt = rt();
238        let short = Value::List(Ref::new(vec![Value::I64(1)]));
239        let long = Value::List(Ref::new(vec![Value::I64(1), Value::I64(2)]));
240        let bigger = Value::List(Ref::new(vec![Value::I64(9)]));
241        assert_eq!(compare(&rt, &short, &long).unwrap(), Ordering::Less);
242        assert_eq!(compare(&rt, &bigger, &long).unwrap(), Ordering::Greater);
243        assert!(eq(&rt, &short, &short.clone()).unwrap());
244    }
245
246    #[test]
247    fn records_skip_ignored_fields() {
248        let mut rt = rt();
249        let id = rt
250            .registry
251            .register(TypeDesc {
252                name: "Doc".to_string(),
253                strategy: Strategy::Structural,
254                body: TypeBody::Record(vec![
255                    FieldDesc {
256                        name: "text".to_string(),
257                        shape: TypeShape::Utf8,
258                        ignored: None,
259                    },
260                    FieldDesc {
261                        name: "cache".to_string(),
262                        shape: TypeShape::U64,
263                        ignored: Some(crate::descriptor::IgnoredDefault::Native(Closure::native(
264                            |_| Ok(Value::U64(0)),
265                        ))),
266                    },
267                ]),
268            })
269            .unwrap();
270        let make = |cache: u64| {
271            Value::Record(Ref::new(Record {
272                type_id: id,
273                fields: Box::new([Value::Utf8(Ref::from("same")), Value::U64(cache)]),
274            }))
275        };
276        let a = make(1);
277        let b = make(2);
278        assert!(eq(&rt, &a, &b).unwrap());
279        assert_eq!(hash(&rt, &a).unwrap(), hash(&rt, &b).unwrap());
280    }
281
282    #[test]
283    fn equal_values_hash_equal_and_deterministically() {
284        let rt = rt();
285        let a = Value::List(Ref::new(vec![Value::I64(1), Value::Utf8(Ref::from("x"))]));
286        let b = Value::List(Ref::new(vec![Value::I64(1), Value::Utf8(Ref::from("x"))]));
287        assert!(eq(&rt, &a, &b).unwrap());
288        assert_eq!(hash(&rt, &a).unwrap(), hash(&rt, &b).unwrap());
289        // FNV-1a is fixed for all time: a golden value pins the
290        // algorithm itself (hash of the canonical bytes `[1,"x"]`).
291        assert_eq!(hash(&rt, &a).unwrap(), fnv1a64(b"[1,\"x\"]"));
292    }
293
294    #[test]
295    fn opaque_hash_and_eq_by_identity() {
296        let mut rt = rt();
297        let id = rt
298            .registry
299            .register(TypeDesc {
300                name: "Handle".to_string(),
301                strategy: Strategy::Opaque,
302                body: TypeBody::Opaque,
303            })
304            .unwrap();
305        let a = Value::Opaque(Ref::new(OpaqueVal {
306            type_id: id,
307            payload: Box::new(1u8),
308        }));
309        let b = a.clone();
310        let c = Value::Opaque(Ref::new(OpaqueVal {
311            type_id: id,
312            payload: Box::new(1u8),
313        }));
314        assert!(eq(&rt, &a, &b).unwrap());
315        assert!(!eq(&rt, &a, &c).unwrap());
316        assert_eq!(hash(&rt, &a).unwrap(), hash(&rt, &b).unwrap());
317        assert_ne!(hash(&rt, &a).unwrap(), hash(&rt, &c).unwrap());
318    }
319
320    #[test]
321    fn closures_do_not_derive() {
322        let rt = rt();
323        let f = Value::Closure(Ref::new(Closure::native(|_| Ok(Value::Unit))));
324        assert_eq!(
325            compare(&rt, &f, &f.clone()).unwrap_err().kind,
326            PanicKind::DerivationError
327        );
328        assert_eq!(hash(&rt, &f).unwrap_err().kind, PanicKind::DerivationError);
329    }
330
331    #[test]
332    fn mismatched_types_error() {
333        let rt = rt();
334        assert_eq!(
335            compare(&rt, &Value::I64(1), &Value::U64(1))
336                .unwrap_err()
337                .kind,
338            PanicKind::TypeError
339        );
340    }
341}