Skip to content

Commit 51f9035

Browse files
feat(core): L1 formula context — pure λδ with self (the fx-field enabler) (#48)
## What this is The first step of **surfacing λδ in the product** (the "make it visible" work). This PR lands the load-bearing **core** piece — the UI "fx" field will sit directly on it — while staying small, pure, and headless-testable. A **formula** evaluation context (spec §5): `eval_formula(nb, self_id, src)` runs a λδ expression with **`self`** bound to a note's snapshot map and **only the reader builtins in scope**. Mutators are deliberately absent, so a formula *cannot* change the notebook — formulas are pure by construction. ## Changes - Split the notebook host's `register` into **`register_readers`** / **`register_mutators`**, so each context grants exactly the surface spec §5 allows: - formula / agent-predicate → readers only; - action (on-create / agent-action / stamp) → readers + mutators. - **`eval_formula`** — binds `self`, installs readers, evaluates, returns the value. - **`WasmNotebook::evalFormula(note_id, src)`** — the browser entrypoint; read-only, returns the printed result (notebook reclaimed unchanged). ## Proven end-to-end A host test exercises the whole stack: - `(count (words (content self)))` → the note's word count; - `(:title self)` → the title (note map is `self`, keyword access works); - `(set-title! (:id self) "X")` → **Unbound** (mutators absent), and the notebook is verified **unchanged**. ## What this deliberately does NOT include No UI and no formula **persistence** yet — where a note's formulas live is a data-model decision that touches the sensitive `Note` schema, so it deserves its own PR (and a heads-up before I touch it). This PR is purely the safe, reusable capability. ## Verification `cargo test` — **82 lib tests** green (+1 formula) + host + golden + invariants + doc-tests. `cargo clippy --all-targets -- -D warnings` clean (default + `--features wasm`). `cargo fmt --check` clean. Production code panic-free. > Estate governance `workflow_audit` findings are pre-existing estate policy, out of scope; `rust-ci` is the gate. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PAEqVP7Y3bZDhtAaPUTbps --- _Generated by [Claude Code](https://claude.ai/code/session_01PAEqVP7Y3bZDhtAaPUTbps)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 94e5fa8 commit 51f9035

3 files changed

Lines changed: 106 additions & 27 deletions

File tree

core/src/lambdadelta_host.rs

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use std::rc::Rc;
2323
use chrono::{DateTime, Utc};
2424
use uuid::Uuid;
2525

26-
use crate::lambdadelta::{Interp, LdError, LdResult, Value};
26+
use crate::lambdadelta::{Budget, Interp, LdError, LdResult, Value};
2727
use crate::note::Point2D;
2828
use crate::notebook::Notebook;
2929

@@ -46,32 +46,58 @@ use crate::notebook::Notebook;
4646
/// assert_eq!(out, Value::Int(1));
4747
/// ```
4848
pub fn register(interp: &mut Interp, nb: Rc<RefCell<Notebook>>) {
49-
// Readers (pure).
50-
reader(interp, &nb, "notes", 0, Some(0), bi_notes);
51-
reader(interp, &nb, "note", 1, Some(1), bi_note);
52-
reader(interp, &nb, "title", 1, Some(1), bi_title);
53-
reader(interp, &nb, "content", 1, Some(1), bi_content);
54-
reader(interp, &nb, "attrs", 1, Some(1), bi_attrs);
55-
reader(interp, &nb, "links", 1, Some(1), bi_links);
56-
reader(interp, &nb, "backlinks", 1, Some(1), bi_backlinks);
57-
reader(interp, &nb, "position", 1, Some(1), bi_position);
58-
reader(interp, &nb, "attr", 2, Some(2), bi_attr);
59-
reader(interp, &nb, "search", 1, Some(1), bi_search);
60-
reader(interp, &nb, "resolve-title", 1, Some(1), bi_resolve_title);
61-
reader(interp, &nb, "agents", 0, Some(0), bi_agents);
62-
reader(interp, &nb, "run-agent", 1, Some(1), bi_run_agent);
63-
64-
// Mutators (effects, `!`-suffixed).
65-
mutator(interp, &nb, "create-note!", 1, Some(3), bi_create_note);
66-
mutator(interp, &nb, "set-title!", 2, Some(2), bi_set_title);
67-
mutator(interp, &nb, "set-content!", 2, Some(2), bi_set_content);
68-
mutator(interp, &nb, "set-attr!", 3, Some(3), bi_set_attr);
69-
mutator(interp, &nb, "remove-attr!", 2, Some(2), bi_remove_attr);
70-
mutator(interp, &nb, "move-note!", 3, Some(3), bi_move_note);
71-
mutator(interp, &nb, "resize-note!", 3, Some(3), bi_resize_note);
72-
mutator(interp, &nb, "link!", 2, Some(2), bi_link);
73-
mutator(interp, &nb, "unlink!", 2, Some(2), bi_unlink);
74-
mutator(interp, &nb, "delete-note!", 1, Some(1), bi_delete_note);
49+
register_readers(interp, &nb);
50+
register_mutators(interp, &nb);
51+
}
52+
53+
/// Register only the pure reader builtins — the surface a **formula** or
54+
/// **agent-predicate** context is allowed (spec §5).
55+
pub fn register_readers(interp: &mut Interp, nb: &Rc<RefCell<Notebook>>) {
56+
reader(interp, nb, "notes", 0, Some(0), bi_notes);
57+
reader(interp, nb, "note", 1, Some(1), bi_note);
58+
reader(interp, nb, "title", 1, Some(1), bi_title);
59+
reader(interp, nb, "content", 1, Some(1), bi_content);
60+
reader(interp, nb, "attrs", 1, Some(1), bi_attrs);
61+
reader(interp, nb, "links", 1, Some(1), bi_links);
62+
reader(interp, nb, "backlinks", 1, Some(1), bi_backlinks);
63+
reader(interp, nb, "position", 1, Some(1), bi_position);
64+
reader(interp, nb, "attr", 2, Some(2), bi_attr);
65+
reader(interp, nb, "search", 1, Some(1), bi_search);
66+
reader(interp, nb, "resolve-title", 1, Some(1), bi_resolve_title);
67+
reader(interp, nb, "agents", 0, Some(0), bi_agents);
68+
reader(interp, nb, "run-agent", 1, Some(1), bi_run_agent);
69+
}
70+
71+
/// Register the `!`-suffixed mutators — permitted only in **action** contexts
72+
/// (on-create / agent-action / stamp; spec §5).
73+
pub fn register_mutators(interp: &mut Interp, nb: &Rc<RefCell<Notebook>>) {
74+
mutator(interp, nb, "create-note!", 1, Some(3), bi_create_note);
75+
mutator(interp, nb, "set-title!", 2, Some(2), bi_set_title);
76+
mutator(interp, nb, "set-content!", 2, Some(2), bi_set_content);
77+
mutator(interp, nb, "set-attr!", 3, Some(3), bi_set_attr);
78+
mutator(interp, nb, "remove-attr!", 2, Some(2), bi_remove_attr);
79+
mutator(interp, nb, "move-note!", 3, Some(3), bi_move_note);
80+
mutator(interp, nb, "resize-note!", 3, Some(3), bi_resize_note);
81+
mutator(interp, nb, "link!", 2, Some(2), bi_link);
82+
mutator(interp, nb, "unlink!", 2, Some(2), bi_unlink);
83+
mutator(interp, nb, "delete-note!", 1, Some(1), bi_delete_note);
84+
}
85+
86+
/// Evaluate a **formula** (spec §5): a pure expression with `self` bound to a
87+
/// note's snapshot map and only the *reader* builtins in scope — mutators are
88+
/// deliberately absent, so a formula can never change the notebook. This is the
89+
/// L1 surface: `(count (words (content self)))`, `(= (attr self :status) "todo")`.
90+
pub fn eval_formula(
91+
nb: Rc<RefCell<Notebook>>,
92+
self_id: &Uuid,
93+
src: &str,
94+
budget: Budget,
95+
) -> LdResult<Value> {
96+
let mut interp = Interp::new();
97+
register_readers(&mut interp, &nb);
98+
let self_val = note_to_value(&nb.borrow(), self_id).unwrap_or(Value::Nil);
99+
interp.global.set(Rc::from("self"), self_val);
100+
interp.eval_str(src, budget)
75101
}
76102

77103
type ReadFn = fn(&Notebook, &[Value]) -> LdResult<Value>;

core/src/lambdadelta_host/tests.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,36 @@ fn mutator_on_unknown_id_errors() {
175175
);
176176
assert!(matches!(r, Err(LdError::User(_))));
177177
}
178+
179+
#[test]
180+
fn formula_binds_self_and_is_read_only() {
181+
let (nb, _i) = setup();
182+
let a = id_of(&nb, "Alpha");
183+
assert!(a.is_some());
184+
let Some(a) = a else { return };
185+
186+
// `self` is bound to the note; readers are in scope. The canonical L1 fx.
187+
assert_eq!(
188+
super::eval_formula(
189+
nb.clone(),
190+
&a,
191+
"(count (words (content self)))",
192+
Budget::new()
193+
),
194+
Ok(Value::Int(4))
195+
);
196+
assert_eq!(
197+
super::eval_formula(nb.clone(), &a, "(:title self)", Budget::new()),
198+
Ok(Value::str("Alpha"))
199+
);
200+
201+
// Mutators are deliberately absent — a formula cannot change the notebook.
202+
let m = super::eval_formula(
203+
nb.clone(),
204+
&a,
205+
"(set-title! (:id self) \"X\")",
206+
Budget::new(),
207+
);
208+
assert!(matches!(m, Err(LdError::Unbound(_))));
209+
assert_eq!(id_of(&nb, "Alpha"), Some(a)); // title unchanged
210+
}

