Skip to main content

thread_engine/
behavior_weft.rs

1//! Weft behaviors — the Thread's **native** code, running beside the WASM
2//! floor (weft-v0.1 · behavior-abi-v0.1).
3//!
4//! A manifest behavior may name a `weft` asset instead of a `wasm` one: a
5//! serialized [`weft::Module`]. It is **verified on load** — types, transitive
6//! effect rows, contracts, static fuel bound — and evaluated per event on the
7//! reference interpreter. Always available (no feature gate, no runtime dep):
8//! the native path is lighter than the floor.
9//!
10//! The behavior contract mirrors the ABI's: the entry definition is
11//! `(state, event) → { state, actions }`. The browser constructs the event by
12//! **projecting the declared event type** — a behavior receives exactly the
13//! fields its type asks for, defaulted where the browser has nothing to offer.
14//! Total in, total out: a Weft behavior cannot crash a world, run away, or
15//! perform an effect its row doesn't declare.
16
17use std::collections::BTreeMap;
18
19use weft::{EffectKind, Module, Ty, Value};
20
21use crate::behavior::Action;
22
23/// A loaded, verified Weft behavior: the module, its persistent state value,
24/// and the fuel ceiling (static bound + contract headroom).
25pub struct WeftBehavior {
26    module: Module,
27    state: Value,
28    fuel: u64,
29}
30
31impl WeftBehavior {
32    /// Parse + verify a serialized module (the `weft` asset's JSON form).
33    /// Verification here is the trust boundary — nothing unverified evaluates.
34    pub fn from_json(text: &str) -> Result<Self, String> {
35        let module: Module = serde_json::from_str(text).map_err(|e| e.to_string())?;
36        let certs = weft::verify_module(&module).map_err(|e| e.to_string())?;
37        let cert = certs
38            .get(&module.entry)
39            .ok_or("module has no entry certificate")?;
40        let entry = module
41            .defs
42            .get(&module.entry)
43            .ok_or("module has no entry def")?;
44        if entry.params.len() != 2 {
45            return Err("a weft behavior's entry must take (state, event)".into());
46        }
47        let state = default_value(&entry.params[0]);
48        // Contracts evaluate inside the same budget; give them headroom.
49        Ok(Self {
50            fuel: cert.fuel_bound + 1024,
51            module,
52            state,
53        })
54    }
55
56    /// Load a behavior from a **package** (`.weftpack.json`) by export petname
57    /// — the `weft-use` path: verify the package, link the export's closure
58    /// into a module, then the usual entry checks. One published export, one
59    /// line of markup, a running behavior.
60    pub fn from_package_json(text: &str, export: &str) -> Result<Self, String> {
61        let pkg: weft::pack::Package = serde_json::from_str(text).map_err(|e| e.to_string())?;
62        pkg.verify().map_err(|e| e.to_string())?;
63        let hash = pkg
64            .export(export)
65            .ok_or_else(|| format!("package '{}' has no export '{export}'", pkg.name))?;
66        let entry_def = pkg
67            .defs
68            .get(&hash)
69            .cloned()
70            .ok_or("export verified to exist")?;
71        let module = weft::pack::link(&[pkg], vec![entry_def], 0).map_err(|e| e.to_string())?;
72        Self::from_json(&serde_json::to_string(&module).map_err(|e| e.to_string())?)
73    }
74
75    /// The effects this behavior may request (its verified row) — for HUD /
76    /// permission surfaces.
77    pub fn effects(&self) -> Vec<EffectKind> {
78        self.module.defs[&self.module.entry]
79            .effects
80            .iter()
81            .copied()
82            .collect()
83    }
84
85    /// Dispatch one interact event. State persists across calls; failures
86    /// (fuel, contract) degrade to no actions — the world keeps rendering.
87    pub fn on_interact(&mut self, name: &str, slug: Option<&str>) -> Vec<Action> {
88        let entry = &self.module.defs[&self.module.entry];
89        let event = project_event(
90            &entry.params[1],
91            &[
92                ("name", Value::Text(name.to_string())),
93                ("slug", Value::Text(slug.unwrap_or("").to_string())),
94            ],
95        );
96        let out = match weft::eval_call(
97            &self.module,
98            self.module.entry,
99            vec![self.state.clone(), event],
100            self.fuel,
101        ) {
102            Ok(o) => o,
103            Err(e) => {
104                tracing::warn!("weft behavior: {e}");
105                return Vec::new();
106            }
107        };
108        let Value::Rec(mut rec) = out.value else {
109            return Vec::new();
110        };
111        if let Some(st) = rec.remove("state") {
112            self.state = st;
113        }
114        match rec.remove("actions") {
115            Some(Value::List(actions)) => actions.into_iter().filter_map(to_action).collect(),
116            _ => Vec::new(),
117        }
118    }
119
120    /// Dispatch one tick. The event record offers `kind: "tick"` and `dt_ms`
121    /// (the browser throttles cadence); a behavior's event type picks the
122    /// fields it wants, same total projection as interact. This is what makes
123    /// a world *behave* rather than only react — native code with a heartbeat,
124    /// still fuel-metered every beat.
125    pub fn on_tick(&mut self, dt_ms: i64) -> Vec<Action> {
126        let entry = &self.module.defs[&self.module.entry];
127        let event = project_event(
128            &entry.params[1],
129            &[
130                ("kind", Value::Text("tick".into())),
131                ("dt_ms", Value::Int(dt_ms)),
132            ],
133        );
134        let out = match weft::eval_call(
135            &self.module,
136            self.module.entry,
137            vec![self.state.clone(), event],
138            self.fuel,
139        ) {
140            Ok(o) => o,
141            Err(e) => {
142                tracing::warn!("weft tick: {e}");
143                return Vec::new();
144            }
145        };
146        let Value::Rec(mut rec) = out.value else {
147            return Vec::new();
148        };
149        if let Some(st) = rec.remove("state") {
150            self.state = st;
151        }
152        match rec.remove("actions") {
153            Some(Value::List(actions)) => actions.into_iter().filter_map(to_action).collect(),
154            _ => Vec::new(),
155        }
156    }
157}
158
159/// The zero value of a type — behavior state starts here.
160fn default_value(ty: &Ty) -> Value {
161    match ty {
162        Ty::Int => Value::Int(0),
163        Ty::Fix => Value::Fix(0),
164        Ty::Bool => Value::Bool(false),
165        Ty::Text => Value::Text(String::new()),
166        Ty::Action => Value::List(Vec::new()), // never used as state; safe filler
167        Ty::List(_) => Value::List(Vec::new()),
168        Ty::Record(fields) => Value::Rec(
169            fields
170                .iter()
171                .map(|(k, t)| (k.clone(), default_value(t)))
172                .collect(),
173        ),
174    }
175}
176
177/// Build the event record a behavior's declared type asks for: named fields
178/// the browser can supply (type-matched), everything else defaulted. Total —
179/// a behavior can never be handed a value outside its type.
180fn project_event(ty: &Ty, available: &[(&str, Value)]) -> Value {
181    let Ty::Record(fields) = ty else {
182        return default_value(ty);
183    };
184    let mut out = BTreeMap::new();
185    for (k, t) in fields {
186        let supplied = available
187            .iter()
188            .find(|(name, v)| name == k && type_of(v) == *t)
189            .map(|(_, v)| v.clone());
190        out.insert(k.clone(), supplied.unwrap_or_else(|| default_value(t)));
191    }
192    Value::Rec(out)
193}
194
195fn type_of(v: &Value) -> Ty {
196    match v {
197        Value::Int(_) => Ty::Int,
198        Value::Fix(_) => Ty::Fix,
199        Value::Bool(_) => Ty::Bool,
200        Value::Text(_) => Ty::Text,
201        Value::Action { .. } => Ty::Action,
202        Value::List(_) => Ty::List(Box::new(Ty::Action)),
203        Value::Rec(fs) => Ty::Record(fs.iter().map(|(k, x)| (k.clone(), type_of(x))).collect()),
204    }
205}
206
207/// Map a constructed Weft effect value onto the browser's [`Action`] set.
208fn to_action(v: Value) -> Option<Action> {
209    let Value::Action { kind, fields } = v else {
210        return None;
211    };
212    let text = |k: &str| match fields.get(k) {
213        Some(Value::Text(s)) => s.clone(),
214        _ => String::new(),
215    };
216    let int = |k: &str| match fields.get(k) {
217        Some(Value::Int(i)) => *i,
218        _ => 0,
219    };
220    Some(match kind {
221        EffectKind::Notify => Action::Notify {
222            text: text("text"),
223            level: None,
224        },
225        EffectKind::Navigate => Action::Navigate { to: text("to") },
226        EffectKind::CodexOpen => Action::CodexOpen { slug: text("slug") },
227        EffectKind::CommerceBuy => Action::CommerceBuy {
228            item: text("item"),
229            price_ref: serde_json::Value::from(int("price_ref")),
230        },
231        EffectKind::GiveItem => Action::GiveItem {
232            item: int("item") as u16,
233            count: int("count").max(1) as u32,
234        },
235        EffectKind::Despawn => Action::Despawn {
236            position: [0.0, 0.0, 0.0],
237        },
238        // The scene ABI: `set_state` names a placement and patches its visible
239        // state — every non-`placement` field rides along as the JSON patch
240        // (a `text` field re-typesets a panel; more verbs join over time).
241        EffectKind::SetState => {
242            let placement = match fields.get("placement") {
243                Some(Value::Text(s)) if !s.is_empty() => Some(s.clone()),
244                _ => None,
245            };
246            let mut patch = serde_json::Map::new();
247            for (k, v) in &fields {
248                if k != "placement" {
249                    patch.insert(k.clone(), value_to_json(v));
250                }
251            }
252            Action::SetState {
253                placement,
254                patch: serde_json::Value::Object(patch),
255            }
256        }
257        // Weft is integer-exact, so spatial fields cross in centimetres and
258        // colors in 0–255 — the mapping to browser floats happens here, once.
259        EffectKind::Spawn => {
260            // Spatial fields: Fix metres first-class (`x`/`y`/`z`/`scale`),
261            // integer centimetres kept for older modules.
262            let fix = |k: &str| match fields.get(k) {
263                Some(Value::Fix(raw)) => Some(*raw as f32 / weft::FIX_SCALE as f32),
264                _ => None,
265            };
266            let axis = |m: &str, cm: &str| fix(m).unwrap_or_else(|| int(cm) as f32 / 100.0);
267            Action::Spawn {
268                builtin: {
269                    let b = text("builtin");
270                    if b.is_empty() {
271                        "sphere".to_string()
272                    } else {
273                        b
274                    }
275                },
276                position: [axis("x", "x_cm"), axis("y", "y_cm"), axis("z", "z_cm")],
277                scale: fix("scale")
278                    .unwrap_or_else(|| int("scale_cm").max(1) as f32 / 100.0)
279                    .clamp(0.01, 50.0),
280                color: [
281                    (int("r").clamp(0, 255) as f32) / 255.0,
282                    (int("g").clamp(0, 255) as f32) / 255.0,
283                    (int("b").clamp(0, 255) as f32) / 255.0,
284                ],
285                name: text("name"),
286                animate: text("animate"),
287                anim_speed: fix("speed").unwrap_or(1.0),
288                anim_amp: fix("amp").unwrap_or(0.25),
289            }
290        }
291        // No browser-side mapping yet — dropped, never invented.
292        EffectKind::PresenceEmit => return None,
293    })
294}
295
296/// A Weft value as JSON — for the `set_state` patch (data crossing the seam).
297fn value_to_json(v: &Value) -> serde_json::Value {
298    match v {
299        Value::Int(i) => serde_json::Value::from(*i),
300        // Fixed-point crosses the seam as a plain number — millionths → f64
301        // exactly (i64 magnitudes here are far under 2^53).
302        Value::Fix(raw) => serde_json::Value::from(*raw as f64 / weft::FIX_SCALE as f64),
303        Value::Bool(b) => serde_json::Value::Bool(*b),
304        Value::Text(s) => serde_json::Value::String(s.clone()),
305        Value::List(xs) => serde_json::Value::Array(xs.iter().map(value_to_json).collect()),
306        Value::Rec(fs) => serde_json::Value::Object(
307            fs.iter()
308                .map(|(k, x)| (k.clone(), value_to_json(x)))
309                .collect(),
310        ),
311        Value::Action { .. } => serde_json::Value::Null,
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use std::collections::BTreeSet;
319    use weft::{Def, PrimOp, Term};
320
321    /// A ticking module: counts beats in state and rewrites a plaque each
322    /// beat — the heartbeat path end to end (project tick event → eval →
323    /// set_state action), state persisting across ticks.
324    #[test]
325    fn tick_dispatch_counts_and_rewrites() {
326        use weft::EffectKind as EK;
327        let state_ty = rec_ty(vec![("beats", Ty::Int)]);
328        let out_ty = rec_ty(vec![
329            ("actions", Ty::List(Box::new(Ty::Action))),
330            ("state", state_ty.clone()),
331        ]);
332        let body = Term::Let(
333            Box::new(Term::Prim(
334                PrimOp::Add,
335                vec![
336                    Term::Get(Box::new(Term::Var(1)), "beats".into()),
337                    Term::Int(1),
338                ],
339            )),
340            Box::new(Term::Rec(
341                [
342                    (
343                        "actions".to_string(),
344                        Term::ListNew(vec![Term::Effect(
345                            EK::SetState,
346                            [
347                                ("placement".to_string(), Term::Text("clock".into())),
348                                (
349                                    "text".to_string(),
350                                    Term::Prim(
351                                        PrimOp::Concat,
352                                        vec![
353                                            Term::Text("Beats: ".into()),
354                                            Term::Prim(PrimOp::ToText, vec![Term::Var(0)]),
355                                        ],
356                                    ),
357                                ),
358                            ]
359                            .into(),
360                        )]),
361                    ),
362                    (
363                        "state".to_string(),
364                        Term::Rec([("beats".to_string(), Term::Var(0))].into()),
365                    ),
366                ]
367                .into(),
368            )),
369        );
370        let def = weft::Def {
371            params: vec![state_ty, rec_ty(vec![])],
372            ret: out_ty,
373            effects: BTreeSet::from([EK::SetState]),
374            body,
375            pre: None,
376            post: None,
377        };
378        let module = weft::Module::build(vec![def], 0).unwrap();
379        let json = serde_json::to_string(&module).unwrap();
380        let mut b = WeftBehavior::from_json(&json).expect("verifies");
381        let a1 = b.on_tick(250);
382        let a2 = b.on_tick(250);
383        let patch_text = |a: &[Action]| match &a[0] {
384            Action::SetState { placement, patch } => {
385                assert_eq!(placement.as_deref(), Some("clock"));
386                patch["text"].as_str().unwrap().to_string()
387            }
388            other => panic!("wrong action {other:?}"),
389        };
390        assert_eq!(patch_text(&a1), "Beats: 1");
391        assert_eq!(patch_text(&a2), "Beats: 2", "state persists across ticks");
392    }
393
394    /// The scene ABI's wire shape: a Weft `set_state` effect becomes an
395    /// `Action::SetState` whose non-`placement` fields ride as the JSON patch.
396    #[test]
397    fn set_state_effects_map_to_scene_patches() {
398        let v = Value::Action {
399            kind: EffectKind::SetState,
400            fields: [
401                ("placement".to_string(), Value::Text("visitors".into())),
402                (
403                    "text".to_string(),
404                    Value::Text("Visitors this session\n3".into()),
405                ),
406                ("count".to_string(), Value::Int(3)),
407            ]
408            .into(),
409        };
410        match to_action(v).expect("maps") {
411            Action::SetState { placement, patch } => {
412                assert_eq!(placement.as_deref(), Some("visitors"));
413                assert_eq!(patch["text"], "Visitors this session\n3");
414                assert_eq!(patch["count"], 3);
415                assert!(patch.get("placement").is_none());
416            }
417            other => panic!("wrong action: {other:?}"),
418        }
419    }
420
421    fn rec_ty(fields: Vec<(&str, Ty)>) -> Ty {
422        Ty::Record(
423            fields
424                .into_iter()
425                .map(|(k, v)| (k.to_string(), v))
426                .collect(),
427        )
428    }
429
430    /// A greeter behavior in Weft: counts visits in state, notifies with the
431    /// interacted object's name — proving verify→project→eval→Action end to end.
432    fn greeter_json() -> String {
433        let state_ty = rec_ty(vec![("visits", Ty::Int)]);
434        let event_ty = rec_ty(vec![("name", Ty::Text)]);
435        let out_ty = rec_ty(vec![
436            ("actions", Ty::List(Box::new(Ty::Action))),
437            ("state", state_ty.clone()),
438        ]);
439        // params: [state=Var1, event=Var0]
440        let body = Term::Rec(
441            [
442                (
443                    "actions".to_string(),
444                    Term::ListNew(vec![Term::Effect(
445                        EffectKind::Notify,
446                        [(
447                            "text".to_string(),
448                            Term::Prim(
449                                PrimOp::Concat,
450                                vec![
451                                    Term::Text("Welcome to ".into()),
452                                    Term::Get(Box::new(Term::Var(0)), "name".into()),
453                                ],
454                            ),
455                        )]
456                        .into(),
457                    )]),
458                ),
459                (
460                    "state".to_string(),
461                    Term::Rec(
462                        [(
463                            "visits".to_string(),
464                            Term::Prim(
465                                PrimOp::Add,
466                                vec![
467                                    Term::Get(Box::new(Term::Var(1)), "visits".into()),
468                                    Term::Int(1),
469                                ],
470                            ),
471                        )]
472                        .into(),
473                    ),
474                ),
475            ]
476            .into(),
477        );
478        let def = Def {
479            params: vec![state_ty, event_ty],
480            ret: out_ty,
481            effects: BTreeSet::from([EffectKind::Notify]),
482            body,
483            pre: None,
484            post: None,
485        };
486        serde_json::to_string(&Module::build(vec![def], 0).unwrap()).unwrap()
487    }
488
489    #[test]
490    fn a_weft_behavior_verifies_projects_and_acts() {
491        let mut b = WeftBehavior::from_json(&greeter_json()).expect("verifies + loads");
492        assert_eq!(b.effects(), vec![EffectKind::Notify]);
493        let actions = b.on_interact("The Waystone", None);
494        assert_eq!(
495            actions,
496            vec![Action::Notify {
497                text: "Welcome to The Waystone".into(),
498                level: None
499            }]
500        );
501        // State persisted: the visit counter climbed (observable determinism —
502        // interact twice, state advanced twice).
503        b.on_interact("The Waystone", None);
504        let Value::Rec(st) = &b.state else { panic!() };
505        assert_eq!(st["visits"], Value::Int(2));
506    }
507
508    /// Tier-2 datapoint (run with `--ignored --nocapture`): events/second
509    /// through the reference interpreter, greeter behavior, release profile
510    /// recommended. `bench_tier2_weft_vs_wasmi` is the matched pair.
511    #[test]
512    #[ignore]
513    fn bench_weft_events_per_second() {
514        let mut b = WeftBehavior::from_json(&greeter_json()).unwrap();
515        let n = 200_000u32;
516        let t0 = std::time::Instant::now();
517        let mut total_actions = 0usize;
518        for _ in 0..n {
519            total_actions += b.on_interact("The Waystone", None).len();
520        }
521        let secs = t0.elapsed().as_secs_f64();
522        println!(
523            "BENCH weft events/sec = {:.0} ({n} events in {secs:.3}s, {total_actions} actions)",
524            n as f64 / secs
525        );
526        assert_eq!(total_actions, n as usize);
527    }
528
529    /// Tier 2, the matched pair: the SAME observable behavior — one interact →
530    /// one notify action — through both production paths: Weft's verified
531    /// interpreter vs. a WASM module on wasmi (the Behavior ABI floor, with
532    /// its real JSON-over-linear-memory marshalling). Run:
533    /// `cargo test --release -p loom --features behaviors bench_tier2 -- --ignored --nocapture`
534    #[cfg(feature = "behaviors")]
535    #[test]
536    #[ignore]
537    fn bench_tier2_weft_vs_wasmi() {
538        use crate::behavior::{Actor, Behavior as _, InteractEvent};
539        use crate::behavior_wasm::WasmBehavior;
540
541        let n = 200_000u32;
542
543        // -- Weft side: static notify (state {}, event {}) → one action.
544        let notify_def = Def {
545            params: vec![Ty::Record(BTreeMap::new()), Ty::Record(BTreeMap::new())],
546            ret: Ty::Record(
547                [
548                    ("actions".to_string(), Ty::List(Box::new(Ty::Action))),
549                    ("state".to_string(), Ty::Record(BTreeMap::new())),
550                ]
551                .into(),
552            ),
553            effects: BTreeSet::from([EffectKind::Notify]),
554            body: Term::Rec(
555                [
556                    (
557                        "actions".to_string(),
558                        Term::ListNew(vec![Term::Effect(
559                            EffectKind::Notify,
560                            [("text".to_string(), Term::Text("The waystone hums.".into()))].into(),
561                        )]),
562                    ),
563                    ("state".to_string(), Term::Rec(BTreeMap::new())),
564                ]
565                .into(),
566            ),
567            pre: None,
568            post: None,
569        };
570        let module = Module::build(vec![notify_def], 0).unwrap();
571        let mut weft_b = WeftBehavior::from_json(&serde_json::to_string(&module).unwrap()).unwrap();
572
573        let t0 = std::time::Instant::now();
574        let mut weft_actions = 0usize;
575        for _ in 0..n {
576            weft_actions += weft_b.on_interact("waystone", None).len();
577        }
578        let weft_secs = t0.elapsed().as_secs_f64();
579
580        // -- wasmi side: the static-reply WAT module the navigator tests use —
581        // returns the same one-notify ActionList through the real ABI.
582        let reply = r#"{"actions":[{"action":"notify","text":"The waystone hums."}]}"#;
583        // Fixed scratch allocator: one allocation per event (the host's event
584        // buffer) at a constant offset — a bump allocator would exhaust the
585        // single page after ~1k events and rig the benchmark.
586        let wat = format!(
587            r#"(module
588              (memory (export "memory") 1)
589              (data (i32.const 0) "{data}")
590              (func (export "thread_alloc") (param i32) (result i32)
591                i32.const 4096)
592              (func (export "thread_on_interact") (param i32 i32) (result i64)
593                i64.const {len}))"#,
594            data = reply.replace('"', "\\\""),
595            len = reply.len(),
596        );
597        let wasm = wat::parse_str(&wat).unwrap();
598        let mut wasm_b = WasmBehavior::load(&wasm).unwrap();
599        let event = InteractEvent {
600            placement: "waystone".into(),
601            actor: Actor { passport_sub: None },
602            world: "meadow".into(),
603            data: serde_json::Value::Null,
604        };
605        let t0 = std::time::Instant::now();
606        let mut wasm_actions = 0usize;
607        for _ in 0..n {
608            wasm_actions += wasm_b.on_interact(&event).len();
609        }
610        let wasm_secs = t0.elapsed().as_secs_f64();
611
612        assert_eq!(weft_actions, n as usize);
613        assert_eq!(wasm_actions, n as usize);
614        println!(
615            "BENCH tier2 matched notify · weft {:.0} ev/s · wasmi {:.0} ev/s · ratio {:.2}x",
616            n as f64 / weft_secs,
617            n as f64 / wasm_secs,
618            wasm_secs / weft_secs,
619        );
620    }
621
622    #[test]
623    fn a_tampered_module_is_refused_at_the_door() {
624        // Body constructs Notify, but the declared row is empty — the exact
625        // module a malicious host might serve. Verification refuses it before
626        // a single step evaluates.
627        let bad = {
628            let mut m: Module = serde_json::from_str(&greeter_json()).unwrap();
629            let mut defs: Vec<Def> = m.defs.values().cloned().collect();
630            defs[0].effects.clear();
631            m = Module::build(defs, 0).unwrap();
632            serde_json::to_string(&m).unwrap()
633        };
634        assert!(WeftBehavior::from_json(&bad).is_err());
635    }
636}