Skip to main content

weft_lang/
lib.rs

1//! # Weft — the Thread's native code format (reference implementation)
2//!
3//! *Ship intent, not instructions.* A Weft program is a typed, **total**,
4//! effect-explicit term graph, identified by the hash of its canonical
5//! encoding. What crosses the wire is the same object that gets verified;
6//! execution backends (this crate's reference interpreter first) compile or
7//! interpret it locally. Spec: [weft-v0.1](https://github.com/Pixygon/thread-spec/blob/main/specs/weft-v0.1.md).
8//!
9//! v0.1 scope — deliberately austere:
10//! - **First-order and non-recursive.** Definitions call other definitions by
11//!   content hash; since a definition's hash doesn't exist until it is built,
12//!   cycles are unconstructible and **every program terminates by
13//!   construction**. (`fold` over finite data arrives in v0.2.)
14//! - **No local names.** Variables are de Bruijn indices, so
15//!   alpha-equivalent programs are *identical* programs — same bytes, same
16//!   hash. Names live outside the code, as metadata.
17//! - **Deterministic core**: integers, booleans, text. Division by zero is
18//!   defined (`= 0`), so no arithmetic traps exist. No floats.
19//! - **Effects are values.** Constructing an [`Action`] performs nothing;
20//!   the host (the browser) receives the returned actions and decides.
21//!   A definition's declared effect row must cover everything it (and its
22//!   callees, transitively) can construct — the row IS the permission.
23//! - **Cost is static.** The verifier computes a fuel bound per definition;
24//!   the interpreter meters actual fuel. Verified code cannot run away.
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::fmt;
28
29use serde::{Deserialize, Serialize};
30use sha2::{Digest, Sha256};
31
32pub mod draft_lib;
33pub mod model_lib;
34pub mod pack;
35pub mod project;
36
37// ---------------------------------------------------------------------------
38// Identity
39// ---------------------------------------------------------------------------
40
41/// A definition's identity: the SHA-256 of its canonical encoding.
42/// Rendered — including on the wire — as `weft:<hex>`. To know the hash is to
43/// know the thing entire.
44#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
45pub struct WeftHash(pub [u8; 32]);
46
47impl Serialize for WeftHash {
48    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
49        ser.serialize_str(&self.to_string())
50    }
51}
52
53impl<'de> Deserialize<'de> for WeftHash {
54    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
55        let s = String::deserialize(de)?;
56        let hex = s
57            .strip_prefix("weft:")
58            .ok_or_else(|| serde::de::Error::custom("weft hash must start with 'weft:'"))?;
59        if hex.len() != 64 {
60            return Err(serde::de::Error::custom("weft hash must be 64 hex chars"));
61        }
62        let mut out = [0u8; 32];
63        for (i, byte) in out.iter_mut().enumerate() {
64            *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
65                .map_err(|_| serde::de::Error::custom("invalid hex in weft hash"))?;
66        }
67        Ok(WeftHash(out))
68    }
69}
70
71impl fmt::Display for WeftHash {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        write!(f, "weft:")?;
74        for b in &self.0 {
75            write!(f, "{b:02x}")?;
76        }
77        Ok(())
78    }
79}
80
81impl fmt::Debug for WeftHash {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        // Short form for logs/tests: first 8 hex chars.
84        write!(f, "weft:")?;
85        for b in &self.0[..4] {
86            write!(f, "{b:02x}")?;
87        }
88        write!(f, "…")
89    }
90}
91
92// ---------------------------------------------------------------------------
93// Types
94// ---------------------------------------------------------------------------
95
96/// The v0.1 type language.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub enum Ty {
99    Int,
100    Bool,
101    Text,
102    /// Deterministic fixed-point: an i64 counting **millionths** (scale
103    /// [`FIX_SCALE`]). The honest float — exact, total, identical on every
104    /// host, no NaN, no rounding modes. `Fix(1_500_000)` is 1.5.
105    Fix,
106    /// An effect request value (see [`Action`]); opaque to programs.
107    Action,
108    List(Box<Ty>),
109    /// Fields are kept sorted by name — the canonical order.
110    Record(BTreeMap<String, Ty>),
111}
112
113// ---------------------------------------------------------------------------
114// Effects
115// ---------------------------------------------------------------------------
116
117/// The effect vocabulary — the Behavior ABI's Actions plus the declarative
118/// interaction effects, as *types*. Constructing one is pure; only the
119/// browser performs it. A behavior whose row lacks `CommerceBuy` cannot
120/// spend a traveler's gold — not "shouldn't": the verifier rejects it.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum EffectKind {
124    Notify,
125    Navigate,
126    CodexOpen,
127    CommerceBuy,
128    PresenceEmit,
129    SetState,
130    GiveItem,
131    Despawn,
132    /// Conjure a new object into the world (a builtin mesh, placed + colored).
133    Spawn,
134}
135
136impl EffectKind {
137    /// Stable wire tag (used in canonical encoding — never reorder; append only).
138    fn tag(self) -> u8 {
139        match self {
140            EffectKind::Notify => 0,
141            EffectKind::Navigate => 1,
142            EffectKind::CodexOpen => 2,
143            EffectKind::CommerceBuy => 3,
144            EffectKind::PresenceEmit => 4,
145            EffectKind::SetState => 5,
146            EffectKind::GiveItem => 6,
147            EffectKind::Despawn => 7,
148            EffectKind::Spawn => 8,
149        }
150    }
151}
152
153// ---------------------------------------------------------------------------
154// Terms
155// ---------------------------------------------------------------------------
156
157/// One whole unit in [`Ty::Fix`]'s scale: fixed-point values count millionths.
158pub const FIX_SCALE: i64 = 1_000_000;
159
160/// The longest list a program may build with [`PrimOp::ListCat`]. Bounded
161/// like everything else: a model of ten thousand parts is a mistake, not an
162/// ambition, and the bound is what lets list growth exist at all without
163/// breaking the totality guarantee.
164pub const MAX_LIST: usize = 8192;
165
166/// Pure operations on the deterministic core.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum PrimOp {
170    Add,
171    Sub,
172    Mul,
173    /// Total: division by zero is 0 (no traps exist in Weft).
174    Div,
175    Lt,
176    Le,
177    EqInt,
178    EqText,
179    And,
180    Or,
181    Not,
182    Concat,
183    /// Render an Int as Text (base 10) — total, deterministic.
184    ToText,
185    // --- fixed-point (appended v0.1.1; discriminant order is the encoding) ---
186    /// Fix + Fix → Fix (wrapping).
187    FAdd,
188    /// Fix − Fix → Fix (wrapping).
189    FSub,
190    /// Fix × Fix → Fix — exact via 128-bit intermediate, then rescaled.
191    FMul,
192    /// Fix ÷ Fix → Fix — exact via 128-bit intermediate; ÷0 = 0 (total).
193    FDiv,
194    FLt,
195    FLe,
196    EqFix,
197    /// Int → Fix (×[`FIX_SCALE`], wrapping).
198    FixOfInt,
199    /// Fix → Int (truncate toward zero).
200    IntOfFix,
201    /// Render a Fix as decimal Text ("1.5", "-0.25") — canonical: no trailing
202    /// zeros, no exponent, always a leading integer digit.
203    FixToText,
204    /// List length (any element type) → Int.
205    Len,
206    /// Fixed-point sine (radians in, Fix in/out) — Bhaskara I's approximation
207    /// computed in pure integer arithmetic: deterministic on every host,
208    /// ~0.2 % max error — a world-builder's sine, not a scientist's.
209    FSin,
210    /// Fixed-point cosine — `FSin(x + π/2)`.
211    FCos,
212    /// Append two lists of the same element type — the composition primitive.
213    /// Weft has no recursive types, so a *tree* cannot be a value; a list of
214    /// uniform records can, and `ListCat` is what lets one program's parts
215    /// join another's (the modeling library is built on it). Appended after
216    /// `FCos`: discriminant order is the encoding, so existing hashes hold.
217    ListCat,
218}
219
220/// The term graph. Variables are de Bruijn indices (0 = innermost binder);
221/// a definition's parameters are the outermost binders, in order.
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223pub enum Term {
224    Int(i64),
225    /// Fixed-point literal (raw millionths — `Term::Fix(1_500_000)` is 1.5).
226    Fix(i64),
227    Bool(bool),
228    Text(String),
229    /// de Bruijn index into the binder stack.
230    Var(u32),
231    /// Bind the first term's value; evaluate the second with it as index 0.
232    Let(Box<Term>, Box<Term>),
233    If(Box<Term>, Box<Term>, Box<Term>),
234    Prim(PrimOp, Vec<Term>),
235    /// Record construction; field order is canonical (sorted) via BTreeMap.
236    Rec(BTreeMap<String, Term>),
237    /// Field projection.
238    Get(Box<Term>, String),
239    ListNew(Vec<Term>),
240    /// Call another definition **by hash** — the only kind of call.
241    Call(WeftHash, Vec<Term>),
242    /// Construct an effect request (an [`Action`] value). Pure.
243    Effect(EffectKind, BTreeMap<String, Term>),
244    /// Bounded map: evaluate `body` (element = Var 0) for each of at most
245    /// `cap` elements of `list` — elements beyond the cap are dropped. The
246    /// cap is what keeps the fuel bound static: totality never bends.
247    Map {
248        cap: u32,
249        list: Box<Term>,
250        body: Box<Term>,
251    },
252    /// Bounded fold: thread an accumulator through at most `cap` elements
253    /// (`body` sees acc = Var 1, element = Var 0).
254    Fold {
255        cap: u32,
256        list: Box<Term>,
257        init: Box<Term>,
258        body: Box<Term>,
259    },
260    /// The list `0..min(count, cap)` — the generator's seed. `cap` keeps the
261    /// fuel bound static; with Map/Fold it makes rings, grids, and spirals.
262    Iota {
263        cap: u32,
264        count: Box<Term>,
265    },
266}
267
268// ---------------------------------------------------------------------------
269// Definitions & modules
270// ---------------------------------------------------------------------------
271
272/// One verified unit of Weft: typed parameters, result type, declared effect
273/// row, optional contracts, and the body. Its hash is its name.
274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
275pub struct Def {
276    pub params: Vec<Ty>,
277    pub ret: Ty,
278    /// Everything this definition (transitively) may construct.
279    pub effects: BTreeSet<EffectKind>,
280    pub body: Term,
281    /// Contract: precondition over the parameters (must type as Bool).
282    #[serde(default)]
283    pub pre: Option<Term>,
284    /// Contract: postcondition over parameters + result (result = index 0).
285    #[serde(default)]
286    pub post: Option<Term>,
287}
288
289/// A set of definitions keyed by hash — the unit of distribution. `entry`
290/// names the behavior's event handler.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct Module {
293    pub defs: BTreeMap<WeftHash, Def>,
294    pub entry: WeftHash,
295}
296
297impl Module {
298    /// Build a module from defs, hashing each; returns the module and the
299    /// hash of `entry_def` (which must be among `defs`).
300    pub fn build(defs: Vec<Def>, entry_index: usize) -> Result<Self, WeftError> {
301        let mut map = BTreeMap::new();
302        let mut entry = None;
303        for (i, d) in defs.into_iter().enumerate() {
304            let h = hash_def(&d);
305            if i == entry_index {
306                entry = Some(h);
307            }
308            map.insert(h, d);
309        }
310        let entry = entry.ok_or(WeftError::UnknownEntry)?;
311        Ok(Module { defs: map, entry })
312    }
313}
314
315// ---------------------------------------------------------------------------
316// Canonical encoding + hashing
317// ---------------------------------------------------------------------------
318
319fn enc_u32(out: &mut Vec<u8>, v: u32) {
320    out.extend_from_slice(&v.to_le_bytes());
321}
322fn enc_i64(out: &mut Vec<u8>, v: i64) {
323    out.extend_from_slice(&v.to_le_bytes());
324}
325fn enc_str(out: &mut Vec<u8>, s: &str) {
326    enc_u32(out, s.len() as u32);
327    out.extend_from_slice(s.as_bytes());
328}
329
330fn enc_ty(out: &mut Vec<u8>, ty: &Ty) {
331    match ty {
332        Ty::Int => out.push(0),
333        Ty::Bool => out.push(1),
334        Ty::Text => out.push(2),
335        Ty::Action => out.push(3),
336        Ty::List(t) => {
337            out.push(4);
338            enc_ty(out, t);
339        }
340        Ty::Fix => out.push(6),
341        Ty::Record(fields) => {
342            out.push(5);
343            enc_u32(out, fields.len() as u32);
344            for (k, t) in fields {
345                enc_str(out, k);
346                enc_ty(out, t);
347            }
348        }
349    }
350}
351
352fn enc_term(out: &mut Vec<u8>, t: &Term) {
353    match t {
354        Term::Int(v) => {
355            out.push(0);
356            enc_i64(out, *v);
357        }
358        Term::Bool(b) => {
359            out.push(1);
360            out.push(*b as u8);
361        }
362        Term::Text(s) => {
363            out.push(2);
364            enc_str(out, s);
365        }
366        Term::Var(i) => {
367            out.push(3);
368            enc_u32(out, *i);
369        }
370        Term::Let(v, b) => {
371            out.push(4);
372            enc_term(out, v);
373            enc_term(out, b);
374        }
375        Term::If(c, a, b) => {
376            out.push(5);
377            enc_term(out, c);
378            enc_term(out, a);
379            enc_term(out, b);
380        }
381        Term::Prim(op, args) => {
382            out.push(6);
383            out.push(*op as u8);
384            enc_u32(out, args.len() as u32);
385            for a in args {
386                enc_term(out, a);
387            }
388        }
389        Term::Rec(fields) => {
390            out.push(7);
391            enc_u32(out, fields.len() as u32);
392            for (k, v) in fields {
393                enc_str(out, k);
394                enc_term(out, v);
395            }
396        }
397        Term::Get(r, k) => {
398            out.push(8);
399            enc_term(out, r);
400            enc_str(out, k);
401        }
402        Term::ListNew(items) => {
403            out.push(9);
404            enc_u32(out, items.len() as u32);
405            for i in items {
406                enc_term(out, i);
407            }
408        }
409        Term::Call(h, args) => {
410            out.push(10);
411            out.extend_from_slice(&h.0);
412            enc_u32(out, args.len() as u32);
413            for a in args {
414                enc_term(out, a);
415            }
416        }
417        Term::Fix(v) => {
418            out.push(12);
419            enc_i64(out, *v);
420        }
421        Term::Map { cap, list, body } => {
422            out.push(13);
423            enc_u32(out, *cap);
424            enc_term(out, list);
425            enc_term(out, body);
426        }
427        Term::Fold {
428            cap,
429            list,
430            init,
431            body,
432        } => {
433            out.push(14);
434            enc_u32(out, *cap);
435            enc_term(out, list);
436            enc_term(out, init);
437            enc_term(out, body);
438        }
439        Term::Iota { cap, count } => {
440            out.push(15);
441            enc_u32(out, *cap);
442            enc_term(out, count);
443        }
444        Term::Effect(kind, fields) => {
445            out.push(11);
446            out.push(kind.tag());
447            enc_u32(out, fields.len() as u32);
448            for (k, v) in fields {
449                enc_str(out, k);
450                enc_term(out, v);
451            }
452        }
453    }
454}
455
456/// The canonical bytes of a definition — what the hash is over.
457pub fn canonical_bytes(def: &Def) -> Vec<u8> {
458    let mut out = Vec::new();
459    out.extend_from_slice(b"weft/0.1\0");
460    enc_u32(&mut out, def.params.len() as u32);
461    for p in &def.params {
462        enc_ty(&mut out, p);
463    }
464    enc_ty(&mut out, &def.ret);
465    enc_u32(&mut out, def.effects.len() as u32);
466    for e in &def.effects {
467        out.push(e.tag());
468    }
469    enc_term(&mut out, &def.body);
470    match &def.pre {
471        None => out.push(0),
472        Some(t) => {
473            out.push(1);
474            enc_term(&mut out, t);
475        }
476    }
477    match &def.post {
478        None => out.push(0),
479        Some(t) => {
480            out.push(1);
481            enc_term(&mut out, t);
482        }
483    }
484    out
485}
486
487/// A definition's identity.
488pub fn hash_def(def: &Def) -> WeftHash {
489    let digest = Sha256::digest(canonical_bytes(def));
490    let mut h = [0u8; 32];
491    h.copy_from_slice(&digest);
492    WeftHash(h)
493}
494
495// ---------------------------------------------------------------------------
496// Errors
497// ---------------------------------------------------------------------------
498
499#[derive(Debug, Clone, PartialEq, Eq)]
500pub enum WeftError {
501    /// A type mismatch, with a machine-usable description.
502    Type(String),
503    /// A constructed effect kind not covered by the declared row.
504    EffectNotDeclared(EffectKind),
505    /// A `Call` names a hash the module doesn't contain.
506    UnknownCall(String),
507    UnknownEntry,
508    /// Runtime guard only — verified code cannot reach it (bound is static).
509    FuelExhausted,
510    /// A contract evaluated to false.
511    ContractViolated(&'static str),
512}
513
514impl fmt::Display for WeftError {
515    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516        match self {
517            WeftError::Type(m) => write!(f, "type error: {m}"),
518            WeftError::EffectNotDeclared(k) => write!(f, "effect not declared: {k:?}"),
519            WeftError::UnknownCall(h) => write!(f, "unknown call target {h}"),
520            WeftError::UnknownEntry => write!(f, "entry hash not in module"),
521            WeftError::FuelExhausted => write!(f, "fuel exhausted"),
522            WeftError::ContractViolated(w) => write!(f, "contract violated: {w}"),
523        }
524    }
525}
526impl std::error::Error for WeftError {}
527
528// ---------------------------------------------------------------------------
529// Verification
530// ---------------------------------------------------------------------------
531
532/// The verifier's certificate for one definition: its checked effect row and
533/// the static fuel bound (an upper bound on interpreter steps per call).
534#[derive(Debug, Clone, PartialEq, Eq)]
535pub struct Verified {
536    pub effects: BTreeSet<EffectKind>,
537    pub fuel_bound: u64,
538}
539
540/// Verify every definition in a module: types, effect rows (transitive),
541/// contract typing, call closure — and compute static fuel bounds.
542/// Verification is per-hash cacheable: same hash, same verdict, forever.
543pub fn verify_module(m: &Module) -> Result<BTreeMap<WeftHash, Verified>, WeftError> {
544    let mut done: BTreeMap<WeftHash, Verified> = BTreeMap::new();
545    // Hash-linking makes cycles unconstructible, so simple iteration to a
546    // fixpoint terminates: each pass verifies defs whose callees are done.
547    let mut remaining: Vec<WeftHash> = m.defs.keys().copied().collect();
548    while !remaining.is_empty() {
549        let before = remaining.len();
550        let mut next = Vec::new();
551        for h in remaining {
552            let def = &m.defs[&h];
553            if callees(&def.body).iter().all(|c| done.contains_key(c)) {
554                let v = verify_def(def, &done, m)?;
555                done.insert(h, v);
556            } else {
557                next.push(h);
558            }
559        }
560        if next.len() == before {
561            // Only possible if a call targets a missing def.
562            let missing = next
563                .iter()
564                .flat_map(|h| callees(&m.defs[h].body))
565                .find(|c| !m.defs.contains_key(c))
566                .map(|c| c.to_string())
567                .unwrap_or_else(|| "<cycle?>".into());
568            return Err(WeftError::UnknownCall(missing));
569        }
570        remaining = next;
571    }
572    if !done.contains_key(&m.entry) {
573        return Err(WeftError::UnknownEntry);
574    }
575    Ok(done)
576}
577
578/// Public view of a term's call targets (pack's closure trim uses it).
579pub fn callees_of(t: &Term) -> BTreeSet<WeftHash> {
580    callees(t)
581}
582
583fn callees(t: &Term) -> BTreeSet<WeftHash> {
584    let mut out = BTreeSet::new();
585    fn walk(t: &Term, out: &mut BTreeSet<WeftHash>) {
586        match t {
587            Term::Call(h, args) => {
588                out.insert(*h);
589                args.iter().for_each(|a| walk(a, out));
590            }
591            Term::Let(a, b) => {
592                walk(a, out);
593                walk(b, out);
594            }
595            Term::If(a, b, c) => {
596                walk(a, out);
597                walk(b, out);
598                walk(c, out);
599            }
600            Term::Prim(_, xs) | Term::ListNew(xs) => xs.iter().for_each(|x| walk(x, out)),
601            Term::Map { list, body, .. } => {
602                walk(list, out);
603                walk(body, out);
604            }
605            Term::Fold {
606                list, init, body, ..
607            } => {
608                walk(list, out);
609                walk(init, out);
610                walk(body, out);
611            }
612            Term::Iota { count, .. } => walk(count, out),
613            Term::Rec(fs) | Term::Effect(_, fs) => fs.values().for_each(|x| walk(x, out)),
614            Term::Get(r, _) => walk(r, out),
615            _ => {}
616        }
617    }
618    walk(t, &mut out);
619    out
620}
621
622fn verify_def(
623    def: &Def,
624    done: &BTreeMap<WeftHash, Verified>,
625    m: &Module,
626) -> Result<Verified, WeftError> {
627    let mut ctx: Vec<Ty> = def.params.clone();
628    let mut used = BTreeSet::new();
629    let mut fuel: u64 = 0;
630    let ty = check(&def.body, &mut ctx, &mut used, &mut fuel, done, m)?;
631    if ty != def.ret {
632        return Err(WeftError::Type(format!(
633            "body is {ty:?}, declared {:?}",
634            def.ret
635        )));
636    }
637    for k in &used {
638        if !def.effects.contains(k) {
639            return Err(WeftError::EffectNotDeclared(*k));
640        }
641    }
642    // Contracts type against params (pre) / params+result (post), as Bool.
643    if let Some(pre) = &def.pre {
644        let mut c = def.params.clone();
645        let (mut u, mut f) = (BTreeSet::new(), 0u64);
646        let t = check(pre, &mut c, &mut u, &mut f, done, m)?;
647        if t != Ty::Bool || !u.is_empty() {
648            return Err(WeftError::Type("pre must be a pure Bool".into()));
649        }
650        fuel += f;
651    }
652    if let Some(post) = &def.post {
653        let mut c = def.params.clone();
654        c.push(def.ret.clone());
655        let (mut u, mut f) = (BTreeSet::new(), 0u64);
656        let t = check(post, &mut c, &mut u, &mut f, done, m)?;
657        if t != Ty::Bool || !u.is_empty() {
658            return Err(WeftError::Type("post must be a pure Bool".into()));
659        }
660        fuel += f;
661    }
662    Ok(Verified {
663        effects: used,
664        fuel_bound: fuel,
665    })
666}
667
668/// Typecheck one term; accumulate used effects and the static step bound.
669fn check(
670    t: &Term,
671    ctx: &mut Vec<Ty>,
672    used: &mut BTreeSet<EffectKind>,
673    fuel: &mut u64,
674    done: &BTreeMap<WeftHash, Verified>,
675    m: &Module,
676) -> Result<Ty, WeftError> {
677    *fuel += 1;
678    match t {
679        Term::Int(_) => Ok(Ty::Int),
680        Term::Fix(_) => Ok(Ty::Fix),
681        Term::Bool(_) => Ok(Ty::Bool),
682        Term::Text(_) => Ok(Ty::Text),
683        Term::Var(i) => {
684            let idx = ctx.len().checked_sub(1 + *i as usize);
685            idx.and_then(|k| ctx.get(k).cloned())
686                .ok_or_else(|| WeftError::Type(format!("unbound var {i}")))
687        }
688        Term::Let(v, b) => {
689            let vt = check(v, ctx, used, fuel, done, m)?;
690            ctx.push(vt);
691            let bt = check(b, ctx, used, fuel, done, m);
692            ctx.pop();
693            bt
694        }
695        Term::If(c, a, b) => {
696            if check(c, ctx, used, fuel, done, m)? != Ty::Bool {
697                return Err(WeftError::Type("if condition must be Bool".into()));
698            }
699            let at = check(a, ctx, used, fuel, done, m)?;
700            let bt = check(b, ctx, used, fuel, done, m)?;
701            if at != bt {
702                return Err(WeftError::Type("if branches differ".into()));
703            }
704            Ok(at)
705        }
706        Term::Prim(op, args) => {
707            let ats: Result<Vec<Ty>, _> = args
708                .iter()
709                .map(|a| check(a, ctx, used, fuel, done, m))
710                .collect();
711            let ats = ats?;
712            use PrimOp::*;
713            let (want, out): (Vec<Ty>, Ty) = match op {
714                Add | Sub | Mul | Div => (vec![Ty::Int, Ty::Int], Ty::Int),
715                Lt | Le | EqInt => (vec![Ty::Int, Ty::Int], Ty::Bool),
716                EqText => (vec![Ty::Text, Ty::Text], Ty::Bool),
717                And | Or => (vec![Ty::Bool, Ty::Bool], Ty::Bool),
718                Not => (vec![Ty::Bool], Ty::Bool),
719                Concat => (vec![Ty::Text, Ty::Text], Ty::Text),
720                ToText => (vec![Ty::Int], Ty::Text),
721                FAdd | FSub | FMul | FDiv => (vec![Ty::Fix, Ty::Fix], Ty::Fix),
722                FLt | FLe | EqFix => (vec![Ty::Fix, Ty::Fix], Ty::Bool),
723                FixOfInt => (vec![Ty::Int], Ty::Fix),
724                IntOfFix => (vec![Ty::Fix], Ty::Int),
725                FixToText => (vec![Ty::Fix], Ty::Text),
726                FSin | FCos => (vec![Ty::Fix], Ty::Fix),
727                // Len is the one polymorphic prim: List(T) → Int for any T.
728                Len => {
729                    return match ats.as_slice() {
730                        [Ty::List(_)] => Ok(Ty::Int),
731                        other => Err(WeftError::Type(format!(
732                            "len expects a list, got {other:?}"
733                        ))),
734                    };
735                }
736                // ListCat: List(T) × List(T) → List(T) — same T, no coercion.
737                ListCat => {
738                    return match ats.as_slice() {
739                        [Ty::List(a), Ty::List(b)] if a == b => Ok(Ty::List(a.clone())),
740                        other => Err(WeftError::Type(format!(
741                            "list-cat expects two lists of the same element type, got {other:?}"
742                        ))),
743                    };
744                }
745            };
746            if ats != want {
747                return Err(WeftError::Type(format!(
748                    "{op:?} expects {want:?}, got {ats:?}"
749                )));
750            }
751            Ok(out)
752        }
753        Term::Rec(fields) => {
754            let mut tys = BTreeMap::new();
755            for (k, v) in fields {
756                tys.insert(k.clone(), check(v, ctx, used, fuel, done, m)?);
757            }
758            Ok(Ty::Record(tys))
759        }
760        Term::Get(r, k) => match check(r, ctx, used, fuel, done, m)? {
761            Ty::Record(fs) => fs
762                .get(k)
763                .cloned()
764                .ok_or_else(|| WeftError::Type(format!("no field '{k}'"))),
765            other => Err(WeftError::Type(format!("get on non-record {other:?}"))),
766        },
767        Term::ListNew(items) => {
768            let mut elem: Option<Ty> = None;
769            for i in items {
770                let t = check(i, ctx, used, fuel, done, m)?;
771                match &elem {
772                    None => elem = Some(t),
773                    Some(e) if *e == t => {}
774                    Some(e) => return Err(WeftError::Type(format!("list mixes {e:?} and {t:?}"))),
775                }
776            }
777            Ok(Ty::List(Box::new(elem.unwrap_or(Ty::Action))))
778        }
779        Term::Call(h, args) => {
780            let callee = m
781                .defs
782                .get(h)
783                .ok_or_else(|| WeftError::UnknownCall(h.to_string()))?;
784            let cert = done
785                .get(h)
786                .ok_or_else(|| WeftError::UnknownCall(h.to_string()))?;
787            if args.len() != callee.params.len() {
788                return Err(WeftError::Type("call arity".into()));
789            }
790            for (a, want) in args.iter().zip(&callee.params) {
791                let at = check(a, ctx, used, fuel, done, m)?;
792                if at != *want {
793                    return Err(WeftError::Type(format!("call arg {at:?} vs {want:?}")));
794                }
795            }
796            // Transitive effects + cost flow into the caller's certificate.
797            used.extend(cert.effects.iter().copied());
798            *fuel += cert.fuel_bound;
799            Ok(callee.ret.clone())
800        }
801        Term::Effect(kind, fields) => {
802            for v in fields.values() {
803                check(v, ctx, used, fuel, done, m)?;
804            }
805            used.insert(*kind);
806            Ok(Ty::Action)
807        }
808        Term::Map { cap, list, body } => {
809            let lt = check(list, ctx, used, fuel, done, m)?;
810            let Ty::List(elem) = lt else {
811                return Err(WeftError::Type(format!("map over non-list {lt:?}")));
812            };
813            // The body is costed once, then charged `cap` times — that is the
814            // whole trick: iteration whose worst case is written in the term.
815            let mut body_fuel: u64 = 0;
816            ctx.push((*elem).clone());
817            let bt = check(body, ctx, used, &mut body_fuel, done, m);
818            ctx.pop();
819            let bt = bt?;
820            *fuel = fuel.saturating_add(body_fuel.saturating_mul(*cap as u64) + *cap as u64);
821            Ok(Ty::List(Box::new(bt)))
822        }
823        Term::Fold {
824            cap,
825            list,
826            init,
827            body,
828        } => {
829            let lt = check(list, ctx, used, fuel, done, m)?;
830            let Ty::List(elem) = lt else {
831                return Err(WeftError::Type(format!("fold over non-list {lt:?}")));
832            };
833            let it = check(init, ctx, used, fuel, done, m)?;
834            let mut body_fuel: u64 = 0;
835            ctx.push(it.clone()); // acc = Var 1
836            ctx.push((*elem).clone()); // element = Var 0
837            let bt = check(body, ctx, used, &mut body_fuel, done, m);
838            ctx.pop();
839            ctx.pop();
840            let bt = bt?;
841            if bt != it {
842                return Err(WeftError::Type(format!(
843                    "fold body is {bt:?}, acc is {it:?}"
844                )));
845            }
846            *fuel = fuel.saturating_add(body_fuel.saturating_mul(*cap as u64) + *cap as u64);
847            Ok(it)
848        }
849        Term::Iota { cap, count } => {
850            let ct = check(count, ctx, used, fuel, done, m)?;
851            if ct != Ty::Int {
852                return Err(WeftError::Type(format!(
853                    "iota count must be Int, got {ct:?}"
854                )));
855            }
856            *fuel = fuel.saturating_add(*cap as u64);
857            Ok(Ty::List(Box::new(Ty::Int)))
858        }
859    }
860}
861
862// ---------------------------------------------------------------------------
863// Values & evaluation
864// ---------------------------------------------------------------------------
865
866/// A runtime value.
867#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
868pub enum Value {
869    Int(i64),
870    /// Raw millionths (see [`FIX_SCALE`]).
871    Fix(i64),
872    Bool(bool),
873    Text(String),
874    List(Vec<Value>),
875    Rec(BTreeMap<String, Value>),
876    /// A constructed effect request — inert data until the host acts on it.
877    Action {
878        kind: EffectKind,
879        fields: BTreeMap<String, Value>,
880    },
881}
882
883/// Evaluate `entry`-style: call a definition with argument values, metering
884/// fuel and enforcing contracts. Deterministic: same module + args → same
885/// value + same fuel spent, on every host.
886#[derive(Debug, Clone, PartialEq)]
887pub struct Evaluated {
888    pub value: Value,
889    pub fuel_spent: u64,
890}
891
892pub fn eval_call(
893    m: &Module,
894    target: WeftHash,
895    args: Vec<Value>,
896    max_fuel: u64,
897) -> Result<Evaluated, WeftError> {
898    let mut fuel = max_fuel;
899    let def = m
900        .defs
901        .get(&target)
902        .ok_or_else(|| WeftError::UnknownCall(target.to_string()))?;
903    // Precondition gate.
904    if let Some(pre) = &def.pre {
905        let mut env = args.clone();
906        if eval(pre, &mut env, m, &mut fuel)? != Value::Bool(true) {
907            return Err(WeftError::ContractViolated("pre"));
908        }
909    }
910    let mut env = args.clone();
911    let value = eval(&def.body, &mut env, m, &mut fuel)?;
912    // Postcondition gate (params + result, result innermost).
913    if let Some(post) = &def.post {
914        let mut env = args;
915        env.push(value.clone());
916        if eval(post, &mut env, m, &mut fuel)? != Value::Bool(true) {
917            return Err(WeftError::ContractViolated("post"));
918        }
919    }
920    Ok(Evaluated {
921        value,
922        fuel_spent: max_fuel - fuel,
923    })
924}
925
926fn eval(t: &Term, env: &mut Vec<Value>, m: &Module, fuel: &mut u64) -> Result<Value, WeftError> {
927    if *fuel == 0 {
928        return Err(WeftError::FuelExhausted);
929    }
930    *fuel -= 1;
931    Ok(match t {
932        Term::Int(v) => Value::Int(*v),
933        Term::Fix(v) => Value::Fix(*v),
934        Term::Bool(b) => Value::Bool(*b),
935        Term::Text(s) => Value::Text(s.clone()),
936        Term::Var(i) => env[env.len() - 1 - *i as usize].clone(),
937        Term::Let(v, b) => {
938            let val = eval(v, env, m, fuel)?;
939            env.push(val);
940            let out = eval(b, env, m, fuel);
941            env.pop();
942            out?
943        }
944        Term::If(c, a, b) => {
945            if eval(c, env, m, fuel)? == Value::Bool(true) {
946                eval(a, env, m, fuel)?
947            } else {
948                eval(b, env, m, fuel)?
949            }
950        }
951        Term::Prim(op, args) => {
952            let vals: Result<Vec<Value>, _> = args.iter().map(|a| eval(a, env, m, fuel)).collect();
953            let vals = vals?;
954            // `ListCat` is the one prim whose *result* can grow: fold it over
955            // itself and a list doubles each round. Charge fuel by the length
956            // produced (work costs fuel — the same law as everywhere else) and
957            // cap the length, so growth is bounded by the same static budget
958            // the verifier proved. Totality never bends.
959            if *op == PrimOp::ListCat {
960                let n = match (vals.first(), vals.get(1)) {
961                    (Some(Value::List(a)), Some(Value::List(b))) => a.len() + b.len(),
962                    _ => 0,
963                } as u64;
964                if n > *fuel {
965                    return Err(WeftError::FuelExhausted);
966                }
967                *fuel -= n;
968                if n as usize > MAX_LIST {
969                    return Err(WeftError::FuelExhausted);
970                }
971            }
972            prim(*op, vals)
973        }
974        Term::Rec(fields) => {
975            let mut out = BTreeMap::new();
976            for (k, v) in fields {
977                out.insert(k.clone(), eval(v, env, m, fuel)?);
978            }
979            Value::Rec(out)
980        }
981        Term::Get(r, k) => match eval(r, env, m, fuel)? {
982            Value::Rec(fs) => fs[k].clone(),
983            _ => unreachable!("verified"),
984        },
985        Term::ListNew(items) => {
986            let vals: Result<Vec<Value>, _> = items.iter().map(|i| eval(i, env, m, fuel)).collect();
987            Value::List(vals?)
988        }
989        Term::Call(h, args) => {
990            let vals: Result<Vec<Value>, _> = args.iter().map(|a| eval(a, env, m, fuel)).collect();
991            let def = m
992                .defs
993                .get(h)
994                .ok_or_else(|| WeftError::UnknownCall(h.to_string()))?;
995            let mut callee_env = vals?;
996            eval(&def.body, &mut callee_env, m, fuel)?
997        }
998        Term::Effect(kind, fields) => {
999            let mut out = BTreeMap::new();
1000            for (k, v) in fields {
1001                out.insert(k.clone(), eval(v, env, m, fuel)?);
1002            }
1003            Value::Action {
1004                kind: *kind,
1005                fields: out,
1006            }
1007        }
1008        Term::Map { cap, list, body } => {
1009            let Value::List(items) = eval(list, env, m, fuel)? else {
1010                unreachable!("verified")
1011            };
1012            let mut out = Vec::new();
1013            for item in items.into_iter().take(*cap as usize) {
1014                env.push(item);
1015                let v = eval(body, env, m, fuel);
1016                env.pop();
1017                out.push(v?);
1018            }
1019            Value::List(out)
1020        }
1021        Term::Fold {
1022            cap,
1023            list,
1024            init,
1025            body,
1026        } => {
1027            let Value::List(items) = eval(list, env, m, fuel)? else {
1028                unreachable!("verified")
1029            };
1030            let mut acc = eval(init, env, m, fuel)?;
1031            for item in items.into_iter().take(*cap as usize) {
1032                env.push(acc);
1033                env.push(item);
1034                let v = eval(body, env, m, fuel);
1035                env.pop();
1036                env.pop();
1037                acc = v?;
1038            }
1039            acc
1040        }
1041        Term::Iota { cap, count } => {
1042            let Value::Int(n) = eval(count, env, m, fuel)? else {
1043                unreachable!("verified")
1044            };
1045            let n = n.max(0).min(*cap as i64);
1046            Value::List((0..n).map(Value::Int).collect())
1047        }
1048    })
1049}
1050
1051fn prim(op: PrimOp, vals: Vec<Value>) -> Value {
1052    use PrimOp::*;
1053    use Value::*;
1054    match (op, vals.as_slice()) {
1055        (Add, [Int(a), Int(b)]) => Int(a.wrapping_add(*b)),
1056        (Sub, [Int(a), Int(b)]) => Int(a.wrapping_sub(*b)),
1057        (Mul, [Int(a), Int(b)]) => Int(a.wrapping_mul(*b)),
1058        // Total division: /0 = 0 (documented; no traps exist in Weft).
1059        (Div, [Int(a), Int(b)]) => Int(if *b == 0 { 0 } else { a.wrapping_div(*b) }),
1060        (Lt, [Int(a), Int(b)]) => Bool(a < b),
1061        (Le, [Int(a), Int(b)]) => Bool(a <= b),
1062        (EqInt, [Int(a), Int(b)]) => Bool(a == b),
1063        (EqText, [Text(a), Text(b)]) => Bool(a == b),
1064        (And, [Bool(a), Bool(b)]) => Bool(*a && *b),
1065        (Or, [Bool(a), Bool(b)]) => Bool(*a || *b),
1066        (Not, [Bool(a)]) => Bool(!a),
1067        (Concat, [Text(a), Text(b)]) => Text(format!("{a}{b}")),
1068        (ToText, [Int(a)]) => Text(a.to_string()),
1069        (FAdd, [Fix(a), Fix(b)]) => Fix(a.wrapping_add(*b)),
1070        (FSub, [Fix(a), Fix(b)]) => Fix(a.wrapping_sub(*b)),
1071        (FMul, [Fix(a), Fix(b)]) => Fix(((*a as i128 * *b as i128) / FIX_SCALE as i128) as i64),
1072        (FDiv, [Fix(a), Fix(b)]) => Fix(if *b == 0 {
1073            0
1074        } else {
1075            ((*a as i128 * FIX_SCALE as i128) / *b as i128) as i64
1076        }),
1077        (FLt, [Fix(a), Fix(b)]) => Bool(a < b),
1078        (FLe, [Fix(a), Fix(b)]) => Bool(a <= b),
1079        (EqFix, [Fix(a), Fix(b)]) => Bool(a == b),
1080        (FixOfInt, [Int(a)]) => Fix(a.wrapping_mul(FIX_SCALE)),
1081        (IntOfFix, [Fix(a)]) => Int(a / FIX_SCALE),
1082        (FixToText, [Fix(a)]) => Text(fix_to_text(*a)),
1083        (Len, [List(xs)]) => Int(xs.len() as i64),
1084        (FSin, [Fix(a)]) => Fix(fix_sin(*a)),
1085        (FCos, [Fix(a)]) => Fix(fix_sin(a.wrapping_add(FIX_PI / 2))),
1086        (ListCat, [List(a), List(b)]) => {
1087            let mut out = a.clone();
1088            out.extend(b.iter().cloned());
1089            List(out)
1090        }
1091        _ => unreachable!("verified"),
1092    }
1093}
1094
1095/// π in Fix micros — the reference constant every host shares.
1096pub const FIX_PI: i64 = 3_141_593;
1097/// τ (2π) in Fix micros.
1098pub const FIX_TAU: i64 = 6_283_185;
1099
1100/// Deterministic fixed-point sine: range-reduce to [0, π] with sign, then
1101/// Bhaskara I's rational approximation `16x(π−x) / (5π² − 4x(π−x))` in i128 —
1102/// integer in, integer out, identical everywhere.
1103fn fix_sin(x: i64) -> i64 {
1104    let mut r = x % FIX_TAU;
1105    if r < 0 {
1106        r += FIX_TAU;
1107    }
1108    let (r, sign) = if r > FIX_PI {
1109        (r - FIX_PI, -1i128)
1110    } else {
1111        (r, 1i128)
1112    };
1113    let x = r as i128;
1114    let pi = FIX_PI as i128;
1115    let scale = FIX_SCALE as i128;
1116    let num = 16 * x * (pi - x); // ≤ 16·(π/2)² ≈ 4e13 — well inside i128
1117    let den = 5 * pi * pi - 4 * x * (pi - x);
1118    if den == 0 {
1119        return 0;
1120    }
1121    ((sign * num * scale) / den) as i64
1122}
1123
1124/// Canonical decimal rendering of a Fix: sign, integer part, then up to six
1125/// fractional digits with trailing zeros trimmed. Total and deterministic —
1126/// this string is part of the language's observable behavior.
1127fn fix_to_text(raw: i64) -> String {
1128    let neg = raw < 0;
1129    let mag = (raw as i128).unsigned_abs();
1130    let whole = mag / FIX_SCALE as u128;
1131    let frac = (mag % FIX_SCALE as u128) as u64;
1132    let mut s = String::new();
1133    if neg {
1134        s.push('-');
1135    }
1136    s.push_str(&whole.to_string());
1137    if frac != 0 {
1138        let f = format!("{frac:06}");
1139        s.push('.');
1140        s.push_str(f.trim_end_matches('0'));
1141    }
1142    s
1143}
1144
1145// ---------------------------------------------------------------------------
1146// Tests — the calculus proves itself headlessly
1147// ---------------------------------------------------------------------------
1148
1149#[cfg(test)]
1150mod tests {
1151    use super::*;
1152
1153    /// Bounded iteration: map doubles, fold sums, the cap truncates, and the
1154    /// static fuel bound covers the worst case — loops that cannot run away.
1155    #[test]
1156    fn bounded_map_and_fold_stay_total() {
1157        let body = Term::Rec(
1158            [
1159                (
1160                    "doubled".to_string(),
1161                    Term::Map {
1162                        cap: 8,
1163                        list: Box::new(Term::ListNew(vec![
1164                            Term::Int(1),
1165                            Term::Int(2),
1166                            Term::Int(3),
1167                        ])),
1168                        body: Box::new(Term::Prim(PrimOp::Mul, vec![Term::Var(0), Term::Int(2)])),
1169                    },
1170                ),
1171                (
1172                    "sum".to_string(),
1173                    Term::Fold {
1174                        cap: 8,
1175                        list: Box::new(Term::ListNew(vec![
1176                            Term::Int(10),
1177                            Term::Int(20),
1178                            Term::Int(30),
1179                        ])),
1180                        init: Box::new(Term::Int(0)),
1181                        body: Box::new(Term::Prim(PrimOp::Add, vec![Term::Var(1), Term::Var(0)])),
1182                    },
1183                ),
1184                (
1185                    "capped".to_string(),
1186                    Term::Fold {
1187                        cap: 2, // only the first two elements count
1188                        list: Box::new(Term::ListNew(vec![
1189                            Term::Int(1),
1190                            Term::Int(1),
1191                            Term::Int(1),
1192                        ])),
1193                        init: Box::new(Term::Int(0)),
1194                        body: Box::new(Term::Prim(PrimOp::Add, vec![Term::Var(1), Term::Var(0)])),
1195                    },
1196                ),
1197                (
1198                    "count".to_string(),
1199                    Term::Prim(
1200                        PrimOp::Len,
1201                        vec![Term::ListNew(vec![Term::Int(7), Term::Int(7)])],
1202                    ),
1203                ),
1204            ]
1205            .into(),
1206        );
1207        let ret = Ty::Record(
1208            [
1209                ("doubled".to_string(), Ty::List(Box::new(Ty::Int))),
1210                ("sum".to_string(), Ty::Int),
1211                ("capped".to_string(), Ty::Int),
1212                ("count".to_string(), Ty::Int),
1213            ]
1214            .into(),
1215        );
1216        let def = Def {
1217            params: vec![],
1218            ret,
1219            effects: BTreeSet::new(),
1220            body,
1221            pre: None,
1222            post: None,
1223        };
1224        let m = Module::build(vec![def], 0).unwrap();
1225        let certs = verify_module(&m).expect("bounded iteration verifies");
1226        let bound = certs[&m.entry].fuel_bound;
1227        let out = eval_call(&m, m.entry, vec![], bound + 8).unwrap();
1228        assert!(
1229            out.fuel_spent <= bound,
1230            "static bound covers the run ({} <= {bound})",
1231            out.fuel_spent
1232        );
1233        let Value::Rec(r) = out.value else { panic!() };
1234        assert_eq!(
1235            r["doubled"],
1236            Value::List(vec![Value::Int(2), Value::Int(4), Value::Int(6)])
1237        );
1238        assert_eq!(r["sum"], Value::Int(60));
1239        assert_eq!(r["capped"], Value::Int(2), "cap truncates");
1240        assert_eq!(r["count"], Value::Int(2));
1241    }
1242
1243    /// Fixed-point is exact and total: 0.1 +. 0.2 is EXACTLY 0.3 (no float
1244    /// drift), ÷0 is 0, and the decimal rendering is canonical.
1245    #[test]
1246    fn fix_arithmetic_is_exact_total_and_typed() {
1247        let fx = |m: i64| Term::Fix(m);
1248        let body = Term::Rec(
1249            [
1250                (
1251                    "sum".to_string(),
1252                    Term::Prim(PrimOp::FAdd, vec![fx(100_000), fx(200_000)]),
1253                ),
1254                (
1255                    "prod".to_string(),
1256                    Term::Prim(PrimOp::FMul, vec![fx(1_500_000), fx(2_000_000)]),
1257                ),
1258                (
1259                    "quot".to_string(),
1260                    Term::Prim(PrimOp::FDiv, vec![fx(1_000_000), fx(0)]),
1261                ),
1262                (
1263                    "text".to_string(),
1264                    Term::Prim(
1265                        PrimOp::FixToText,
1266                        vec![Term::Prim(PrimOp::FSub, vec![fx(0), fx(250_000)])],
1267                    ),
1268                ),
1269                (
1270                    "trunc".to_string(),
1271                    Term::Prim(PrimOp::IntOfFix, vec![fx(2_900_000)]),
1272                ),
1273            ]
1274            .into(),
1275        );
1276        let ret = Ty::Record(
1277            [
1278                ("sum".to_string(), Ty::Fix),
1279                ("prod".to_string(), Ty::Fix),
1280                ("quot".to_string(), Ty::Fix),
1281                ("text".to_string(), Ty::Text),
1282                ("trunc".to_string(), Ty::Int),
1283            ]
1284            .into(),
1285        );
1286        let def = Def {
1287            params: vec![],
1288            ret,
1289            effects: BTreeSet::new(),
1290            body,
1291            pre: None,
1292            post: None,
1293        };
1294        let m = Module::build(vec![def], 0).unwrap();
1295        verify_module(&m).expect("fix ops verify");
1296        let out = eval_call(&m, m.entry, vec![], 10_000).unwrap();
1297        let Value::Rec(r) = out.value else { panic!() };
1298        assert_eq!(r["sum"], Value::Fix(300_000)); // 0.1 + 0.2 == 0.3, exactly
1299        assert_eq!(r["prod"], Value::Fix(3_000_000)); // 1.5 × 2 == 3
1300        assert_eq!(r["quot"], Value::Fix(0)); // total ÷0
1301        assert_eq!(r["text"], Value::Text("-0.25".into()));
1302        assert_eq!(r["trunc"], Value::Int(2));
1303    }
1304
1305    /// Mixing Fix and Int without a conversion is a TYPE ERROR — the two
1306    /// numeric worlds only meet through fix/trunc.
1307    #[test]
1308    fn fix_and_int_do_not_mix_silently() {
1309        let body = Term::Prim(PrimOp::FAdd, vec![Term::Fix(1), Term::Int(1)]);
1310        let def = Def {
1311            params: vec![],
1312            ret: Ty::Fix,
1313            effects: BTreeSet::new(),
1314            body,
1315            pre: None,
1316            post: None,
1317        };
1318        let m = Module::build(vec![def], 0).unwrap();
1319        assert!(matches!(verify_module(&m), Err(WeftError::Type(_))));
1320    }
1321
1322    fn rec(fields: Vec<(&str, Term)>) -> Term {
1323        Term::Rec(
1324            fields
1325                .into_iter()
1326                .map(|(k, v)| (k.to_string(), v))
1327                .collect(),
1328        )
1329    }
1330    fn rec_ty(fields: Vec<(&str, Ty)>) -> Ty {
1331        Ty::Record(
1332            fields
1333                .into_iter()
1334                .map(|(k, v)| (k.to_string(), v))
1335                .collect(),
1336        )
1337    }
1338
1339    /// The Meadow's chop-a-tree interaction, as a Weft behavior:
1340    /// `(state {hits: Int}, event {}) → {state, actions}` — three chops fell
1341    /// the tree, granting wood, a message, and a despawn.
1342    fn chop_module() -> Module {
1343        let state_ty = rec_ty(vec![("hits", Ty::Int)]);
1344        let out_ty = rec_ty(vec![
1345            ("actions", Ty::List(Box::new(Ty::Action))),
1346            ("state", state_ty.clone()),
1347        ]);
1348        // params: [state (Var 1), event (Var 0)]
1349        let hits_plus_one = Term::Prim(
1350            PrimOp::Add,
1351            vec![
1352                Term::Get(Box::new(Term::Var(1)), "hits".into()),
1353                Term::Int(1),
1354            ],
1355        );
1356        let body = Term::Let(
1357            Box::new(hits_plus_one), // Var 0 = new hit count (state→2, event→1)
1358            Box::new(Term::If(
1359                Box::new(Term::Prim(PrimOp::Le, vec![Term::Int(3), Term::Var(0)])),
1360                // Felled: give wood ×3, message, despawn; reset hits.
1361                Box::new(rec(vec![
1362                    (
1363                        "actions",
1364                        Term::ListNew(vec![
1365                            Term::Effect(
1366                                EffectKind::GiveItem,
1367                                [
1368                                    ("item".to_string(), Term::Int(20100001)),
1369                                    ("count".to_string(), Term::Int(3)),
1370                                ]
1371                                .into(),
1372                            ),
1373                            Term::Effect(
1374                                EffectKind::Notify,
1375                                [("text".to_string(), Term::Text("The tree falls.".into()))].into(),
1376                            ),
1377                            Term::Effect(EffectKind::Despawn, BTreeMap::new()),
1378                        ]),
1379                    ),
1380                    ("state", rec(vec![("hits", Term::Int(0))])),
1381                ])),
1382                // Not yet: keep counting.
1383                Box::new(rec(vec![
1384                    ("actions", Term::ListNew(vec![])),
1385                    ("state", rec(vec![("hits", Term::Var(0))])),
1386                ])),
1387            )),
1388        );
1389        let def = Def {
1390            params: vec![state_ty, rec_ty(vec![])],
1391            ret: out_ty,
1392            effects: [
1393                EffectKind::GiveItem,
1394                EffectKind::Notify,
1395                EffectKind::Despawn,
1396            ]
1397            .into(),
1398            body,
1399            pre: None,
1400            post: None,
1401        };
1402        Module::build(vec![def], 0).unwrap()
1403    }
1404
1405    fn state(hits: i64) -> Value {
1406        Value::Rec([("hits".to_string(), Value::Int(hits))].into())
1407    }
1408    fn event() -> Value {
1409        Value::Rec(BTreeMap::new())
1410    }
1411
1412    #[test]
1413    fn identity_is_content_and_only_content() {
1414        let a = chop_module();
1415        let b = chop_module();
1416        assert_eq!(a.entry, b.entry, "same structure, same hash — always");
1417        // One constant changed → a different program, a different identity.
1418        let mut defs: Vec<Def> = a.defs.values().cloned().collect();
1419        if let Term::Let(_, body) = &mut defs[0].body {
1420            if let Term::If(cond, _, _) = body.as_mut() {
1421                **cond = Term::Prim(PrimOp::Le, vec![Term::Int(4), Term::Var(0)]);
1422            }
1423        }
1424        let c = Module::build(defs, 0).unwrap();
1425        assert_ne!(a.entry, c.entry);
1426        // And the rendering is stable + prefixed.
1427        assert!(a.entry.to_string().starts_with("weft:"));
1428    }
1429
1430    #[test]
1431    fn the_chop_behavior_verifies_and_runs() {
1432        let m = chop_module();
1433        let certs = verify_module(&m).expect("verifies");
1434        let cert = &certs[&m.entry];
1435        assert_eq!(
1436            cert.effects,
1437            [
1438                EffectKind::GiveItem,
1439                EffectKind::Notify,
1440                EffectKind::Despawn
1441            ]
1442            .into()
1443        );
1444        // Two chops: nothing yet, hits accumulate.
1445        let r = eval_call(&m, m.entry, vec![state(1), event()], cert.fuel_bound).unwrap();
1446        let Value::Rec(out) = &r.value else { panic!() };
1447        assert_eq!(out["actions"], Value::List(vec![]));
1448        assert_eq!(out["state"], state(2));
1449        // Third chop: the tree falls.
1450        let r = eval_call(&m, m.entry, vec![state(2), event()], cert.fuel_bound).unwrap();
1451        let Value::Rec(out) = &r.value else { panic!() };
1452        let Value::List(actions) = &out["actions"] else {
1453            panic!()
1454        };
1455        assert_eq!(actions.len(), 3);
1456        assert!(matches!(
1457            &actions[0],
1458            Value::Action {
1459                kind: EffectKind::GiveItem,
1460                ..
1461            }
1462        ));
1463        assert_eq!(out["state"], state(0));
1464    }
1465
1466    #[test]
1467    fn the_effect_row_is_the_permission() {
1468        let m = chop_module();
1469        let mut defs: Vec<Def> = m.defs.values().cloned().collect();
1470        // Strip Despawn from the declared row; the body still constructs it.
1471        defs[0].effects.remove(&EffectKind::Despawn);
1472        let m2 = Module::build(defs, 0).unwrap();
1473        assert_eq!(
1474            verify_module(&m2).unwrap_err(),
1475            WeftError::EffectNotDeclared(EffectKind::Despawn),
1476        );
1477        // And a behavior CANNOT quietly gain commerce: constructing a buy
1478        // without declaring it is rejected the same way.
1479        let sneaky = Def {
1480            params: vec![],
1481            ret: Ty::Action,
1482            effects: BTreeSet::new(),
1483            body: Term::Effect(EffectKind::CommerceBuy, BTreeMap::new()),
1484            pre: None,
1485            post: None,
1486        };
1487        let m3 = Module::build(vec![sneaky], 0).unwrap();
1488        assert_eq!(
1489            verify_module(&m3).unwrap_err(),
1490            WeftError::EffectNotDeclared(EffectKind::CommerceBuy),
1491        );
1492    }
1493
1494    #[test]
1495    fn type_errors_are_rejected() {
1496        // if on an Int condition
1497        let bad = Def {
1498            params: vec![],
1499            ret: Ty::Int,
1500            effects: BTreeSet::new(),
1501            body: Term::If(
1502                Box::new(Term::Int(1)),
1503                Box::new(Term::Int(2)),
1504                Box::new(Term::Int(3)),
1505            ),
1506            pre: None,
1507            post: None,
1508        };
1509        let m = Module::build(vec![bad], 0).unwrap();
1510        assert!(matches!(verify_module(&m), Err(WeftError::Type(_))));
1511    }
1512
1513    #[test]
1514    fn static_fuel_bound_dominates_actual_cost() {
1515        let m = chop_module();
1516        let cert = &verify_module(&m).unwrap()[&m.entry];
1517        for hits in 0..5 {
1518            let r = eval_call(&m, m.entry, vec![state(hits), event()], cert.fuel_bound).unwrap();
1519            assert!(
1520                r.fuel_spent <= cert.fuel_bound,
1521                "spent {} > bound {}",
1522                r.fuel_spent,
1523                cert.fuel_bound
1524            );
1525        }
1526        // Determinism: same inputs, same fuel, same value — every time.
1527        let a = eval_call(&m, m.entry, vec![state(2), event()], cert.fuel_bound).unwrap();
1528        let b = eval_call(&m, m.entry, vec![state(2), event()], cert.fuel_bound).unwrap();
1529        assert_eq!(a.value, b.value);
1530        assert_eq!(a.fuel_spent, b.fuel_spent);
1531    }
1532
1533    #[test]
1534    fn calls_compose_by_hash_and_effects_flow_transitively() {
1535        // helper: double(x) = x + x  (pure)
1536        let double = Def {
1537            params: vec![Ty::Int],
1538            ret: Ty::Int,
1539            effects: BTreeSet::new(),
1540            body: Term::Prim(PrimOp::Add, vec![Term::Var(0), Term::Var(0)]),
1541            pre: None,
1542            post: None,
1543        };
1544        let dh = hash_def(&double);
1545        // shout(x): notify with double(x); declares Notify.
1546        let shout = Def {
1547            params: vec![Ty::Int],
1548            ret: Ty::Action,
1549            effects: [EffectKind::Notify].into(),
1550            body: Term::Effect(
1551                EffectKind::Notify,
1552                [("n".to_string(), Term::Call(dh, vec![Term::Var(0)]))].into(),
1553            ),
1554            pre: None,
1555            post: None,
1556        };
1557        let m = Module::build(vec![double, shout], 1).unwrap();
1558        let certs = verify_module(&m).unwrap();
1559        assert!(certs[&m.entry].effects.contains(&EffectKind::Notify));
1560        let r = eval_call(
1561            &m,
1562            m.entry,
1563            vec![Value::Int(21)],
1564            certs[&m.entry].fuel_bound,
1565        )
1566        .unwrap();
1567        assert_eq!(
1568            r.value,
1569            Value::Action {
1570                kind: EffectKind::Notify,
1571                fields: [("n".to_string(), Value::Int(42))].into()
1572            }
1573        );
1574        // A caller that FORGETS the callee's effect is rejected: rows are
1575        // transitive, not copy-paste.
1576        let sneaky = Def {
1577            params: vec![Ty::Int],
1578            ret: Ty::Action,
1579            effects: BTreeSet::new(), // declares nothing
1580            body: Term::Call(m.entry, vec![Term::Var(0)]),
1581            pre: None,
1582            post: None,
1583        };
1584        let mut defs: Vec<Def> = m.defs.values().cloned().collect();
1585        defs.push(sneaky);
1586        let m2 = Module::build(defs, 2).unwrap();
1587        assert_eq!(
1588            verify_module(&m2).unwrap_err(),
1589            WeftError::EffectNotDeclared(EffectKind::Notify)
1590        );
1591    }
1592
1593    #[test]
1594    fn contracts_gate_execution() {
1595        // inc(x) with pre: 0 <= x, post: x < result
1596        let inc = Def {
1597            params: vec![Ty::Int],
1598            ret: Ty::Int,
1599            effects: BTreeSet::new(),
1600            body: Term::Prim(PrimOp::Add, vec![Term::Var(0), Term::Int(1)]),
1601            pre: Some(Term::Prim(PrimOp::Le, vec![Term::Int(0), Term::Var(0)])),
1602            // post ctx: [x, result] — result is innermost (Var 0), x is Var 1.
1603            post: Some(Term::Prim(PrimOp::Lt, vec![Term::Var(1), Term::Var(0)])),
1604        };
1605        let m = Module::build(vec![inc], 0).unwrap();
1606        let cert = &verify_module(&m).unwrap()[&m.entry];
1607        let ok = eval_call(&m, m.entry, vec![Value::Int(5)], cert.fuel_bound + 16).unwrap();
1608        assert_eq!(ok.value, Value::Int(6));
1609        let bad = eval_call(&m, m.entry, vec![Value::Int(-1)], cert.fuel_bound + 16);
1610        assert_eq!(bad.unwrap_err(), WeftError::ContractViolated("pre"));
1611    }
1612
1613    #[test]
1614    fn list_cat_composes_and_stays_bounded() {
1615        // Composition: two lists of the same element type join.
1616        let cat = Def {
1617            params: vec![Ty::List(Box::new(Ty::Int)), Ty::List(Box::new(Ty::Int))],
1618            ret: Ty::List(Box::new(Ty::Int)),
1619            effects: BTreeSet::new(),
1620            body: Term::Prim(PrimOp::ListCat, vec![Term::Var(1), Term::Var(0)]),
1621            pre: None,
1622            post: None,
1623        };
1624        let m = Module::build(vec![cat], 0).unwrap();
1625        let out = eval_call(
1626            &m,
1627            m.entry,
1628            vec![
1629                Value::List(vec![Value::Int(1), Value::Int(2)]),
1630                Value::List(vec![Value::Int(3)]),
1631            ],
1632            10_000,
1633        )
1634        .unwrap();
1635        assert_eq!(
1636            out.value,
1637            Value::List(vec![Value::Int(1), Value::Int(2), Value::Int(3)])
1638        );
1639
1640        // Mismatched element types don't verify.
1641        let bad = Def {
1642            params: vec![Ty::List(Box::new(Ty::Int)), Ty::List(Box::new(Ty::Text))],
1643            ret: Ty::List(Box::new(Ty::Int)),
1644            effects: BTreeSet::new(),
1645            body: Term::Prim(PrimOp::ListCat, vec![Term::Var(1), Term::Var(0)]),
1646            pre: None,
1647            post: None,
1648        };
1649        assert!(
1650            Module::build(vec![bad], 0).is_err()
1651                || verify_module(
1652                    &Module::build(
1653                        vec![Def {
1654                            params: vec![Ty::List(Box::new(Ty::Int)), Ty::List(Box::new(Ty::Text))],
1655                            ret: Ty::List(Box::new(Ty::Int)),
1656                            effects: BTreeSet::new(),
1657                            body: Term::Prim(PrimOp::ListCat, vec![Term::Var(1), Term::Var(0)]),
1658                            pre: None,
1659                            post: None,
1660                        }],
1661                        0
1662                    )
1663                    .unwrap()
1664                )
1665                .is_err()
1666        );
1667
1668        // Growth costs fuel: doubling a list inside a fold exhausts the
1669        // budget instead of exploding memory. (16 doublings of a 4-element
1670        // seed would be 262144 elements; the fuel charge stops it long before.)
1671        let doubling = Def {
1672            params: vec![Ty::List(Box::new(Ty::Int))],
1673            ret: Ty::List(Box::new(Ty::Int)),
1674            effects: BTreeSet::new(),
1675            body: Term::Fold {
1676                cap: 16,
1677                list: Term::Iota {
1678                    cap: 16,
1679                    count: Box::new(Term::Int(16)),
1680                }
1681                .into(),
1682                init: Box::new(Term::Var(0)),
1683                // acc = Var 1, element = Var 0 → acc ++ acc
1684                body: Box::new(Term::Prim(
1685                    PrimOp::ListCat,
1686                    vec![Term::Var(1), Term::Var(1)],
1687                )),
1688            },
1689            pre: None,
1690            post: None,
1691        };
1692        let m = Module::build(vec![doubling], 0).unwrap();
1693        let seed = Value::List(vec![Value::Int(0); 4]);
1694        let r = eval_call(&m, m.entry, vec![seed], 5_000);
1695        assert!(
1696            matches!(r, Err(WeftError::FuelExhausted)),
1697            "runaway list growth is stopped by fuel, not by memory: {r:?}"
1698        );
1699    }
1700
1701    #[test]
1702    fn division_is_total() {
1703        let d = Def {
1704            params: vec![Ty::Int, Ty::Int],
1705            ret: Ty::Int,
1706            effects: BTreeSet::new(),
1707            body: Term::Prim(PrimOp::Div, vec![Term::Var(1), Term::Var(0)]),
1708            pre: None,
1709            post: None,
1710        };
1711        let m = Module::build(vec![d], 0).unwrap();
1712        let cert = &verify_module(&m).unwrap()[&m.entry];
1713        let r = eval_call(
1714            &m,
1715            m.entry,
1716            vec![Value::Int(7), Value::Int(0)],
1717            cert.fuel_bound,
1718        )
1719        .unwrap();
1720        assert_eq!(r.value, Value::Int(0), "x/0 = 0 — no traps exist in Weft");
1721    }
1722}