core/src/wasm.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,26 @@ impl WasmNotebook {
225225
result.map(|v| v.to_string()).map_err(err)
226226
}
227227

228+
/// Evaluate a **formula** against note `note_id`: a pure λδ expression with
229+
/// `self` bound to that note and only reader builtins in scope (spec §5).
230+
/// Read-only — the notebook is never mutated. Returns the printed result.
231+
/// This is the L1 "fx field" entrypoint.
232+
#[wasm_bindgen(js_name = evalFormula)]
233+
pub fn eval_formula(&mut self, note_id: &str, src: &str) -> Result<String, JsValue> {
234+
use crate::lambdadelta::Budget;
235+
use std::cell::RefCell;
236+
use std::rc::Rc;
237+
238+
let id = parse_id(note_id)?;
239+
let shared = Rc::new(RefCell::new(std::mem::take(&mut self.inner)));
240+
let result = crate::lambdadelta_host::eval_formula(shared.clone(), &id, src, Budget::new());
241+
match Rc::try_unwrap(shared) {
242+
Ok(cell) => self.inner = cell.into_inner(),
243+
Err(still_shared) => self.inner = still_shared.borrow().clone(),
244+
}
245+
result.map(|v| v.to_string()).map_err(err)
246+
}
247+
228248
/// Export every note as a Markdown file: `[{ name, content }]`.
229249
pub fn export_markdown(&self) -> Result<JsValue, JsValue> {
230250
let files: Vec<MarkdownFileView> = crate::exchange::to_markdown(&self.inner)

0 commit comments

Comments
 (0)