Files
AILang/crates/ailang-core/src/hash.rs
T
Brummel b9ca8894a5 Iter 18a: (borrow T) / (own T) mode annotations on fn signatures
Adds form-A surface (borrow T) / (own T) wrappers in fn-type
params and ret slots. Internally these are not new Type variants
but per-position metadata on Type::Fn:

  Type::Fn { params, param_modes, ret, ret_mode, effects }
  enum ParamMode { Implicit, Own, Borrow }

This keeps Type itself unchanged, so unification, occurs, apply,
and ~190 other Type match-arms in the typechecker need no new
branch. Fields use serde skip-if-default predicates so canonical
JSON hashes for every pre-18a fixture stay bit-identical (sum,
list, hof, closure, list_map, etc.: zero diff under git).

Iter 18a is purely additive: typechecker treats modes as
transparent (mode-compat check deferred to 18c), no codegen
change (--alloc=gc / Boehm path unchanged), no linearity
enforcement. The annotation info flows into the JSON side-table
that 18c will consume.

Equality of mode slices is length-tolerant: a vec![] (the form
written by mechanically-updated construction sites that elide
modes) compares equal to vec![Implicit; n]. This kept the patch
from being a 100-site mechanical migration through the
typechecker. 18c will choose: keep the slack, or normalise to
full-length on construction.

New fixture examples/borrow_own_demo.{ailx,ail.json} exercises
both modes. list_length declares (borrow (con List)); sum_list
declares (own (con List)). Stdout: 3 then 6.

Documentation: DESIGN.md schema-additions block clarified that
modes are Type::Fn metadata (not Type variants); migration plan
flag rename --memory=rc → --alloc=rc to match the existing CLI
flag.

Verification:
- cargo build --workspace green
- cargo test --workspace green (154 tests, +1 vs baseline 153)
- git diff examples/{sum,list,hof,closure,list_map,...}.ail.json: empty
- borrow_own_demo runtime stdout: "3\n6\n"
- canonical JSON contains "param_modes":["borrow"] and ["own"]
2026-05-08 02:07:07 +02:00

122 lines
4.2 KiB
Rust

//! Content-addressed hashing for definitions.
//!
//! Hash = BLAKE3 over the canonical JSON bytes of a [`Def`] (see
//! [`crate::canonical`]). The hash function takes a [`Def`] by
//! reference, so there is no `hash` field to strip — the in-memory
//! struct does not carry one.
//!
//! The single entry point at this level is [`def_hash`]. The parallel
//! entry point at module granularity is
//! [`crate::workspace::module_hash`].
use crate::ast::Def;
use crate::canonical;
/// Content hash of a single [`Def`] — the 16-hex-char (64-bit) prefix
/// of its BLAKE3 hash over canonical JSON bytes.
///
/// 64 bits is wide enough to be unique across realistic AILang
/// codebases and short enough to read at a glance in pretty-printed
/// manifests. The hash is computed over the **canonical JSON byte
/// pre-image**, not over the in-memory struct, so any change to the
/// canonical form (new fields, different `skip_serializing_if`
/// behaviour, key reordering bug) changes every hash. The Iter 13a
/// regression test below pins concrete hashes for two example
/// definitions to catch that.
///
/// # Examples
///
/// ```ignore
/// use ailang_core::{ast::*, def_hash};
///
/// let def = Def::Const(ConstDef {
/// name: "answer".into(),
/// ty: Type::int(),
/// value: Term::Lit { lit: Literal::Int { value: 42 } },
/// doc: None,
/// });
///
/// // Stable across runs: same canonical bytes -> same hash.
/// assert_eq!(def_hash(&def), def_hash(&def));
/// assert_eq!(def_hash(&def).len(), 16);
/// ```
pub fn def_hash(def: &Def) -> String {
let bytes = canonical::to_bytes(def);
let h = blake3::hash(&bytes);
let hex = h.to_hex();
hex.as_str()[..16].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::*;
fn sample_fn() -> Def {
Def::Fn(FnDef {
name: "add".into(),
ty: Type::Fn {
params: vec![Type::int(), Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec!["a".into(), "b".into()],
body: Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Var { name: "a".into() },
Term::Var { name: "b".into() },
],
tail: false,
},
doc: None,
})
}
#[test]
fn hash_is_stable() {
let h1 = def_hash(&sample_fn());
let h2 = def_hash(&sample_fn());
assert_eq!(h1, h2);
assert_eq!(h1.len(), 16);
}
#[test]
fn hash_changes_with_content() {
let mut def = sample_fn();
let h1 = def_hash(&def);
if let Def::Fn(ref mut f) = def {
f.name = "mul".into();
}
let h2 = def_hash(&def);
assert_ne!(h1, h2);
}
/// Iter 13a regression: adding `vars` to TypeDef and `args` to
/// `Type::Con` must NOT change canonical-JSON hashes of any pre-13a
/// definition. Recorded hashes were captured from on-disk modules
/// before the schema extension; if this fires, a
/// `skip_serializing_if` is missing or wrong. We deserialise the
/// real example modules from disk to avoid drift between the test
/// and the source-of-truth JSON.
#[test]
fn iter13a_schema_extension_preserves_pre_13a_hashes() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let examples = manifest_dir.join("../../examples");
let sum_src = std::fs::read(examples.join("sum.ail.json"))
.expect("examples/sum.ail.json present");
let sum_mod: crate::ast::Module = serde_json::from_slice(&sum_src).unwrap();
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
assert_eq!(def_hash(sum_def), "db33f57cb329935e");
let list_src = std::fs::read(examples.join("list.ail.json"))
.expect("examples/list.ail.json present");
let list_mod: crate::ast::Module = serde_json::from_slice(&list_src).unwrap();
let int_list_def = list_mod.defs.iter().find(|d| d.name() == "IntList").unwrap();
assert_eq!(def_hash(int_list_def), "b082192bd0c99202");
}
}