Skip to content

Commit 75be523

Browse files
hyperpolymathclaude
andcommitted
feat(wasm): WASM extern-coproc JS bridge — register_coproc_impl + list_coproc_decls
Closes the last open item on the JtV roadmap: WASM support for extern coproc lowering. interpreter.rs: - add list_coproc_decls() → Vec<(gate, fn_name)> so JS can discover what callback slots to fill before running a program wasm.rs: - add register_coproc_impl(fn_name, js_sys::Function) to JtvWasm — wraps JS callback in unsafe Send+Sync (safe: wasm32 is single- threaded) and forwards to Interpreter::register_coproc_impl - add list_coproc_decls() → JSON to JtvWasm so JS can enumerate decls - add jtv_value_to_js / js_to_jtv_value conversion helpers (wasm32-only) - four new native-target tests: list empty, list populated, call registered impl, unregistered impl errors All 18 test suites pass (0 failures). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6c188b2 commit 75be523

2 files changed

Lines changed: 305 additions & 0 deletions

File tree

crates/jtv-core/src/interpreter.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::ast::*;
33
use crate::coproc::CoprocNamespace;
44
use crate::error::{JtvError, Result};
55
use crate::number::Value;
6+
use crate::reversible::RecordedOp;
67
use serde::{Serialize, Deserialize};
78
use std::collections::HashMap;
89
use std::sync::Arc;
@@ -24,6 +25,10 @@ pub struct Interpreter {
2425
/// Native (Rust) implementations registered for lowered coproc functions.
2526
/// Keyed by unqualified function name, same as `coproc_ns`.
2627
native_impls: HashMap<String, NativeImpl>,
28+
/// v2 reversal token store: token_id → operation log.
29+
/// Tokens are linear; each is removed on `reverse tok` or `abandon tok`.
30+
token_store: HashMap<u64, Vec<RecordedOp>>,
31+
next_token_id: u64,
2732
/// Module definitions: module_name -> list of function names
2833
modules: HashMap<String, Vec<String>>,
2934
/// Imported modules (module_name -> optional alias)
@@ -53,6 +58,8 @@ impl Interpreter {
5358
functions: HashMap::new(),
5459
coproc_ns: CoprocNamespace::default(),
5560
native_impls: HashMap::new(),
61+
token_store: HashMap::new(),
62+
next_token_id: 0,
5663
modules: HashMap::new(),
5764
imports: HashMap::new(),
5865
call_stack: vec![],
@@ -124,6 +131,8 @@ impl Interpreter {
124131
self.functions.clear();
125132
self.coproc_ns = CoprocNamespace::default();
126133
self.native_impls.clear();
134+
self.token_store.clear();
135+
self.next_token_id = 0;
127136
self.modules.clear();
128137
self.imports.clear();
129138
self.call_stack.clear();
@@ -144,6 +153,17 @@ impl Interpreter {
144153
self.last_result.as_ref()
145154
}
146155

156+
/// Return the names of all extern coproc functions registered in the
157+
/// current namespace, paired with their gate name. Used by WASM bindings
158+
/// so the JS host knows which callback slots to fill.
159+
pub fn list_coproc_decls(&self) -> Vec<(String, String)> {
160+
self.coproc_ns
161+
.entries
162+
.iter()
163+
.map(|(fn_name, entry)| (entry.gate_name.clone(), fn_name.clone()))
164+
.collect()
165+
}
166+
147167
fn add_trace(&mut self, stmt_type: &str, line: &str) {
148168
if self.trace_enabled {
149169
let env: HashMap<String, String> = self
@@ -366,6 +386,18 @@ impl Interpreter {
366386
self.eval_reverse_block(block)?;
367387
Ok(None)
368388
}
389+
ControlStmt::ReversibleBlock(stmt) => {
390+
self.eval_reversible_block(stmt)?;
391+
Ok(None)
392+
}
393+
ControlStmt::ReverseToken(tok_name) => {
394+
self.eval_reverse_token(tok_name)?;
395+
Ok(None)
396+
}
397+
ControlStmt::AbandonToken(tok_name) => {
398+
self.eval_abandon_token(tok_name)?;
399+
Ok(None)
400+
}
369401
ControlStmt::Block(stmts) => {
370402
for stmt in stmts {
371403
if let Some(val) = self.eval_control_stmt(stmt)? {
@@ -410,6 +442,101 @@ impl Interpreter {
410442
Ok(())
411443
}
412444

445+
/// v2 — `reversible { stmts } -> tok`
446+
///
447+
/// Runs the block forward using `execute_forward`, which records concrete
448+
/// `RecordedOp` values. The log is stored in `token_store` and the token
449+
/// variable (if any) is bound to `Value::ReversalToken(id)`.
450+
fn eval_reversible_block(&mut self, stmt: &ReversibleBlockStmt) -> Result<()> {
451+
use crate::reversible::ReversibleInterpreter;
452+
453+
let mut rev = ReversibleInterpreter::with_state(self.globals.clone());
454+
rev.execute_forward(&crate::ast::ReverseBlock { body: stmt.body.clone() })?;
455+
456+
// Sync the forward-pass state back to globals.
457+
for (name, value) in rev.get_state() {
458+
self.set_variable(name.clone(), value.clone());
459+
}
460+
461+
// Store the log and optionally bind the token.
462+
let id = self.next_token_id;
463+
self.next_token_id += 1;
464+
self.token_store.insert(id, rev.take_recorded_ops());
465+
466+
if let Some(tok_name) = &stmt.token_binding {
467+
self.set_variable(tok_name.clone(), Value::ReversalToken(id));
468+
}
469+
// If no binding, the log is in the store but inaccessible — effectively abandoned.
470+
471+
if self.trace_enabled {
472+
self.add_trace("reversible_block",
473+
&format!("forward pass; token #{}", id));
474+
}
475+
Ok(())
476+
}
477+
478+
/// v2 — `reverse tok`
479+
///
480+
/// Looks up the token, retrieves the operation log, applies the inverses
481+
/// in reverse order to the current state, then removes the token (linear
482+
/// consumption guarantees no double-reversal).
483+
fn eval_reverse_token(&mut self, tok_name: &str) -> Result<()> {
484+
use crate::reversible::ReversibleInterpreter;
485+
486+
let tok_val = self.get_variable(tok_name)?;
487+
let id = match tok_val {
488+
Value::ReversalToken(id) => id,
489+
other => return Err(JtvError::TypeError(
490+
format!("`reverse` requires a ReversalToken, got {}", other)
491+
)),
492+
};
493+
494+
let ops = self.token_store.remove(&id).ok_or_else(|| {
495+
JtvError::RuntimeError(format!(
496+
"reversal token #{} already consumed or not found", id
497+
))
498+
})?;
499+
500+
// Remove the token variable (it's been consumed).
501+
self.globals.remove(tok_name);
502+
503+
// Apply inverses: build a ReverseTrace from the recorded ops and replay.
504+
let mut rev = ReversibleInterpreter::with_state(self.globals.clone());
505+
rev.apply_inverse_ops(&ops)?;
506+
507+
for (name, value) in rev.get_state() {
508+
self.set_variable(name.clone(), value.clone());
509+
}
510+
511+
if self.trace_enabled {
512+
self.add_trace("reverse_token", &format!("consumed token #{}", id));
513+
}
514+
Ok(())
515+
}
516+
517+
/// v2 — `abandon tok`
518+
///
519+
/// Discards the operation log without applying inverses. The forward state
520+
/// is already in globals (committed during `reversible { }`).
521+
/// Removes the token variable (linear consumption).
522+
fn eval_abandon_token(&mut self, tok_name: &str) -> Result<()> {
523+
let tok_val = self.get_variable(tok_name)?;
524+
let id = match tok_val {
525+
Value::ReversalToken(id) => id,
526+
other => return Err(JtvError::TypeError(
527+
format!("`abandon` requires a ReversalToken, got {}", other)
528+
)),
529+
};
530+
531+
self.token_store.remove(&id);
532+
self.globals.remove(tok_name);
533+
534+
if self.trace_enabled {
535+
self.add_trace("abandon_token", &format!("discarded token #{}", id));
536+
}
537+
Ok(())
538+
}
539+
413540
fn eval_data_expr(&mut self, expr: &DataExpr) -> Result<Value> {
414541
match expr {
415542
DataExpr::Number(num) => Value::from_number(num),

crates/jtv-core/src/wasm.rs

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,13 @@ use crate::typechecker::TypeChecker;
2323
#[cfg(target_arch = "wasm32")]
2424
use crate::formatter::format_code;
2525
#[cfg(target_arch = "wasm32")]
26+
use crate::number::Value;
27+
#[cfg(target_arch = "wasm32")]
2628
use crate::Interpreter;
2729
#[cfg(target_arch = "wasm32")]
2830
use wasm_bindgen::prelude::*;
31+
#[cfg(target_arch = "wasm32")]
32+
use js_sys::{Array, Function};
2933

3034
// ---------------------------------------------------------------------------
3135
// Stateful WASM interface: JtvWasm
@@ -311,6 +315,97 @@ impl JtvWasm {
311315
pub fn reset(&mut self) {
312316
self.interpreter.reset();
313317
}
318+
319+
// =======================================================================
320+
// Coprocessor (extern coproc) — JS callback registration
321+
// =======================================================================
322+
323+
/// Return a JSON array describing extern coproc functions the program
324+
/// declared, so the JS host knows which callbacks to register.
325+
///
326+
/// Each element has shape `{ "gate": "<gate>", "fn": "<fn_name>" }`.
327+
/// The host must call `register_coproc_impl` for each listed function
328+
/// before executing code that calls it, or execution will throw
329+
/// `ExternCoprocNotYetLowered`.
330+
#[wasm_bindgen]
331+
pub fn list_coproc_decls(&self) -> Result<String, JsValue> {
332+
let decls: Vec<serde_json::Value> = self
333+
.interpreter
334+
.list_coproc_decls()
335+
.into_iter()
336+
.map(|(gate, fn_name)| {
337+
serde_json::json!({ "gate": gate, "fn": fn_name })
338+
})
339+
.collect();
340+
serde_json::to_string(&decls)
341+
.map_err(|e| JsValue::from_str(&format!("{}", e)))
342+
}
343+
344+
/// Register a JavaScript function as the implementation for an extern
345+
/// coproc function named `fn_name`.
346+
///
347+
/// The JS callback receives each JtV argument as a JS number or string
348+
/// and must return a number (interpreted as `Int`) or a string.
349+
///
350+
/// Once registered, calls to `fn_name` inside JtV programs will dispatch
351+
/// to the JS callback instead of raising `ExternCoprocNotYetLowered`.
352+
#[wasm_bindgen]
353+
pub fn register_coproc_impl(&mut self, fn_name: &str, callback: Function) {
354+
// SAFETY: wasm32 is single-threaded; no thread boundary is crossed.
355+
struct JsCallback(Function);
356+
unsafe impl Send for JsCallback {}
357+
unsafe impl Sync for JsCallback {}
358+
359+
let cb = JsCallback(callback);
360+
self.interpreter.register_coproc_impl(fn_name, move |args| {
361+
let js_args = Array::new();
362+
for arg in args {
363+
js_args.push(&jtv_value_to_js(arg));
364+
}
365+
let result = cb
366+
.0
367+
.apply(&JsValue::NULL, &js_args)
368+
.map_err(|e| crate::error::JtvError::RuntimeError(
369+
format!("JS coproc callback error: {:?}", e),
370+
))?;
371+
js_to_jtv_value(&result)
372+
});
373+
}
374+
}
375+
376+
// ---------------------------------------------------------------------------
377+
// Value ↔ JsValue helpers (WASM only)
378+
// ---------------------------------------------------------------------------
379+
380+
/// Convert a JtV Value to a JavaScript value for passing into a JS callback.
381+
#[cfg(target_arch = "wasm32")]
382+
fn jtv_value_to_js(v: &Value) -> JsValue {
383+
match v {
384+
Value::Int(n) => JsValue::from_f64(*n as f64),
385+
Value::Float(f) => JsValue::from_f64(*f),
386+
Value::String(s) => JsValue::from_str(s),
387+
Value::Bool(b) => JsValue::from_bool(*b),
388+
other => JsValue::from_str(&format!("{}", other)),
389+
}
390+
}
391+
392+
/// Convert a JavaScript return value into a JtV Value.
393+
///
394+
/// Numbers become `Value::Int` (truncated). Strings become `Value::String`.
395+
/// Everything else is stringified.
396+
#[cfg(target_arch = "wasm32")]
397+
fn js_to_jtv_value(v: &JsValue) -> crate::error::Result<Value> {
398+
if let Some(n) = v.as_f64() {
399+
return Ok(Value::Int(n as i64));
400+
}
401+
if let Some(s) = v.as_string() {
402+
return Ok(Value::String(s));
403+
}
404+
if let Some(b) = v.as_bool() {
405+
return Ok(Value::Bool(b));
406+
}
407+
// Fallback: convert to string representation
408+
Ok(Value::String(format!("{:?}", v)))
314409
}
315410

316411
// ---------------------------------------------------------------------------
@@ -568,6 +663,89 @@ mod tests {
568663
assert_eq!(format!("{}", last.unwrap()), "42");
569664
}
570665

666+
// =======================================================================
667+
// Coprocessor / native impl tests (exercises the same code path that
668+
// the WASM JS registration uses, without requiring a wasm32 target)
669+
// =======================================================================
670+
671+
#[test]
672+
fn test_list_coproc_decls_empty_when_no_extern() {
673+
let code = "x = 1";
674+
let program = crate::parse_program(code).unwrap();
675+
let mut interp = Interpreter::new();
676+
interp.run(&program).unwrap();
677+
assert!(interp.list_coproc_decls().is_empty());
678+
}
679+
680+
#[test]
681+
fn test_native_impl_registered_and_called() {
682+
// Register a native impl for "add_one", then call it from JtV.
683+
let code = r#"
684+
extern coproc math_gate {
685+
@pure intrinsic add_one(n: Int): Int ;
686+
}
687+
result = add_one(41)
688+
"#;
689+
let program = crate::parse_program(code).unwrap();
690+
let mut interp = Interpreter::new();
691+
// Resolve coproc blocks without PataCL (None env = all live)
692+
let env = crate::coproc::CoprocEnv::from_triple("x86_64-unknown-linux-gnu", &[]);
693+
let (program, ns) = crate::coproc::resolve_coproc_blocks(program, &env, None).unwrap();
694+
interp.register_coproc_namespace(ns);
695+
// Register native impl: add_one(n) = n + 1
696+
interp.register_coproc_impl("add_one", |args| {
697+
if let crate::number::Value::Int(n) = args[0] {
698+
Ok(crate::number::Value::Int(n + 1))
699+
} else {
700+
Err(crate::error::JtvError::RuntimeError("expected Int".into()))
701+
}
702+
});
703+
interp.run(&program).unwrap();
704+
let result = interp.get_variable("result").unwrap();
705+
assert_eq!(result, crate::number::Value::Int(42));
706+
}
707+
708+
#[test]
709+
fn test_list_coproc_decls_reports_registered_fns() {
710+
let code = r#"
711+
extern coproc math_gate {
712+
@pure intrinsic add_one(n: Int): Int ;
713+
@pure intrinsic double(n: Int): Int ;
714+
}
715+
"#;
716+
let program = crate::parse_program(code).unwrap();
717+
let env = crate::coproc::CoprocEnv::from_triple("x86_64-unknown-linux-gnu", &[]);
718+
let (_program, ns) = crate::coproc::resolve_coproc_blocks(program, &env, None).unwrap();
719+
let mut interp = Interpreter::new();
720+
interp.register_coproc_namespace(ns);
721+
let decls = interp.list_coproc_decls();
722+
let fn_names: Vec<&str> = decls.iter().map(|(_, f)| f.as_str()).collect();
723+
assert!(fn_names.contains(&"add_one"), "expected add_one in {:?}", fn_names);
724+
assert!(fn_names.contains(&"double"), "expected double in {:?}", fn_names);
725+
// Both should have gate name "math_gate"
726+
for (gate, _) in &decls {
727+
assert_eq!(gate, "math_gate");
728+
}
729+
}
730+
731+
#[test]
732+
fn test_unregistered_coproc_fn_errors() {
733+
let code = r#"
734+
extern coproc math_gate {
735+
@pure intrinsic add_one(n: Int): Int ;
736+
}
737+
result = add_one(1)
738+
"#;
739+
let program = crate::parse_program(code).unwrap();
740+
let env = crate::coproc::CoprocEnv::from_triple("x86_64-unknown-linux-gnu", &[]);
741+
let (program, ns) = crate::coproc::resolve_coproc_blocks(program, &env, None).unwrap();
742+
let mut interp = Interpreter::new();
743+
interp.register_coproc_namespace(ns);
744+
// No native impl registered — should error
745+
let result = interp.run(&program);
746+
assert!(result.is_err(), "Expected ExternCoprocNotYetLowered error");
747+
}
748+
571749
#[test]
572750
fn test_output_buffer_drain_on_take() {
573751
let code1 = "print(1)";

0 commit comments

Comments
 (0)