Skip to main content

shape_vm/executor/state_builtins/
core.rs

1// Content-addressed VM state primitives (`std::state` module).
2//
3// Implements the Rust-backed builtins for the `state` module:
4// - Value hashing (`state.hash`, `state.fn_hash`, `state.schema_hash`)
5// - Serialization (`state.serialize`, `state.deserialize`)
6// - Diffing (`state.diff`, `state.patch`)
7// - Introspection stubs (`state.capture`, `state.capture_all`, etc.)
8//
9// Each function follows the `ModuleFn` signature:
10// `fn(&[ValueWord], &ModuleContext) -> Result<ValueWord, String>`
11
12use super::introspection::{
13    state_args_stub, state_caller_stub, state_capture_all_stub, state_capture_call_stub,
14    state_capture_module_stub, state_capture_stub, state_locals_stub, state_resume_frame_stub,
15    state_resume_stub,
16};
17use shape_runtime::module_exports::{ModuleContext, ModuleExports, ModuleFunction, ModuleParam};
18use shape_runtime::state_diff;
19use shape_runtime::type_schema::{FieldType, TypeSchema};
20use shape_value::ValueWord;
21use std::sync::Arc;
22
23// ---------------------------------------------------------------------------
24// Module constructor
25// ---------------------------------------------------------------------------
26
27/// Create the `state` extension module with all content-addressed builtins.
28pub fn create_state_module() -> ModuleExports {
29    let mut module = ModuleExports::new("std::core::state");
30    module.description = "Content-addressed VM state primitives".to_string();
31
32    // -- Type schemas for state introspection types --
33
34    module.add_type_schema(TypeSchema::new(
35        "FunctionRef",
36        vec![
37            ("name".to_string(), FieldType::String),
38            ("hash".to_string(), FieldType::String),
39        ],
40    ));
41
42    module.add_type_schema(TypeSchema::new(
43        "FrameState",
44        vec![
45            ("function_name".to_string(), FieldType::String),
46            ("blob_hash".to_string(), FieldType::String),
47            ("ip".to_string(), FieldType::I64),
48            ("locals".to_string(), FieldType::Any),
49            ("args".to_string(), FieldType::Any),
50            ("upvalues".to_string(), FieldType::Any),
51        ],
52    ));
53
54    module.add_type_schema(TypeSchema::new(
55        "VmState",
56        vec![
57            ("frames".to_string(), FieldType::Any),
58            ("module_bindings".to_string(), FieldType::Any),
59            ("instruction_count".to_string(), FieldType::I64),
60        ],
61    ));
62
63    module.add_type_schema(TypeSchema::new(
64        "ModuleState",
65        vec![("bindings".to_string(), FieldType::Any)],
66    ));
67
68    module.add_type_schema(TypeSchema::new(
69        "CallPayload",
70        vec![
71            ("hash".to_string(), FieldType::String),
72            ("args".to_string(), FieldType::Any),
73        ],
74    ));
75
76    // -- Content addressing --
77
78    module.add_function_with_schema(
79        "hash",
80        state_hash,
81        ModuleFunction {
82            description: "SHA-256 content hash of any value".to_string(),
83            params: vec![ModuleParam {
84                name: "value".into(),
85                type_name: "any".into(),
86                required: true,
87                description: "Value to hash".into(),
88                ..Default::default()
89            }],
90            return_type: Some("string".into()),
91        },
92    );
93
94    module.add_function_with_schema(
95        "fn_hash",
96        state_fn_hash,
97        ModuleFunction {
98            description: "Get a function's content hash from its FunctionBlob".to_string(),
99            params: vec![ModuleParam {
100                name: "f".into(),
101                type_name: "any".into(),
102                required: true,
103                description: "Function value".into(),
104                ..Default::default()
105            }],
106            return_type: Some("string".into()),
107        },
108    );
109
110    module.add_function_with_schema(
111        "schema_hash",
112        state_schema_hash,
113        ModuleFunction {
114            description: "Content hash of a type's schema definition".to_string(),
115            params: vec![ModuleParam {
116                name: "type_name".into(),
117                type_name: "string".into(),
118                required: true,
119                description: "Name of the type to hash".into(),
120                ..Default::default()
121            }],
122            return_type: Some("string".into()),
123        },
124    );
125
126    // -- Serialization --
127
128    module.add_function_with_schema(
129        "serialize",
130        state_serialize,
131        ModuleFunction {
132            description: "Serialize a value to MessagePack bytes".to_string(),
133            params: vec![ModuleParam {
134                name: "value".into(),
135                type_name: "any".into(),
136                required: true,
137                description: "Value to serialize".into(),
138                ..Default::default()
139            }],
140            return_type: Some("Array<int>".into()),
141        },
142    );
143
144    module.add_function_with_schema(
145        "deserialize",
146        state_deserialize,
147        ModuleFunction {
148            description: "Deserialize MessagePack bytes back to a value".to_string(),
149            params: vec![ModuleParam {
150                name: "bytes".into(),
151                type_name: "Array<int>".into(),
152                required: true,
153                description: "MessagePack byte array".into(),
154                ..Default::default()
155            }],
156            return_type: Some("any".into()),
157        },
158    );
159
160    // -- Diffing --
161
162    module.add_function_with_schema(
163        "diff",
164        state_diff,
165        ModuleFunction {
166            description: "Compute delta between two values using content-hash trees".to_string(),
167            params: vec![
168                ModuleParam {
169                    name: "old".into(),
170                    type_name: "any".into(),
171                    required: true,
172                    description: "Old value".into(),
173                    ..Default::default()
174                },
175                ModuleParam {
176                    name: "new".into(),
177                    type_name: "any".into(),
178                    required: true,
179                    description: "New value".into(),
180                    ..Default::default()
181                },
182            ],
183            return_type: Some("Delta".into()),
184        },
185    );
186
187    module.add_function_with_schema(
188        "patch",
189        state_patch,
190        ModuleFunction {
191            description: "Apply a delta to a base value, producing the updated value".to_string(),
192            params: vec![
193                ModuleParam {
194                    name: "base".into(),
195                    type_name: "any".into(),
196                    required: true,
197                    description: "Base value".into(),
198                    ..Default::default()
199                },
200                ModuleParam {
201                    name: "delta".into(),
202                    type_name: "Delta".into(),
203                    required: true,
204                    description: "Delta to apply".into(),
205                    ..Default::default()
206                },
207            ],
208            return_type: Some("any".into()),
209        },
210    );
211
212    // -- Capture primitives (stubs — need live VM access) --
213
214    module.add_function_with_schema(
215        "capture",
216        state_capture_stub,
217        ModuleFunction {
218            description: "Capture current function's frame state".to_string(),
219            params: vec![],
220            return_type: Some("FrameState".into()),
221        },
222    );
223
224    module.add_function_with_schema(
225        "capture_all",
226        state_capture_all_stub,
227        ModuleFunction {
228            description: "Capture full VM execution state".to_string(),
229            params: vec![],
230            return_type: Some("VmState".into()),
231        },
232    );
233
234    module.add_function_with_schema(
235        "capture_module",
236        state_capture_module_stub,
237        ModuleFunction {
238            description: "Capture module-level bindings and type schemas".to_string(),
239            params: vec![],
240            return_type: Some("ModuleState".into()),
241        },
242    );
243
244    module.add_function_with_schema(
245        "capture_call",
246        state_capture_call_stub,
247        ModuleFunction {
248            description: "Build a ready-to-call payload without executing".to_string(),
249            params: vec![
250                ModuleParam {
251                    name: "f".into(),
252                    type_name: "any".into(),
253                    required: true,
254                    description: "Function to capture".into(),
255                    ..Default::default()
256                },
257                ModuleParam {
258                    name: "args".into(),
259                    type_name: "Array<any>".into(),
260                    required: true,
261                    description: "Arguments for the call".into(),
262                    ..Default::default()
263                },
264            ],
265            return_type: Some("CallPayload".into()),
266        },
267    );
268
269    // -- Resume primitives (stubs) --
270
271    module.add_function_with_schema(
272        "resume",
273        state_resume_stub,
274        ModuleFunction {
275            description: "Resume full VM state (does not return)".to_string(),
276            params: vec![ModuleParam {
277                name: "vm".into(),
278                type_name: "VmState".into(),
279                required: true,
280                description: "VM state to resume".into(),
281                ..Default::default()
282            }],
283            return_type: None,
284        },
285    );
286
287    module.add_function_with_schema(
288        "resume_frame",
289        state_resume_frame_stub,
290        ModuleFunction {
291            description: "Re-enter a captured function frame and return its result".to_string(),
292            params: vec![ModuleParam {
293                name: "f".into(),
294                type_name: "FrameState".into(),
295                required: true,
296                description: "Frame state to resume".into(),
297                ..Default::default()
298            }],
299            return_type: Some("any".into()),
300        },
301    );
302
303    // -- Introspection (stubs) --
304
305    module.add_function_with_schema(
306        "caller",
307        state_caller_stub,
308        ModuleFunction {
309            description: "Get a reference to the calling function".to_string(),
310            params: vec![],
311            return_type: Some("FunctionRef?".into()),
312        },
313    );
314
315    module.add_function_with_schema(
316        "args",
317        state_args_stub,
318        ModuleFunction {
319            description: "Get the current function's arguments as an array".to_string(),
320            params: vec![],
321            return_type: Some("Array<any>".into()),
322        },
323    );
324
325    module.add_function_with_schema(
326        "locals",
327        state_locals_stub,
328        ModuleFunction {
329            description: "Get the current scope's local variables as a map".to_string(),
330            params: vec![],
331            return_type: Some("Map<string, any>".into()),
332        },
333    );
334
335    module.add_function_with_schema(
336        "snapshot",
337        state_capture_all_stub,
338        ModuleFunction {
339            description: "Create a snapshot of the current execution state. This is a suspension point: the engine saves all state and returns Snapshot::Hash(id). When resumed from a snapshot, execution continues here and returns Snapshot::Resumed.".to_string(),
340            params: vec![],
341            return_type: Some("Snapshot".into()),
342        },
343    );
344
345    module
346}
347
348// ===========================================================================
349// Content addressing implementations
350// ===========================================================================
351
352/// `state.hash(value) -> string`
353///
354/// Compute SHA-256 content hash of any ValueWord value using the structural
355/// hashing from `shape_runtime::state_diff::content_hash_value`.
356pub(crate) fn state_hash(args: &[ValueWord], ctx: &ModuleContext) -> Result<ValueWord, String> {
357    let value = args.first().ok_or("state.hash requires 1 argument")?;
358    let digest = state_diff::content_hash_value(value, ctx.schemas);
359    Ok(ValueWord::from_string(Arc::new(digest.hex().to_string())))
360}
361
362/// `state.fn_hash(f) -> string`
363///
364/// Look up the content hash for function `f` from the VM-provided
365/// `ModuleContext.function_hashes` table.  Returns a 64-character hex
366/// string when the hash is available, or falls back to `"fn:<id>"` when
367/// content-addressed metadata has not been populated.
368pub(crate) fn state_fn_hash(args: &[ValueWord], ctx: &ModuleContext) -> Result<ValueWord, String> {
369    let f = args.first().ok_or("state.fn_hash requires 1 argument")?;
370
371    let func_id = if let Some(fid) = f.as_function() {
372        Some(fid as usize)
373    } else if let Some(heap_ref) = f.as_heap_ref() {
374        if let shape_value::HeapValue::Closure { function_id, .. } = heap_ref {
375            Some(*function_id as usize)
376        } else {
377            None
378        }
379    } else {
380        None
381    };
382
383    let func_id = func_id.ok_or("state.fn_hash: argument is not a function")?;
384
385    // Look up the real content hash from the VM-provided table.
386    if let Some(hashes) = ctx.function_hashes {
387        if let Some(Some(hash_bytes)) = hashes.get(func_id) {
388            let hex: String = hash_bytes
389                .iter()
390                .fold(String::with_capacity(64), |mut acc, b| {
391                    use std::fmt::Write;
392                    let _ = write!(acc, "{:02x}", b);
393                    acc
394                });
395            return Ok(ValueWord::from_string(Arc::new(hex)));
396        }
397    }
398
399    // Fallback: return function ID as placeholder when hashes are unavailable.
400    Ok(ValueWord::from_string(Arc::new(format!("fn:{}", func_id))))
401}
402
403/// `state.schema_hash(type_name) -> string`
404///
405/// Look up the type in the TypeSchemaRegistry and return its content hash
406/// as a hex string.
407pub(crate) fn state_schema_hash(
408    args: &[ValueWord],
409    ctx: &ModuleContext,
410) -> Result<ValueWord, String> {
411    let name_nb = args
412        .first()
413        .ok_or("state.schema_hash requires 1 argument")?;
414    let type_name = name_nb
415        .as_str()
416        .ok_or("state.schema_hash: argument must be a string")?;
417
418    let schema = ctx.schemas.get(type_name).ok_or_else(|| {
419        format!(
420            "state.schema_hash: type '{}' not found in registry",
421            type_name
422        )
423    })?;
424
425    // content_hash is Option<[u8; 32]>. If not yet computed, compute it on a clone.
426    let hash_bytes = match schema.content_hash {
427        Some(hash) => hash,
428        None => {
429            // Schema doesn't have a cached hash. Compute it from a mutable clone.
430            let mut schema_clone = schema.clone();
431            schema_clone.content_hash()
432        }
433    };
434
435    // Convert [u8; 32] to hex string
436    let hex = hash_bytes
437        .iter()
438        .fold(String::with_capacity(64), |mut acc, b| {
439            use std::fmt::Write;
440            let _ = write!(acc, "{:02x}", b);
441            acc
442        });
443
444    Ok(ValueWord::from_string(Arc::new(hex)))
445}
446
447// ===========================================================================
448// Serialization implementations
449// ===========================================================================
450
451/// `state.serialize(value) -> Array<int>`
452///
453/// Serialize a ValueWord value to MessagePack bytes, returned as an Array of ints.
454/// Uses rmp-serde via the snapshot SerializableVMValue representation.
455pub(crate) fn state_serialize(
456    args: &[ValueWord],
457    _ctx: &ModuleContext,
458) -> Result<ValueWord, String> {
459    let value = args.first().ok_or("state.serialize requires 1 argument")?;
460
461    // Serialize the ValueWord using the snapshot mechanism.
462    // SnapshotStore is only needed for blob-backed heap values (DataTable etc.),
463    // but the API requires one. Use a temp directory.
464    let tmp = std::env::temp_dir().join("shape_state_serialize");
465    let store = shape_runtime::snapshot::SnapshotStore::new(&tmp)
466        .map_err(|e| format!("state.serialize: failed to create temp store: {}", e))?;
467    let serializable = shape_runtime::snapshot::nanboxed_to_serializable(value, &store)
468        .map_err(|e| format!("state.serialize: {}", e))?;
469
470    let bytes = rmp_serde::to_vec(&serializable)
471        .map_err(|e| format!("state.serialize: msgpack encoding failed: {}", e))?;
472
473    // Convert Vec<u8> to Array<ValueWord> of ints
474    let arr: Vec<ValueWord> = bytes
475        .iter()
476        .map(|&b| ValueWord::from_i64(b as i64))
477        .collect();
478    Ok(ValueWord::from_array(Arc::new(arr)))
479}
480
481/// `state.deserialize(bytes) -> Any`
482///
483/// Deserialize MessagePack bytes (Array of ints) back to a ValueWord value.
484pub(crate) fn state_deserialize(
485    args: &[ValueWord],
486    _ctx: &ModuleContext,
487) -> Result<ValueWord, String> {
488    let bytes_nb = args
489        .first()
490        .ok_or("state.deserialize requires 1 argument")?;
491    let arr = bytes_nb
492        .as_any_array()
493        .ok_or("state.deserialize: argument must be an Array<int>")?
494        .to_generic();
495
496    // Convert Array<ValueWord> of ints to Vec<u8>
497    let mut bytes = Vec::with_capacity(arr.len());
498    for nb in arr.iter() {
499        let b = nb
500            .as_i64()
501            .or_else(|| nb.as_f64().map(|f| f as i64))
502            .ok_or("state.deserialize: array elements must be integers")?;
503        if !(0..=255).contains(&b) {
504            return Err(format!(
505                "state.deserialize: byte value {} out of range 0..255",
506                b
507            ));
508        }
509        bytes.push(b as u8);
510    }
511
512    // Deserialize via the snapshot mechanism
513    let serializable: shape_runtime::snapshot::SerializableVMValue = rmp_serde::from_slice(&bytes)
514        .map_err(|e| format!("state.deserialize: msgpack decoding failed: {}", e))?;
515
516    let tmp = std::env::temp_dir().join("shape_state_deserialize");
517    let store = shape_runtime::snapshot::SnapshotStore::new(&tmp)
518        .map_err(|e| format!("state.deserialize: failed to create temp store: {}", e))?;
519    let nb = shape_runtime::snapshot::serializable_to_nanboxed(&serializable, &store)
520        .map_err(|e| format!("state.deserialize: {}", e))?;
521
522    Ok(nb)
523}
524
525// ===========================================================================
526// Diffing implementations
527// ===========================================================================
528
529/// `state.diff(old, new) -> Delta`
530///
531/// Compute delta between two values using content-hash tree comparison.
532///
533/// Returns a proper `Delta` TypedObject matching the `state.shape` definition:
534/// - `changed`: `Map<string, Any>` (HashMap of path -> new value)
535/// - `removed`: `Array<string>` (array of removed paths)
536///
537/// Falls back to a plain two-element array `[changed_pairs, removed]` if the
538/// Delta schema is not found in the registry.
539pub(crate) fn state_diff(args: &[ValueWord], ctx: &ModuleContext) -> Result<ValueWord, String> {
540    let old = args.first().ok_or("state.diff requires 2 arguments")?;
541    let new = args.get(1).ok_or("state.diff requires 2 arguments")?;
542
543    let delta = state_diff::diff_values(old, new, ctx.schemas);
544
545    // Build changed: Map<string, Any> as a ValueWord HashMap
546    let mut keys = Vec::with_capacity(delta.changed.len());
547    let mut values = Vec::with_capacity(delta.changed.len());
548    for (path, value) in delta.changed.iter() {
549        keys.push(ValueWord::from_string(Arc::new(path.clone())));
550        values.push(value.clone());
551    }
552    let changed_map = ValueWord::from_hashmap_pairs(keys, values);
553
554    // Build removed: Array<string>
555    let removed_arr: Vec<ValueWord> = delta
556        .removed
557        .iter()
558        .map(|s| ValueWord::from_string(Arc::new(s.clone())))
559        .collect();
560    let removed = ValueWord::from_array(Arc::new(removed_arr));
561
562    // Create a proper Delta TypedObject using the registered schema
563    if let Some(schema) = ctx.schemas.get("Delta") {
564        use shape_value::heap_value::HeapValue;
565        use shape_value::slot::ValueSlot;
566
567        let schema_id = schema.id as u64;
568        // Delta has two fields: changed (slot 0) and removed (slot 1)
569        // Both are complex heap types (HashMap and Array)
570        let slots = vec![
571            ValueSlot::from_heap(changed_map.as_heap_ref().unwrap().clone()),
572            ValueSlot::from_heap(removed.as_heap_ref().unwrap().clone()),
573        ];
574        let heap_mask: u64 = 0b11; // both slots are heap pointers
575
576        return Ok(ValueWord::from_heap_value(HeapValue::TypedObject {
577            schema_id,
578            slots: slots.into_boxed_slice(),
579            heap_mask,
580        }));
581    }
582
583    // Fallback: return as a typed_object_from_pairs via the predeclared schema path
584    Ok(shape_runtime::type_schema::typed_object_from_pairs(&[
585        ("changed", changed_map),
586        ("removed", removed),
587    ]))
588}
589
590/// `state.patch(base, delta) -> Any`
591///
592/// Apply a delta (from `state.diff`) to a base value.
593///
594/// Accepts a Delta TypedObject (with `changed` and `removed` fields) or
595/// a legacy two-element array `[changed_pairs, removed_keys]` for backwards
596/// compatibility.
597pub(crate) fn state_patch(args: &[ValueWord], ctx: &ModuleContext) -> Result<ValueWord, String> {
598    let base = args.first().ok_or("state.patch requires 2 arguments")?;
599    let delta_nb = args.get(1).ok_or("state.patch requires 2 arguments")?;
600
601    let mut delta = state_diff::Delta::empty();
602
603    // Try TypedObject (Delta) first
604    if let Some((schema_id, slots, heap_mask)) = delta_nb.as_typed_object() {
605        let is_delta = ctx
606            .schemas
607            .get_by_id(schema_id as u32)
608            .map(|s| s.name == "Delta")
609            .unwrap_or(false);
610
611        if is_delta && slots.len() >= 2 {
612            // slot 0 = changed (Map<string, Any> / HashMap)
613            let changed_nb = if heap_mask & 1 != 0 {
614                slots[0].as_heap_nb()
615            } else {
616                return Err("state.patch: Delta.changed slot is not a heap value".to_string());
617            };
618
619            // slot 1 = removed (Array<string>)
620            let removed_nb = if heap_mask & 2 != 0 {
621                slots[1].as_heap_nb()
622            } else {
623                return Err("state.patch: Delta.removed slot is not a heap value".to_string());
624            };
625
626            // Extract changed from HashMap
627            if let Some((keys, values, _index)) = changed_nb.as_hashmap() {
628                for (k, v) in keys.iter().zip(values.iter()) {
629                    let key = k
630                        .as_str()
631                        .ok_or("state.patch: changed key must be a string")?
632                        .to_string();
633                    delta.changed.insert(key, v.clone());
634                }
635            } else {
636                return Err("state.patch: Delta.changed must be a Map".to_string());
637            }
638
639            // Extract removed from Array
640            if let Some(view) = removed_nb.as_any_array() {
641                let arr = view.to_generic();
642                for nb in arr.iter() {
643                    let key = nb
644                        .as_str()
645                        .ok_or("state.patch: removed entry must be a string")?
646                        .to_string();
647                    delta.removed.push(key);
648                }
649            } else {
650                return Err("state.patch: Delta.removed must be an Array".to_string());
651            }
652
653            let result = state_diff::patch_value(base, &delta, ctx.schemas);
654            return Ok(result);
655        }
656    }
657
658    // Fallback: legacy array format [changed_pairs, removed_keys]
659    let delta_arr = delta_nb
660        .as_any_array()
661        .ok_or("state.patch: delta must be a Delta TypedObject or [changed, removed] array")?
662        .to_generic();
663
664    if delta_arr.len() < 2 {
665        return Err(
666            "state.patch: delta must be a Delta TypedObject or [changed, removed] array"
667                .to_string(),
668        );
669    }
670
671    let changed_pairs = delta_arr[0]
672        .as_any_array()
673        .ok_or("state.patch: changed must be an array of [key, value] pairs")?
674        .to_generic();
675    let removed_arr = delta_arr[1]
676        .as_any_array()
677        .ok_or("state.patch: removed must be an array of strings")?
678        .to_generic();
679
680    for pair in changed_pairs.iter() {
681        let pair_arr = pair
682            .as_any_array()
683            .ok_or("state.patch: each changed entry must be a [key, value] pair")?
684            .to_generic();
685        if pair_arr.len() < 2 {
686            return Err("state.patch: each changed entry must be a [key, value] pair".to_string());
687        }
688        let key = pair_arr[0]
689            .as_str()
690            .ok_or("state.patch: changed key must be a string")?
691            .to_string();
692        delta.changed.insert(key, pair_arr[1].clone());
693    }
694
695    for nb in removed_arr.iter() {
696        let key = nb
697            .as_str()
698            .ok_or("state.patch: removed entry must be a string")?
699            .to_string();
700        delta.removed.push(key);
701    }
702
703    let result = state_diff::patch_value(base, &delta, ctx.schemas);
704    Ok(result)
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use shape_runtime::type_schema::FieldType;
711
712    /// Helper: find the schema with the given name in the module's type_schemas vec.
713    fn find_schema<'a>(
714        module: &'a ModuleExports,
715        name: &str,
716    ) -> &'a shape_runtime::type_schema::TypeSchema {
717        module
718            .type_schemas
719            .iter()
720            .find(|s| s.name == name)
721            .unwrap_or_else(|| panic!("schema '{}' not found", name))
722    }
723
724    #[test]
725    fn test_state_schemas_have_concrete_field_types() {
726        let module = create_state_module();
727
728        // --- FunctionRef: both fields should be String ---
729        let func_ref = find_schema(&module, "FunctionRef");
730        assert_eq!(
731            func_ref.get_field("name").unwrap().field_type,
732            FieldType::String
733        );
734        assert_eq!(
735            func_ref.get_field("hash").unwrap().field_type,
736            FieldType::String
737        );
738
739        // --- FrameState: 3 typed, 3 dynamic ---
740        let frame = find_schema(&module, "FrameState");
741        assert_eq!(
742            frame.get_field("function_name").unwrap().field_type,
743            FieldType::String
744        );
745        assert_eq!(
746            frame.get_field("blob_hash").unwrap().field_type,
747            FieldType::String
748        );
749        assert_eq!(frame.get_field("ip").unwrap().field_type, FieldType::I64);
750        assert_eq!(
751            frame.get_field("locals").unwrap().field_type,
752            FieldType::Any
753        );
754        assert_eq!(frame.get_field("args").unwrap().field_type, FieldType::Any);
755        assert_eq!(
756            frame.get_field("upvalues").unwrap().field_type,
757            FieldType::Any
758        );
759
760        // --- VmState: 1 typed, 2 dynamic ---
761        let vm_state = find_schema(&module, "VmState");
762        assert_eq!(
763            vm_state.get_field("instruction_count").unwrap().field_type,
764            FieldType::I64
765        );
766        assert_eq!(
767            vm_state.get_field("frames").unwrap().field_type,
768            FieldType::Any
769        );
770        assert_eq!(
771            vm_state.get_field("module_bindings").unwrap().field_type,
772            FieldType::Any
773        );
774
775        // --- ModuleState: all dynamic ---
776        let mod_state = find_schema(&module, "ModuleState");
777        assert_eq!(
778            mod_state.get_field("bindings").unwrap().field_type,
779            FieldType::Any
780        );
781
782        // --- CallPayload: 1 typed, 1 dynamic ---
783        let call = find_schema(&module, "CallPayload");
784        assert_eq!(
785            call.get_field("hash").unwrap().field_type,
786            FieldType::String
787        );
788        assert_eq!(call.get_field("args").unwrap().field_type, FieldType::Any);
789    }
790}