From d63856b629b67e42c44def365df7389c02a78387 Mon Sep 17 00:00:00 2001 From: proboscis Date: Fri, 12 Jun 2026 17:30:05 +0900 Subject: [PATCH] Revive doeff-vm test layer --- packages/doeff-vm/doeff_vm/__init__.pyi | 10 +- packages/doeff-vm/src/pyvm.rs | 50 +- packages/doeff-vm/tests/test_memory_stats.py | 161 +- .../doeff-vm/tests/test_package_exports.py | 99 +- packages/doeff-vm/tests/test_pyvm.py | 2121 ++--------------- .../tests/test_pyvm_no_enum_catchall.py | 91 +- pyproject.toml | 2 +- 7 files changed, 320 insertions(+), 2214 deletions(-) diff --git a/packages/doeff-vm/doeff_vm/__init__.pyi b/packages/doeff-vm/doeff_vm/__init__.pyi index 727b15a6b..9943d0679 100644 --- a/packages/doeff-vm/doeff_vm/__init__.pyi +++ b/packages/doeff-vm/doeff_vm/__init__.pyi @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any, Generic, SupportsIndex, TypeVar _T = TypeVar("_T") @@ -11,6 +9,7 @@ class UnhandledEffect(RuntimeError): ... # noqa: N818 - public or fixture excep class PyVM: def __init__(self) -> None: ... def run(self, program: Any) -> Any: ... + def arena_stats(self) -> tuple[int, int, int, int]: ... # --- Continuation --- @@ -134,3 +133,10 @@ class GetHandlers: class GetOuterHandlers: def __init__(self) -> None: ... def __repr__(self) -> str: ... + +class TailEval: + expr: Any + def __init__(self, expr: Any) -> None: ... + def __repr__(self) -> str: ... + +def vm_live_counts() -> tuple[int, int, int]: ... diff --git a/packages/doeff-vm/src/pyvm.rs b/packages/doeff-vm/src/pyvm.rs index 6836b54d1..161537857 100644 --- a/packages/doeff-vm/src/pyvm.rs +++ b/packages/doeff-vm/src/pyvm.rs @@ -79,6 +79,12 @@ impl PyVM { context: Option>, ) -> pyo3::PyErr { match err { + doeff_vm_core::VMError::OneShotViolation { fiber_id } => { + pyo3::exceptions::PyRuntimeError::new_err(format!( + "one-shot violation: continuation {:?} already consumed", + fiber_id + )) + } doeff_vm_core::VMError::UncaughtException { exception } => { let py_obj = value_to_python(py, exception); // Attach VM-captured traceback to the exception @@ -108,7 +114,24 @@ impl PyVM { doeff_vm_core::VMError::DelegateNoOuterHandler { effect } => { self.make_unhandled_effect_error(py, "Pass: no outer handler", &effect, context) } - other => pyo3::exceptions::PyRuntimeError::new_err(format!("{}", other)), + doeff_vm_core::VMError::HandlerNotFound { marker } => { + pyo3::exceptions::PyRuntimeError::new_err(format!( + "handler not found for marker {}", + marker.raw() + )) + } + doeff_vm_core::VMError::InvalidSegment { message } => { + pyo3::exceptions::PyRuntimeError::new_err(format!("invalid segment: {}", message)) + } + doeff_vm_core::VMError::PythonError { message } => { + pyo3::exceptions::PyRuntimeError::new_err(format!("Python error: {}", message)) + } + doeff_vm_core::VMError::InternalError { message } => { + pyo3::exceptions::PyRuntimeError::new_err(format!("internal error: {}", message)) + } + doeff_vm_core::VMError::TypeError { message } => { + pyo3::exceptions::PyRuntimeError::new_err(format!("type error: {}", message)) + } } } @@ -232,7 +255,19 @@ impl PyVM { format!("{} ({})", type_name, repr) } } - other => format!("{:?}", other), + Value::Unit => "Unit".to_string(), + Value::Int(value) => format!("Int({})", value), + Value::Bool(value) => format!("Bool({})", value), + Value::String(value) => format!("String({:?})", value), + Value::None => "None".to_string(), + Value::Callable(callable) => callable + .name() + .map(|name| format!("Callable({})", name)) + .unwrap_or_else(|| "Callable()".to_string()), + Value::Stream(_) => "Stream()".to_string(), + Value::Continuation(_) => "Continuation()".to_string(), + Value::Var(var) => format!("Var({:?})", var), + Value::List(items) => format!("List(len={})", items.len()), } } @@ -276,7 +311,16 @@ impl PyVM { } } } - _ => { + Value::Unit + | Value::Int(_) + | Value::Bool(_) + | Value::String(_) + | Value::None + | Value::Stream(_) + | Value::Continuation(_) + | Value::Var(_) + | Value::List(_) + | Value::Opaque(_) => { return Err(pyo3::exceptions::PyRuntimeError::new_err( "external call: not callable", )); diff --git a/packages/doeff-vm/tests/test_memory_stats.py b/packages/doeff-vm/tests/test_memory_stats.py index 6315b1963..fa43e9e59 100644 --- a/packages/doeff-vm/tests/test_memory_stats.py +++ b/packages/doeff-vm/tests/test_memory_stats.py @@ -1,152 +1,65 @@ -from dataclasses import dataclass -from pathlib import Path +"""Liveness diagnostics for the current doeff-vm bridge.""" import doeff_vm -from doeff_core_effects.effects import EffectBase +from doeff_core_effects.scheduler import scheduled -from doeff import Gather, Pass, Resume, Spawn, WithHandler, do -from doeff import run as vm_run +from doeff import Gather, Spawn, do, run -Effect = EffectBase -# REMOVED: from doeff_core_effects.cache_handlers import memo_rewriters, sqlite_cache_handler -# REMOVED: from doeff_vm import default_handlers +class SyntheticQuery(doeff_vm.EffectBase): + def __init__(self, key: str) -> None: + self.key = key -def test_memory_stats_exported_with_expected_keys(): - stats = doeff_vm.memory_stats() - assert callable(doeff_vm.memory_stats) - assert set(stats) >= { - "live_segments", - "live_continuations", - "live_ir_streams", - "rust_heap_bytes", - } - assert all(isinstance(stats[key], int) for key in stats) +def _synthetic_query_handler(): + @do + def handler(effect, k): + if isinstance(effect, SyntheticQuery): + return (yield doeff_vm.Resume(k, effect.key)) + yield doeff_vm.Pass(effect, k) + return handler -def test_memory_stats_counts_return_to_baseline_after_run(): - before = doeff_vm.memory_stats() - result = doeff_vm.run(doeff_vm.Pure(7)) - after = doeff_vm.memory_stats() +def test_vm_live_counts_exported_with_expected_shape() -> None: + live_segments, live_continuations, live_ir_streams = doeff_vm.vm_live_counts() - assert result.is_ok() - assert result.value == 7 - assert after["live_segments"] == before["live_segments"] - assert after["live_continuations"] == before["live_continuations"] - assert after["live_ir_streams"] == before["live_ir_streams"] + assert isinstance(live_segments, int) + assert isinstance(live_continuations, int) + assert isinstance(live_ir_streams, int) -def test_memory_stats_counts_return_to_baseline_after_deep_handler_spawn_chain( - tmp_path: Path, -): - cache_path = tmp_path / "vm_memory_stats.sqlite3" +def test_vm_live_counts_return_to_baseline_after_pyvm_run() -> None: + before = doeff_vm.vm_live_counts() + vm = doeff_vm.PyVM() - @dataclass(frozen=True, kw_only=True) - class SyntheticQuery(EffectBase): - key: str + assert vm.run(doeff_vm.Pure(7)) == 7 - def synthetic_query_handler(): - @do - def _handler(effect: Effect, k): - if not isinstance(effect, SyntheticQuery): - yield Pass() - return - return (yield Resume(k, effect.key)) + assert doeff_vm.vm_live_counts() == before + assert vm.arena_stats() == (0, 0, 0, 0) - return _handler +def test_vm_live_counts_return_to_baseline_after_scheduled_handler_chain() -> None: @do def worker(batch_index: int, task_index: int): return (yield SyntheticQuery(key=f"{batch_index}:{task_index}")) @do def scenario(): + batches: list[list[str]] = [] for batch_index in range(2): tasks = [] - for task_index in range(20): - task = yield Spawn( - worker(batch_index=batch_index, task_index=task_index), - daemon=False, - ) - tasks.append(task) - values = yield Gather(*tasks) - if len(values) != 20: - raise AssertionError(f"expected 20 values, got {len(values)}") - - wrapped = scenario() - for handler in reversed( - ( - synthetic_query_handler(), - *memo_rewriters(SyntheticQuery), # noqa: F821 - legacy removed API reference is intentionally preserved - sqlite_cache_handler(cache_path), # noqa: F821 - legacy removed API reference is intentionally preserved - ) - ): - wrapped = WithHandler(handler, wrapped) - - before = doeff_vm.memory_stats() - result = vm_run(wrapped, handlers=default_handlers()) # noqa: F821 - legacy removed API reference is intentionally preserved - after = doeff_vm.memory_stats() - - assert result.is_ok() - assert after["live_segments"] == before["live_segments"] - assert after["live_continuations"] == before["live_continuations"] - assert after["live_ir_streams"] == before["live_ir_streams"] - - -def test_pyvm_run_releases_internal_vm_capacities_after_deep_handler_spawn_chain( - tmp_path: Path, -): - @dataclass(frozen=True, kw_only=True) - class SyntheticQuery(EffectBase): - key: str - - def synthetic_query_handler(): - @do - def _handler(effect: Effect, k): - if not isinstance(effect, SyntheticQuery): - yield Pass() - return - return (yield Resume(k, effect.key)) - - return _handler + for task_index in range(10): + tasks.append((yield Spawn(worker(batch_index, task_index)))) + batches.append(list((yield Gather(*tasks)))) + return batches - @do - def worker(batch_index: int, task_index: int): - return (yield SyntheticQuery(key=f"{batch_index}:{task_index}")) + program = scheduled(doeff_vm.WithHandler(_synthetic_query_handler(), scenario())) + before = doeff_vm.vm_live_counts() - @do - def scenario(): - for batch_index in range(2): - tasks = [] - for task_index in range(20): - task = yield Spawn( - worker(batch_index=batch_index, task_index=task_index), - daemon=False, - ) - tasks.append(task) - values = yield Gather(*tasks) - if len(values) != 20: - raise AssertionError(f"expected 20 values, got {len(values)}") - - program = scenario() - for handler in reversed( - ( - synthetic_query_handler(), - *default_handlers(), # noqa: F821 - legacy removed API reference is intentionally preserved - ) - ): - program = WithHandler(handler, program) + assert run(program) == [ + [f"0:{task_index}" for task_index in range(10)], + [f"1:{task_index}" for task_index in range(10)], + ] - vm = doeff_vm.PyVM() - vm.run(program) - after = vm.memory_stats() - - assert after["arena_capacity"] == 0 - assert after["dispatch_capacity"] == 0 - assert after["segment_dispatch_binding_capacity"] == 0 - assert after["scope_state_capacity"] == 0 - assert after["scope_writer_log_capacity"] == 0 - assert after["retired_scope_state_capacity"] == 0 - assert after["retired_scope_writer_log_capacity"] == 0 + assert doeff_vm.vm_live_counts() == before diff --git a/packages/doeff-vm/tests/test_package_exports.py b/packages/doeff-vm/tests/test_package_exports.py index 7e0907890..b008255bf 100644 --- a/packages/doeff-vm/tests/test_package_exports.py +++ b/packages/doeff-vm/tests/test_package_exports.py @@ -1,45 +1,66 @@ +"""Package export checks for the current doeff-vm bridge API.""" import importlib +CURRENT_RUNTIME_SYMBOLS = ( + "PyVM", + "K", + "Callable", + "EffectBase", + "IRStream", + "UnhandledEffect", + "Ok", + "Err", + "Pure", + "Perform", + "Resume", + "Transfer", + "Apply", + "Expand", + "Pass", + "WithHandler", + "ResumeThrow", + "TransferThrow", + "WithObserve", + "GetTraceback", + "GetExecutionContext", + "GetHandlers", + "GetOuterHandlers", + "TailEval", + "vm_live_counts", +) -def test_package_exports_runtime_api_symbols() -> None: +REMOVED_FACADE_SYMBOLS = ( + "run", + "async_run", + "state", + "reader", + "writer", + "scheduler", + "RunResult", + "DoeffTracebackData", + "memory_stats", + "RustHandler", +) + + +def test_package_exports_current_runtime_api_symbols() -> None: mod = importlib.import_module("doeff_vm") - required = ( - "run", - "async_run", - "state", - "reader", - "writer", - "RunResult", - "DoeffTracebackData", - "PyVM", - ) - missing = [name for name in required if not hasattr(mod, name)] - assert not missing, f"missing module exports: {missing}" - - -def test_package_all_contains_runtime_contract() -> None: + + missing = [name for name in CURRENT_RUNTIME_SYMBOLS if not hasattr(mod, name)] + assert not missing, f"missing package exports: {missing}" + + +def test_removed_facade_symbols_are_not_reexported_from_vm_package() -> None: mod = importlib.import_module("doeff_vm") - exported = set(getattr(mod, "__all__", [])) - expected = { - "run", - "async_run", - "state", - "reader", - "writer", - "RunResult", - "DoeffTracebackData", - "PyVM", - "RustHandler", - } - assert expected.issubset(exported) - - -def test_submodule_and_package_share_runtime_symbols() -> None: - pkg = importlib.import_module("doeff_vm") - sub = importlib.import_module("doeff_vm.doeff_vm") - assert pkg.run is sub.run - assert pkg.async_run is sub.async_run - assert pkg.state is sub.state - assert pkg.reader is sub.reader - assert pkg.writer is sub.writer + + unexpected = [name for name in REMOVED_FACADE_SYMBOLS if hasattr(mod, name)] + assert not unexpected, f"removed facade symbols still exported: {unexpected}" + + +def test_submodule_and_package_share_current_runtime_symbols() -> None: + package = importlib.import_module("doeff_vm") + extension = importlib.import_module("doeff_vm.doeff_vm") + + for name in CURRENT_RUNTIME_SYMBOLS: + assert getattr(package, name) is getattr(extension, name) diff --git a/packages/doeff-vm/tests/test_pyvm.py b/packages/doeff-vm/tests/test_pyvm.py index caee4f662..b441c1fd2 100644 --- a/packages/doeff-vm/tests/test_pyvm.py +++ b/packages/doeff-vm/tests/test_pyvm.py @@ -1,2087 +1,232 @@ -import asyncio +"""Current doeff-vm Python bridge contract tests.""" import doeff_vm import pytest +from doeff_core_effects.handlers import reader, state, writer +from doeff_core_effects.scheduler import Gather, Spawn, scheduled -from doeff import Ask, Effect, Get, Modify, Program, Pure, Put, Tell, do +from doeff import Ask, Get, Put, Tell, do, run -def _with_handlers(program, *handlers): - wrapped = program - for handler in reversed(handlers): - wrapped = doeff_vm.WithHandler(handler, wrapped) - return wrapped - - -def test_import(): - """Test that doeff_vm can be imported.""" - import doeff_vm - - assert hasattr(doeff_vm, "PyVM") - assert hasattr(doeff_vm, "state") - assert hasattr(doeff_vm, "writer") - assert hasattr(doeff_vm, "scheduler") - assert not hasattr(doeff_vm, "PyStdlib") - assert not hasattr(doeff_vm, "PySchedulerHandler") - - -def test_pyvm_creation(): - """Test PyVM can be created.""" - from doeff_vm import PyVM - - vm = PyVM() - assert vm is not None +class CustomEffect(doeff_vm.EffectBase): + def __init__(self, value: int) -> None: + self.value = value -def test_core_handler_sentinels_exported(): - """Test handler sentinels are exported from the module.""" - assert doeff_vm.state is not None - assert doeff_vm.reader is not None - assert doeff_vm.writer is not None - assert doeff_vm.scheduler is not None +class SecondEffect(doeff_vm.EffectBase): + def __init__(self, value: int) -> None: + self.value = value @do -def simple_program() -> Program[int]: +def simple_program(): return 42 - yield # make it a generator - - -def test_simple_pure_program(): - """Test running a simple program that returns a value.""" - from doeff_vm import PyVM - - vm = PyVM() - result = vm.run(simple_program()) - assert result == 42 - - -@do -def counter_program() -> Program[int]: - yield Put("counter", 0) - val = yield Get("counter") - yield Put("counter", val + 1) - new_val = yield Get("counter") - return new_val - - -def test_state_effects(): - """Test Get/Put effects with state handler.""" - from doeff_vm import PyVM - - vm = PyVM() - result = vm.run(_with_handlers(counter_program(), doeff_vm.state)) - assert result == 1 - - -@do -def nested_program() -> Program[int]: - yield Put("a", 10) - yield Put("b", 20) - a = yield Get("a") - b = yield Get("b") - return a + b - - -def test_multiple_state_operations(): - """Test multiple state operations.""" - from doeff_vm import PyVM - - vm = PyVM() - result = vm.run(_with_handlers(nested_program(), doeff_vm.state)) - assert result == 30 - - -@do -def logging_program() -> Program[str]: - yield Tell("Starting") - yield Tell("Processing") - yield Tell("Done") - return "completed" - - -def test_writer_effects(): - """Test Tell effects with writer handler.""" - from doeff_vm import PyVM - - vm = PyVM() - result = vm.run(_with_handlers(logging_program(), doeff_vm.writer)) - assert result == "completed" - - logs = vm.logs() - assert len(logs) == 3 - assert logs[0] == "Starting" - assert logs[1] == "Processing" - assert logs[2] == "Done" - - -@do -def state_and_logging_program() -> Program[int]: - yield Tell("Setting counter") - yield Put("counter", 100) - yield Tell("Getting counter") - val = yield Get("counter") - yield Tell(f"Counter is {val}") - return val - - -def test_combined_effects(): - """Test state and writer handlers used within one VM across runs.""" - from doeff_vm import PyVM - - vm = PyVM() - state_result = vm.run(_with_handlers(counter_program(), doeff_vm.state)) - writer_result = vm.run(_with_handlers(logging_program(), doeff_vm.writer)) - - assert state_result == 1 - assert writer_result == "completed" - - logs = vm.logs() - assert len(logs) == 3 - - -@do -def modify_program() -> Program[tuple[int, int]]: - yield Put("counter", 10) - old_value = yield Modify("counter", lambda x: x * 2) - new_value = yield Get("counter") - return (old_value, new_value) - - -def test_modify_effect(): - """Test Modify effect with Python callback function.""" - from doeff_vm import PyVM - - vm = PyVM() - result = vm.run(_with_handlers(modify_program(), doeff_vm.state)) - assert result == (10, 20) - - -@do -def nested_state_and_writer_program() -> Program[int]: - yield Tell("outer start") - yield Put("x", 1) - yield Tell("after put x=1") - x = yield Get("x") - yield Tell(f"got x={x}") - yield Put("x", x + 10) - final = yield Get("x") - yield Tell(f"final x={final}") - return final -def test_nested_stdlib_handlers(): - """Test state and writer handlers can both run under the same VM.""" - from doeff_vm import PyVM - - vm = PyVM() - state_result = vm.run(_with_handlers(modify_program(), doeff_vm.state)) - writer_result = vm.run(_with_handlers(logging_program(), doeff_vm.writer)) - - assert state_result == (10, 20) - assert writer_result == "completed" - - logs = vm.logs() - assert logs == ["Starting", "Processing", "Done"] - - -@do -def interleaved_effects_program() -> Program[int]: - yield Put("a", 100) - yield Tell("set a=100") - yield Put("b", 200) - yield Tell("set b=200") - a = yield Get("a") - b = yield Get("b") - yield Tell(f"sum={a + b}") - yield Modify("a", lambda x: x * 2) - a2 = yield Get("a") - yield Tell(f"doubled a={a2}") - return a2 + b - - -def test_interleaved_state_writer_modify(): - """Test state operations and logging both remain functional.""" - from doeff_vm import PyVM - - vm = PyVM() - state_result = vm.run(_with_handlers(modify_program(), doeff_vm.state)) - writer_result = vm.run(_with_handlers(logging_program(), doeff_vm.writer)) - assert state_result == (10, 20) - assert writer_result == "completed" - - logs = vm.logs() - assert logs == ["Starting", "Processing", "Done"] - - -class CustomEffect(doeff_vm.EffectBase): - """Custom effect for testing Python handlers.""" - - def __init__(self, value): - self.value = value - - -def make_custom_handler(): - """Create a handler that doubles the effect value and delegates unknown effects.""" +def test_import_exports_current_low_level_api() -> None: + assert doeff_vm.PyVM is not None + assert doeff_vm.EffectBase is not None + assert doeff_vm.WithHandler is not None + assert doeff_vm.TailEval is not None + assert doeff_vm.vm_live_counts is not None + + removed_runtime_facade_symbols = { + "run", + "async_run", + "state", + "reader", + "writer", + "scheduler", + "RunResult", + "memory_stats", + } + assert removed_runtime_facade_symbols.isdisjoint(set(dir(doeff_vm))) - @do - def handler(effect: Effect, k): - if isinstance(effect, CustomEffect): - resume_value = yield doeff_vm.Resume(k, effect.value * 2) - return resume_value - else: - yield doeff_vm.Delegate() - return handler +def test_pyvm_creation() -> None: + vm = doeff_vm.PyVM() + assert vm.arena_stats() == (0, 0, 0, 0) -def test_python_handler_basic(): - """Test basic Python handler invocation.""" - from doeff_vm import PyVM +def test_simple_pure_program() -> None: + vm = doeff_vm.PyVM() + assert vm.run(simple_program()) == 42 - vm = PyVM() - @do - def body() -> Program[int]: - result = yield CustomEffect(21) - return result +def test_apply_resolves_doexpr_args() -> None: + vm = doeff_vm.PyVM() + adder = doeff_vm.Callable(lambda left, right: left + right) + program = doeff_vm.Apply(doeff_vm.Pure(adder), [doeff_vm.Pure(1), doeff_vm.Pure(2)]) - @do - def main() -> Program[int]: - handler = make_custom_handler() - result = yield doeff_vm.WithHandler(handler, body()) - return result + assert vm.run(program) == 3 - result = vm.run(main()) - assert result == 42 +def test_with_handler_rejects_non_callable_handler() -> None: + with pytest.raises(TypeError, match="handler must be callable"): + doeff_vm.WithHandler(42, simple_program()) -def make_state_counting_handler(): - """Create a handler that counts effect invocations and delegates unknown effects.""" - state = {"count": 0} +def test_python_handler_basic() -> None: @do - def handler(effect: Effect, k): + def handler(effect, k): if isinstance(effect, CustomEffect): - state["count"] += 1 - resume_value = yield doeff_vm.Resume(k, state["count"]) - return resume_value - else: - yield doeff_vm.Delegate() - - return handler, state - - -def test_python_handler_multiple_effects(): - """Test Python handler handling multiple effects.""" - from doeff_vm import PyVM - - vm = PyVM() - - @do - def body() -> Program[list[int]]: - a = yield CustomEffect(1) - b = yield CustomEffect(2) - c = yield CustomEffect(3) - return [a, b, c] + return (yield doeff_vm.Resume(k, effect.value * 2)) + yield doeff_vm.Pass(effect, k) @do - def main() -> Program[list[int]]: - handler, _ = make_state_counting_handler() - result = yield doeff_vm.WithHandler(handler, body()) - return result - - result = vm.run(main()) - assert result == [1, 2, 3] - - -def test_python_handler_with_stdlib(): - """Test Python handler working alongside stdlib handlers.""" - from doeff_vm import PyVM + def body(): + value = yield CustomEffect(21) + return value - vm = PyVM() + assert run(doeff_vm.WithHandler(handler, body())) == 42 - @do - def body() -> Program[int]: - x = yield CustomEffect(10) - yield Put("x", x) - y = yield Get("x") - return y +def test_nested_with_handler_passes_to_outer_handler() -> None: @do - def main() -> Program[int]: - handler = make_custom_handler() - result = yield doeff_vm.WithHandler(handler, body()) - return result - - result = vm.run(_with_handlers(main(), doeff_vm.state)) - assert result in (20, None) - - -def test_nested_with_handler(): - """Test nested WithHandler: inner handler completes, then outer handler handles.""" - from doeff_vm import PyVM - - vm = PyVM() + def inner_handler(effect, k): + if isinstance(effect, SecondEffect): + return (yield doeff_vm.Resume(k, f"inner:{effect.value}")) + yield doeff_vm.Pass(effect, k) @do - def inner_handler(effect: Effect, k): + def outer_handler(effect, k): if isinstance(effect, CustomEffect): - resume_value = yield doeff_vm.Resume(k, effect.value + 100) - return resume_value - else: - yield doeff_vm.Delegate() + return (yield doeff_vm.Resume(k, f"outer:{effect.value}")) + yield doeff_vm.Pass(effect, k) @do - def outer_handler(effect: Effect, k): - if isinstance(effect, CustomEffect): - resume_value = yield doeff_vm.Resume(k, effect.value * 2) - return resume_value - else: - yield doeff_vm.Delegate() + def body(): + return (yield CustomEffect(5)) - @do - def inner_body() -> Program[int]: - val = yield CustomEffect(5) # inner_handler: 5 + 100 = 105 - return val + program = doeff_vm.WithHandler(outer_handler, doeff_vm.WithHandler(inner_handler, body())) + assert run(program) == "outer:5" - @do - def outer_body() -> Program[int]: - inner_result = yield doeff_vm.WithHandler(inner_handler, inner_body()) # 105 - outer_val = yield CustomEffect(inner_result) # outer_handler: 105 * 2 = 210 - return outer_val +def test_get_handlers_reports_dispatch_continuation_chain() -> None: @do - def main() -> Program[int]: - result = yield doeff_vm.WithHandler(outer_handler, outer_body()) - return result - - result = vm.run(main()) - assert result == 210 - - -def test_handler_transforms_resume_result(): - """Test that handler can transform the value returned after Resume.""" - from doeff_vm import PyVM - - vm = PyVM() + def inner_handler(effect, k): + yield doeff_vm.Pass(effect, k) @do - def transforming_handler(effect: Effect, k): + def outer_handler(effect, k): if isinstance(effect, CustomEffect): - resume_value = yield doeff_vm.Resume(k, effect.value) - return resume_value * 3 - else: - yield doeff_vm.Delegate() - - @do - def body() -> Program[int]: - x = yield CustomEffect(10) # handler sends 10 back - return x + 5 # body returns 15 + handlers = yield doeff_vm.GetHandlers(k) + return (yield doeff_vm.Resume(k, [handler.__name__ for handler in handlers])) + yield doeff_vm.Pass(effect, k) @do - def main() -> Program[int]: - # Handler resumes body with 10, body returns 15, - # handler gets resume_value=15, returns 15*3=45. - result = yield doeff_vm.WithHandler(transforming_handler, body()) - return result - - result = vm.run(main()) - assert result == 45 + def body(): + return (yield CustomEffect(1)) + program = doeff_vm.WithHandler(outer_handler, doeff_vm.WithHandler(inner_handler, body())) + assert run(program) == ["inner_handler", "outer_handler"] -def test_handler_return_after_resume_delivers_raw_doexpr(): - """Handler return should deliver raw DoExpr values after consuming k.""" - from doeff_vm import PyVM - vm = PyVM() +def test_transfer_resumes_and_abandons_handler_generator() -> None: + handler_continued = {"ran": False} @do - def raw_doexpr_handler(effect: Effect, k): + def transfer_handler(effect, k): if isinstance(effect, CustomEffect): - resume_value = yield doeff_vm.Resume(k, effect.value * 2) - return doeff_vm.Pure(f"wrapped:{resume_value}") - yield doeff_vm.Delegate() + yield doeff_vm.Transfer(k, effect.value * 10) + handler_continued["ran"] = True + yield doeff_vm.Pass(effect, k) @do - def body() -> Program[int]: - value = yield CustomEffect(10) + def body(): + value = yield CustomEffect(7) return value + 1 - @do - def main(): - result = yield doeff_vm.WithHandler(raw_doexpr_handler, body()) - return result - - result = vm.run(main()) - assert isinstance(result, doeff_vm.Pure) - assert result.value == "wrapped:21" - - -def test_handler_abandon_continuation(): - """Handler returns without consuming the continuation: raise immediately.""" - from doeff_vm import PyVM - - vm = PyVM() - - @do - def abandoning_handler(effect: Effect, k): - if isinstance(effect, CustomEffect): - # Return directly without Resume; the body's continuation is abandoned. - return effect.value * 10 - yield doeff_vm.Delegate() - - @do - def body() -> Program[int]: - x = yield CustomEffect(7) # handler does not resume, so body stops here - return x + 1000 # this line should never execute - - @do - def main() -> Program[int]: - result = yield doeff_vm.WithHandler(abandoning_handler, body()) - return result - - with pytest.raises( - RuntimeError, - match=r"handler returned without consuming continuation .* Resume\(k, v\), Transfer\(k, v\), Discontinue\(k, exn\), or Pass\(\)", - ): - vm.run(main()) - - -@pytest.mark.parametrize( - ("primitive_name", "expected_snippet"), - [ - ("Pass", "return Pass() -> yield Pass()"), - ("Resume", "return Resume(k, value) -> yield Resume(k, value)"), - ("Delegate", "return Delegate() -> yield Delegate()"), - ("Transfer", "return Transfer(k, value) -> yield Transfer(k, value)"), - ("Discontinue", "return Discontinue(k, exn) -> yield Discontinue(k, exn)"), - ( - "ResumeContinuation", - "return ResumeContinuation(k, value) -> yield ResumeContinuation(k, value)", - ), - ], -) -def test_handler_returning_control_primitive_raises_clear_error( - primitive_name: str, expected_snippet: str -) -> None: - from doeff_vm import PyVM - - vm = PyVM() - - @do - def bad_handler(effect: Effect, k): - if isinstance(effect, CustomEffect): - if primitive_name == "Pass": - return doeff_vm.Pass() - if primitive_name == "Resume": - return doeff_vm.Resume(k, effect.value * 2) - if primitive_name == "Delegate": - return doeff_vm.Delegate() - if primitive_name == "Transfer": - return doeff_vm.Transfer(k, effect.value * 3) - if primitive_name == "Discontinue": - return doeff_vm.Discontinue(k, ValueError("reason")) - if primitive_name == "ResumeContinuation": - return doeff_vm.ResumeContinuation(k, effect.value * 4) - raise AssertionError(f"unexpected primitive {primitive_name}") - yield doeff_vm.Delegate() - - @do - def body() -> Program[int]: - return (yield CustomEffect(7)) - - @do - def main() -> Program[int]: - return (yield doeff_vm.WithHandler(bad_handler, body())) - - with pytest.raises(RuntimeError) as exc_info: - vm.run(main()) - - message = str(exc_info.value) - assert f"Handler returned {primitive_name}" in message - assert "control primitives must be yielded, not returned" in message - assert expected_snippet in message - - -def test_discontinue_basic(): - from doeff_vm import PyVM - - vm = PyVM() - - @do - def discontinue_handler(effect: Effect, k): - if isinstance(effect, CustomEffect): - yield doeff_vm.Discontinue(k) - yield doeff_vm.Delegate() - - @do - def body() -> Program[str]: - try: - _ = yield CustomEffect(7) - return "unreachable" - except BaseException as exc: - return f"{exc.__class__.__module__}.{exc.__class__.__name__}" - - @do - def main() -> Program[str]: - return (yield doeff_vm.WithHandler(discontinue_handler, body())) - - assert vm.run(main()).endswith(".Discontinued") - + assert run(doeff_vm.WithHandler(transfer_handler, body())) == 71 + assert handler_continued["ran"] is False -def test_discontinue_with_custom_exception(): - from doeff_vm import PyVM - - vm = PyVM() +def test_resume_throw_can_be_caught_by_body() -> None: @do - def discontinue_handler(effect: Effect, k): + def throwing_handler(effect, k): if isinstance(effect, CustomEffect): - yield doeff_vm.Discontinue(k, ValueError("reason")) - yield doeff_vm.Delegate() + return (yield doeff_vm.ResumeThrow(k, ValueError("boom"))) + yield doeff_vm.Pass(effect, k) @do - def body() -> Program[str]: + def body(): try: - _ = yield CustomEffect(7) - return "unreachable" + yield CustomEffect(1) except ValueError as exc: return str(exc) - - @do - def main() -> Program[str]: - return (yield doeff_vm.WithHandler(discontinue_handler, body())) - - assert vm.run(main()) == "reason" - - -def test_handler_accumulates_across_effects(): - """Test handler that accumulates state across multiple effect invocations.""" - from doeff_vm import PyVM - - vm = PyVM() - - accumulated = [] - - @do - def accumulating_handler(effect: Effect, k): - if isinstance(effect, CustomEffect): - accumulated.append(effect.value) - total = sum(accumulated) - resume_value = yield doeff_vm.Resume(k, total) - return resume_value - else: - yield doeff_vm.Delegate() - - @do - def body() -> Program[tuple[int, int, int]]: - a = yield CustomEffect(10) # accumulated=[10], total=10 - b = yield CustomEffect(20) # accumulated=[10,20], total=30 - c = yield CustomEffect(30) # accumulated=[10,20,30], total=60 - return (a, b, c) - - @do - def main() -> Program[tuple[int, int, int]]: - result = yield doeff_vm.WithHandler(accumulating_handler, body()) - return result - - result = vm.run(main()) - assert result == (10, 30, 60) - assert accumulated == [10, 20, 30] - - -def test_scheduler_creation(): - """Test scheduler handler can be created and installed.""" - assert doeff_vm.scheduler is not None - - -def test_scheduler_create_promise(): - """Test scheduler CreatePromise effect.""" - from doeff_vm import PyVM - - vm = PyVM() - - @do - def body() -> Program[dict]: - promise = yield doeff_vm.CreatePromiseEffect() - return promise - - result = vm.run(_with_handlers(body(), doeff_vm.scheduler)) - # Result should be a dict representing a PromiseHandle - assert isinstance(result, dict) - assert result["type"] == "Promise" - assert "promise_id" in result - - -def test_py_store_basic(): - """Test PyStore read/write.""" - from doeff_vm import PyVM - - vm = PyVM() - - # Set and get - vm.set_store("key1", 42) - assert vm.get_store("key1") == 42 - - # Get the dict directly - store = vm.py_store() - assert isinstance(store, dict) - assert store["key1"] == 42 - - # Store persists across runs - vm.set_store("key2", "hello") - - @do - def prog() -> Program[int]: - return 1 - yield - - vm.run(prog()) - - assert vm.get_store("key2") == "hello" - - -def _async_escape(action): - """Create the VM AsyncEscape DoCtrl node for async Python calls.""" - import doeff_vm - - return doeff_vm.PythonAsyncSyntaxEscape(action=action) - - -async def async_run(vm, program): - """Async driver that can await AsyncEscape actions. - - Unlike ``vm.run()``, this driver can handle ``CallAsync`` events by - awaiting the returned coroutine inside a real asyncio event loop. - """ - vm.start_program(program) - - while True: - result = vm.step_once() - tag = result[0] - if tag == "done": - return result[1] - if tag == "error": - raise result[1] - if tag == "call_async": - func, args = result[1], result[2] - try: - awaitable = func(*args) - value = await awaitable - vm.feed_async_result(value) - except Exception as e: - vm.feed_async_error(e) - elif tag == "continue": - # yield to the event loop briefly so other tasks can run - await asyncio.sleep(0) - else: - raise RuntimeError(f"Unexpected step result tag: {tag}") - - -# --------------------------------------------------------------------------- -# Async tests -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_async_run_basic(): - """Test async_run with an AsyncEscape action returning a value.""" - from doeff_vm import PyVM - - vm = PyVM() - - async def my_async_action(): - await asyncio.sleep(0.01) - return 42 - - @do - def body() -> Program[int]: - result = yield _async_escape(my_async_action) - return result - - result = await async_run(vm, body()) - assert result == 42 - - -@pytest.mark.asyncio -async def test_async_run_multiple_awaits(): - """Test async_run with several sequential async escapes.""" - from doeff_vm import PyVM - - vm = PyVM() - - async def async_add(a, b): - await asyncio.sleep(0) - return a + b - - @do - def body() -> Program[int]: - x = yield _async_escape(lambda: async_add(1, 2)) - y = yield _async_escape(lambda: async_add(x, 10)) - return y - - result = await async_run(vm, body()) - assert result == 13 - - -@pytest.mark.asyncio -async def test_async_run_error_propagation(): - """Test that exceptions from async actions propagate correctly.""" - from doeff_vm import PyVM - - vm = PyVM() - - async def failing_action(): - raise ValueError("async boom") - - @do - def body() -> Program[int]: - result = yield _async_escape(failing_action) - return result # should not reach here - - with pytest.raises(ValueError, match="async boom"): - await async_run(vm, body()) - - -@pytest.mark.asyncio -async def test_async_run_with_pure_return(): - """Test async_run with a program that has no async escapes.""" - from doeff_vm import PyVM - - vm = PyVM() - - @do - def body() -> Program[int]: - return 99 - yield # make it a generator - - result = await async_run(vm, body()) - assert result == 99 - - -@pytest.mark.asyncio -async def test_async_run_with_state_effects(): - """Test async_run with stdlib state effects + async escapes.""" - from doeff_vm import PyVM - - vm = PyVM() - - async def fetch_value(): - await asyncio.sleep(0) - return 100 - - @do - def body() -> Program[int]: - fetched = yield _async_escape(fetch_value) - yield Put("x", fetched) - val = yield Get("x") - return val - - result = await async_run(vm, _with_handlers(body(), doeff_vm.state)) - assert result == 100 - - -def test_feed_async_result_after_completion_raises_runtime_error(): - """Late async results must not be silently dropped after the VM is done.""" - from doeff_vm import PyVM - - vm = PyVM() - assert vm.run(simple_program()) == 42 - - with pytest.raises(RuntimeError, match="receive_python_result called without current segment"): - vm.feed_async_result(123) - - -def test_feed_async_error_after_completion_raises_runtime_error(): - """Late async errors must not be silently dropped after the VM is done.""" - from doeff_vm import PyVM - - vm = PyVM() - assert vm.run(simple_program()) == 42 - - with pytest.raises(RuntimeError, match="receive_python_result called without current segment"): - vm.feed_async_error(RuntimeError("late async boom")) - - -## -- Scoped handler behavior tests (WithHandler) ---------------------------- - - -def test_run_scoped_state(): - """Test wrapped run installs state handler only for that run.""" - from doeff_vm import PyVM - - vm = PyVM() - - result = vm.run(_with_handlers(counter_program(), doeff_vm.state)) - assert result == 1 - - -def test_run_scoped_writer(): - """Test wrapped run installs writer handler only for that run.""" - from doeff_vm import PyVM - - vm = PyVM() - - result = vm.run(_with_handlers(logging_program(), doeff_vm.writer)) - assert result == "completed" - logs = vm.logs() - assert len(logs) == 3 - - -def test_run_scoped_combined(): - """Test wrapped state and writer runs in one VM instance.""" - from doeff_vm import PyVM - - vm = PyVM() - - state_result = vm.run(_with_handlers(counter_program(), doeff_vm.state)) - writer_result = vm.run(_with_handlers(logging_program(), doeff_vm.writer)) - assert state_result == 1 - assert writer_result == "completed" - logs = vm.logs() - assert len(logs) == 3 - - -def test_run_scoped_handlers_do_not_leak(): - """Test wrapped handlers are scoped to a single run. - - After a wrapped run with the state sentinel, a subsequent run() without - handlers should fail because there is no handler for Get/Put effects. - """ - from doeff_vm import PyVM - - vm = PyVM() - - # First run succeeds with scoped state handler - result = vm.run(_with_handlers(counter_program(), doeff_vm.state)) - assert result == 1 - - # Second run without handlers should fail (no state handler) - with pytest.raises(Exception, match=r"(no matching handler|unhandled effect)"): - vm.run(counter_program()) - - -def test_run_scoped_handlers_do_not_leak_writer(): - """Test wrapped writer handlers are scoped to a single run.""" - from doeff_vm import PyVM - - vm = PyVM() - - # First run succeeds with scoped writer handler - result = vm.run(_with_handlers(logging_program(), doeff_vm.writer)) - assert result == "completed" - - # Second run without handlers should fail - with pytest.raises(Exception, match=r"(no matching handler|unhandled effect)"): - vm.run(logging_program()) - - -def test_run_scoped_error_still_cleans_up(): - """Test wrapped handlers do not leak even when the program raises an error.""" - from doeff_vm import PyVM - - vm = PyVM() - - @do - def failing_program() -> Program[int]: - yield Put("x", 1) - raise ValueError("intentional error") - yield # make it a generator - - # This should raise but still clean up the handlers - with pytest.raises(ValueError, match="intentional error"): - vm.run(_with_handlers(failing_program(), doeff_vm.state)) - - # Verify handler was cleaned up: running a state program should fail - with pytest.raises(Exception, match=r"(no matching handler|unhandled effect)"): - vm.run(counter_program()) - - -def test_run_scoped_successive_independent_runs(): - """Test that successive wrapped runs are independent.""" - from doeff_vm import PyVM - - vm = PyVM() - - @do - def put_program() -> Program[None]: - yield Put("key", 42) - return None - - @do - def get_program() -> Program[int]: - val = yield Get("key") - return val - - # Run 1: put a value - vm.run(_with_handlers(put_program(), doeff_vm.state)) - - # Run 2: get the value (state persists in RustStore across runs). - result = vm.run(_with_handlers(get_program(), doeff_vm.state)) - # RustStore state persists across runs (it's the store, not the handler) - assert result == 42 - - -## -- ProgramBase validation tests (to_generator_strict) -------------------- - - -def test_yielded_raw_generator_rejected_as_program(): - """Yielding a raw generator inside a program body should raise TypeError. - - The VM requires program-classification entries to be ProgramBase objects (with - a ``to_generator`` method). Raw generators must go through ``vm.run()`` - or ``vm.start_program()`` instead. - """ - from doeff_vm import PyVM - - vm = PyVM() - - def sub_generator(): - """A plain generator (not decorated with @do).""" - yield Pure(42) - - @do - def main() -> Program[int]: - # Yielding a raw generator triggers classify_yielded -> program-classification - # -> StartProgram -> to_generator_strict -> TypeError - result = yield sub_generator() - return result - - with pytest.raises(TypeError, match=r"(Expected ProgramBase|DoExpr)"): - vm.run(main()) - - -def test_yielded_program_base_accepted(): - """Yielding a @do-decorated ProgramBase inside a program body should work. - - This is the happy-path counterpart of the strict validation test. - """ - from doeff_vm import PyVM - - vm = PyVM() - - @do - def sub_program() -> Program[int]: - return 99 - yield # make it a generator - - @do - def main() -> Program[int]: - result = yield sub_program() - return result - - result = vm.run(main()) - assert result == 99 - - -def test_run_rejects_raw_generator(): - """vm.run() should reject raw generators at the top level.""" - from doeff_vm import PyVM - - vm = PyVM() - - def raw_gen(): - return 7 - yield # make it a generator - - with pytest.raises(TypeError, match="DoExpr"): - vm.run(raw_gen()) - - -## -- Gap-detection tests (G1, G2, G4) -------------------------------------- -# These tests expose specific spec-vs-impl divergences. -# They should FAIL before the corresponding fixes are applied. - - -class GetHandlers: - """Control primitive: request the current handler chain.""" - - -class UnknownThing: - """Not an effect, not a primitive, not a program. - - Used by G4 test to verify that truly unrecognized yielded objects - produce a TypeError (legacy unknown-yield classification) rather than being silently - dispatched as Effect::Python. - """ - - def __init__(self, value): - self.value = value - - -def test_handler_returning_program_base_violates_linearity(): - """G1: handler return no longer auto-evaluates returned ProgramBase values.""" - from doeff_vm import PyVM - - vm = PyVM() - - @do - def handler_func(effect: Effect, k): - """Regular function that returns a @do-decorated ProgramBase.""" - - @do - def handler_program() -> Program[int]: - result = yield doeff_vm.Resume(k, effect.value * 2) - return result - - return handler_program() # Returns ProgramBase, NOT a generator - - @do - def body() -> Program[int]: - result = yield CustomEffect(21) - return result - - @do - def main() -> Program[int]: - result = yield doeff_vm.WithHandler(handler_func, body()) - return result - - with pytest.raises(RuntimeError, match="handler returned without consuming continuation"): - vm.run(main()) - - -def test_get_handlers_uses_dispatch_handler_chain(): - """G2: GetHandlers inside a handler should return the dispatch-time handler_chain. - - When a handler installs additional handlers (via WithHandler) during its - execution and then calls GetHandlers, the result should reflect the - handler_chain snapshot taken when dispatch started — NOT the current - scope_chain which includes the newly installed handler. - - Verified: handle_get_handlers correctly uses dispatch context's - handler_chain, not current_scope_chain(). - """ - from doeff_vm import ( - Delegate as VMDelegate, - ) - from doeff_vm import ( - GetHandlers as VMGetHandlers, - ) - from doeff_vm import ( - Perform as VMPerform, - ) - from doeff_vm import ( - PyVM, - ) - from doeff_vm import ( - Resume as VMResume, - ) - from doeff_vm import ( - WithHandler as VMWithHandler, - ) - - vm = PyVM() - - @do - def outer_handler(effect: Effect, k): - if isinstance(effect, CustomEffect): - # Install a temporary inner handler during handler execution - @do - def temp_handler(eff: Effect, k2): - yield VMDelegate() - - # GetHandlers here: if using scope_chain, result includes - # temp_handler; if using handler_chain, it does not. - inner_result = yield VMWithHandler(temp_handler, VMGetHandlers()) - # inner_result is the handler list from GetHandlers - resume_value = yield VMResume(k, inner_result) - return resume_value - yield VMDelegate() - - @do - def main() -> Program[object]: - result = yield VMWithHandler(outer_handler, VMPerform(CustomEffect(42))) - return result - - result = vm.run(main()) - # result is the list returned by GetHandlers. - # Spec (handler_chain): should NOT include temp_handler. - # Impl (scope_chain): DOES include temp_handler → list is longer. - # - # With only outer_handler installed at dispatch time, handler_chain - # has exactly 1 handler. scope_chain would have 2 (temp + outer). - assert isinstance(result, list) - assert len(result) == 1, ( - f"Expected 1 handler (dispatch handler_chain) but got {len(result)} " - f"(scope_chain leaked newly installed handler)" - ) - - -def test_yielding_unknown_object_raises_type_error(): - """G4: Yielding a primitive value (not an effect/primitive/program) raises TypeError. - - Primitive Python types (int, str, bool, None, etc.) are not valid effects - and cannot be dispatched through handlers. The VM should raise TypeError - for these, not silently try to dispatch them. - - Class instances remain valid as custom effects (Effect::Python). - """ - from doeff_vm import PyVM - - vm = PyVM() - - def body_int(): - result = yield 42 - return result - - def body_str(): - result = yield "not an effect" - return result - - with pytest.raises(TypeError): - vm.run(body_int()) - - with pytest.raises(TypeError): - vm.run(body_str()) - - -## -- R8 Spec Gap TDD tests -------------------------------------------------- -# These tests verify spec compliance fixes found during systematic audit. - - -def test_run_with_result_has_result_getter(): - """G9: PyRunResult must have a .result getter returning (tag, payload).""" - from doeff_vm import PyVM - - vm = PyVM() - - result = vm.run_with_result(simple_program()) - assert result.is_ok() - - # .result may be a tuple ("ok", value) or a Result wrapper object. - r = result.result - if isinstance(r, tuple): - assert r[0] == "ok" - assert r[1] == 42 - else: - assert "Ok(" in repr(r) - - -def test_run_with_result_raw_store_excludes_env(): - """G10: raw_store should contain state entries only, not env entries.""" - from doeff_vm import PyVM - - vm = PyVM() - - # Pre-populate env - vm.put_env("config_key", "config_value") - - @do - def prog() -> Program[int]: - yield Put("counter", 42) - return 1 - - result = vm.run_with_result(_with_handlers(prog(), doeff_vm.state)) - raw = result.raw_store - assert isinstance(raw, dict) - assert "counter" in raw - assert raw["counter"] == 42 - # env entries must NOT appear in raw_store - assert "__env__config_key" not in raw - assert "config_key" not in raw - - -def test_with_handler_program_attribute(): - """WithHandler pyclass uses 'program' field (not 'body') per spec.""" - from doeff_vm import PyVM - from doeff_vm import WithHandler as RustWithHandler - - vm = PyVM() - - @do - def handler(effect: Effect, k): - if isinstance(effect, CustomEffect): - resume_value = yield doeff_vm.Resume(k, effect.value * 3) - return resume_value - yield doeff_vm.Delegate() - - @do - def body() -> Program[int]: - result = yield CustomEffect(10) - return result - - # Use the Rust pyclass WithHandler (which has 'program' not 'body') - @do - def main() -> Program[int]: - result = yield RustWithHandler(handler, body()) - return result - - result = vm.run(main()) - assert result == 30 - - -def test_is_standard_excludes_scheduler_effect(): - """G14: is_standard() should not include Scheduler effects. - - Scheduler effects go through dispatch like all others, so is_standard - correctly identifies only state/reader/writer effects. - """ - from doeff_vm import PyVM - - vm = PyVM() - - # Verify scheduler effects go through dispatch (not bypassed) - @do - def body() -> Program[dict]: - promise = yield doeff_vm.CreatePromiseEffect() - return promise - - result = vm.run(_with_handlers(body(), doeff_vm.scheduler)) - assert isinstance(result, dict) - - -def test_put_state_and_put_env(): - """R8-E: put_state, put_env, env_items work correctly.""" - from doeff_vm import PyVM - - vm = PyVM() - - vm.put_state("x", 10) - vm.put_env("env_key", "env_val") - - state = vm.state_items() - assert state["x"] == 10 - - env = vm.env_items() - assert env["env_key"] == "env_val" - - -def test_run_with_result_error_case(): - """R8-J: PyRunResult wraps errors properly.""" - from doeff_vm import PyVM - - vm = PyVM() - - @do - def failing_prog() -> Program[int]: - raise ValueError("boom") - yield - - result = vm.run_with_result(failing_prog()) - assert result.is_err() - assert not result.is_ok() - - # .result may be a tuple ("err", exception) or a Result wrapper object. - r = result.result - if isinstance(r, tuple): - assert r[0] == "err" - else: - assert "Err(" in repr(r) - - -## -- G11: Module-level run() and async_run() ---------------------------------- -# Spec defines these as top-level functions, not just methods on PyVM. - - -def test_module_level_run_exists(): - """G11: doeff_vm.run() must be a module-level function.""" - import doeff_vm - - assert hasattr(doeff_vm, "run"), "Module-level run() function missing" - assert callable(doeff_vm.run) - - -def test_module_level_async_run_exists(): - """G11: doeff_vm.async_run() must be a module-level function.""" - import doeff_vm - - assert hasattr(doeff_vm, "async_run"), "Module-level async_run() function missing" - assert callable(doeff_vm.async_run) - - -def test_module_level_run_basic(): - """G11: run(program) returns a RunResult with the program's return value.""" - from doeff_vm import run - - result = run(simple_program()) - assert result.is_ok() - assert result.value == 42 - - -def test_module_level_run_wraps_invalid_top_level_yield_in_run_result(): - """G11: invalid yielded values surface as Err RunResult, not raw TypeError.""" - from doeff_vm import run - - @do - def invalid_program() -> Program[object]: - yield object() return "unreachable" - result = run(invalid_program()) - - assert result.is_err() - assert isinstance(result.error, TypeError) - assert str(result.error).startswith("yielded value must be EffectBase or DoExpr, got ") - assert "(type: object)" in str(result.error) - assert "Hint: this may be a resolved ProgramLike value." not in str(result.error) - assert result.traceback_data is not None - - -def test_module_level_run_with_env_and_store(): - """G11: run() accepts env and store dicts to seed the VM.""" - from doeff_vm import WithHandler, run, state - - @do - def prog() -> Program[int]: - val = yield Get("counter") - return val - - result = run(WithHandler(state, prog()), store={"counter": 99}) - assert result.is_ok() - assert result.value == 99 - - -def test_module_level_run_with_state_handler(): - """G11: run(WithHandler(state, program)) installs state handler.""" - from doeff_vm import WithHandler, run, state - - result = run(WithHandler(state, counter_program())) - assert result.is_ok() - assert result.value == 1 - # raw_store should contain the state - assert result.raw_store.get("counter") == 1 - - -def test_module_level_run_with_writer_handler(): - """G11: run(WithHandler(writer, program)) installs writer handler.""" - from doeff_vm import WithHandler, run, writer - - result = run(WithHandler(writer, logging_program())) - assert result.is_ok() - assert result.value == "completed" - - -def test_module_level_run_no_handlers_unhandled_effect(): - """G11: run(program) with no handlers raises for unhandled effects.""" - from doeff_vm import run - - result = run(counter_program()) - assert result.is_err() - - -def test_module_level_async_run_basic(): - """G11: async_run() works with asyncio event loop.""" - from doeff_vm import async_run - - @do - def pure_generator_program() -> Program[int]: - return 42 - yield - - async def main(): - result = await async_run(pure_generator_program()) - return result - - result = asyncio.run(main()) - assert result.is_ok() - assert result.value == 42 - - -## -- R9: Semantic Correctness Enforcement Tests ---------------------------------- -# These tests enforce SPEC-008 Rev 9 and SPEC-009 Rev 3 invariants. -# Written as TDD RED — they SHOULD FAIL until the implementation is corrected. - - -class TestR9HandlerNesting: - """ADR-13, INV-16: run() must use WithHandler nesting, not install_handler.""" - - def test_run_with_sentinel_handler_objects(self): - """R9: run() accepts sentinel handler objects (not strings).""" - import doeff_vm - - # Module-level sentinel objects must exist - assert hasattr(doeff_vm, "state"), "Module should export 'state' sentinel" - assert hasattr(doeff_vm, "reader"), "Module should export 'reader' sentinel" - assert hasattr(doeff_vm, "writer"), "Module should export 'writer' sentinel" - - from doeff_vm import WithHandler, run, state - - result = run(WithHandler(state, counter_program())) - assert result.is_ok() - assert result.value == 1 - - def test_run_sentinel_state_and_reader(self): - """R9: run() supports state and reader sentinels.""" - from doeff_vm import WithHandler, reader, run, state - - @do - def state_prog() -> Program[int]: - val = yield Get("x") - return val - - @do - def reader_prog() -> Program[str]: - name = yield Ask("name") - return name - - state_result = run(WithHandler(state, state_prog()), store={"x": 42}) - reader_result = run(WithHandler(reader, reader_prog()), env={"name": "count"}) - assert state_result.is_ok() - assert state_result.value == 42 - assert reader_result.is_ok() - assert reader_result.value == "count" - - def test_run_handler_ordering_is_deterministic(self): - """R9 INV-16: Handler ordering must be deterministic across runs.""" - from doeff_vm import WithHandler, run, state - - results = [] - for _ in range(20): - - @do - def prog() -> Program[int]: - yield Put("x", 1) - val = yield Get("x") - return val - - result = run(WithHandler(state, prog()), store={"x": 0}) - assert result.is_ok() - results.append(result.value) - - # All 20 runs must produce the same result - assert all(r == results[0] for r in results), f"Non-deterministic results: {results}" - - def test_run_with_python_handler_and_sentinel(self): - """R9: run() accepts mixed Python handlers and sentinel handlers.""" - from doeff_vm import Resume, WithHandler, run, state - - @do - def my_handler(effect: Effect, k): - if hasattr(effect, "message"): - # Custom Tell handler that does nothing - result = yield Resume(k, None) - return result - from doeff_vm import Delegate - - yield Delegate() - - @do - def prog() -> Program[int]: - yield Put("x", 10) - val = yield Get("x") - return val - - result = run(WithHandler(my_handler, WithHandler(state, prog()))) - assert result.is_ok() - assert result.value == 10 - - -class TestR9RunResultNaming: - """SPEC-009: RunResult must be the Python-visible class name.""" - - def test_runresult_class_name(self): - """R9: The class exposed to Python must be named 'RunResult', not 'PyRunResult'.""" - from doeff_vm import run - - result = run(simple_program()) - assert type(result).__name__ == "RunResult", ( - f"Expected class name 'RunResult', got '{type(result).__name__}'" - ) - - def test_runresult_importable(self): - """R9: RunResult should be importable from doeff_vm.""" - from doeff_vm import RunResult - - assert RunResult is not None - - -class TestR9ClassifyYieldedCompleteness: - """INV-17: classify_yielded must have explicit arms for all scheduler effects. - - These tests verify that scheduler effects are classified as Effect::Scheduler, - not falling through to Effect::Python. The distinction matters because the - scheduler handler's can_handle() only matches Effect::Scheduler(_). - - We test this by checking that the effect class type names are recognized - in classify_yielded's string matching. Since we can't directly introspect - classify_yielded from Python, we verify that scheduler effects dispatched - without a scheduler handler result in UnhandledEffect (not TypeError from - Unknown classification or silent misrouting via Effect::Python). - """ - - def test_scheduler_effects_type_names_are_recognized(self): - """R9 INV-17: All scheduler effect type names must be in classify_yielded.""" - # This is a structural test — it checks that the Rust source code - # contains explicit classify_yielded arms for all scheduler effects. - # We import from the test to verify the effect classes exist and have - # the expected type names that classify_yielded should match. - from doeff.effects.promise import CompletePromiseEffect, FailPromiseEffect - from doeff.effects.race import RaceEffect - - # Verify the type names that classify_yielded needs to match - assert SpawnEffect.__name__ == "SpawnEffect" # noqa: F821 - legacy removed API reference is intentionally preserved - assert GatherEffect.__name__ == "GatherEffect" # noqa: F821 - legacy removed API reference is intentionally preserved - assert RaceEffect.__name__ == "RaceEffect" - assert CompletePromiseEffect.__name__ == "CompletePromiseEffect" - assert FailPromiseEffect.__name__ == "FailPromiseEffect" - - def test_classify_gather_effect_not_as_program(self): - """R9 INV-17: GatherEffect must be classified as Effect, not Program. - - GatherEffect extends EffectBase extends ProgramBase, so it has - `to_generator`. classify_yielded must check for effect type names - BEFORE the to_generator fallback, otherwise scheduler effects get - misclassified as program-classification and the VM tries to start them. - """ - import subprocess - import sys - - # Run in subprocess with timeout to detect hang - result = subprocess.run( - [ - sys.executable, - "-c", - """ -from doeff import do, Program -from doeff_core_effects.scheduler import Gather -from doeff_vm import PyVM - -vm = PyVM() - -@do -def prog(): - result = yield GatherEffect(items=[]) - return result - -result = vm.run_with_result(prog()) -# If we get here, classify was correct (should be UnhandledEffect error) -print("OK" if result.is_err() else "WRONG_OK") -""", - ], - capture_output=True, - text=True, - timeout=3, - check=False, - ) - assert result.returncode == 0, f"Process crashed: {result.stderr}" - assert result.stdout.strip() == "OK", ( - f"GatherEffect misclassified. stdout={result.stdout.strip()}, " - f"stderr={result.stderr[:200]}" - ) - - def test_classify_yielded_must_check_effects_before_to_generator(self): - """R9 INV-17: scheduler effects remain effect values, not programs. - - SPEC-TYPES-001 Rev 11 separates EffectValue data from DoExpr control, - so scheduler effect classes must not expose program-only conversion - methods such as ``to_generator``. - """ - from doeff.effects.promise import CompletePromiseEffect, FailPromiseEffect - from doeff.effects.race import RaceEffect - - for cls in [ - GatherEffect, # noqa: F821 - legacy removed API reference is intentionally preserved - RaceEffect, - SpawnEffect, # noqa: F821 - legacy removed API reference is intentionally preserved - CompletePromiseEffect, - FailPromiseEffect, - ]: - assert not hasattr(cls, "to_generator"), ( - f"{cls.__name__} should be EffectValue-only and not expose to_generator" - ) - - # The gather test above verifies runtime classify behavior directly. - - -class TestR9AsyncRunSemantics: - """API-12: async_run must be truly async.""" - - def test_async_run_yields_to_event_loop(self): - """R9 API-12: async_run must yield control to the event loop.""" - import asyncio - - from doeff_vm import async_run - - @do - def pure_generator_program() -> Program[int]: - return 42 - yield - - execution_order = [] - - async def background_task(): - execution_order.append("background_start") - await asyncio.sleep(0) - execution_order.append("background_end") - - async def main(): - execution_order.append("main_start") - bg = asyncio.create_task(background_task()) - result = await async_run(pure_generator_program()) - await bg - execution_order.append("main_end") - return result - - result = asyncio.run(main()) - assert result.is_ok() - assert result.value == 42 - # If async_run is truly async, background_task should have had a - # chance to run between main_start and main_end - assert "background_start" in execution_order, ( - "Background task never started — async_run may be blocking" - ) - - -# === SPEC-009 Gap TDD Tests === -# G1 (RunResult.result returns Ok/Err) and G3 (Modify returns new_value) -# Python integration tests deferred until run_with_result/put_store are wired. -# Rust-side fixes verified by Rust unit tests: -# - G3: vm::tests::test_s009_g3_modify_resumes_with_new_value (handler.rs) - - -## -- R13-I: Tag dispatch tests -------------------------------------------------- - - -def test_tag_attribute_accessible(): - """R13-I: tag attribute is accessible on DoCtrlBase and EffectBase instances.""" - import doeff_vm - - # TAG constants exist - assert hasattr(doeff_vm, "TAG_PURE") - assert hasattr(doeff_vm, "TAG_PERFORM") - assert hasattr(doeff_vm, "TAG_EFFECT") - assert hasattr(doeff_vm, "TAG_UNKNOWN") - - # Verify tag values - assert doeff_vm.TAG_PURE == 0 - assert doeff_vm.TAG_MAP == 2 - assert doeff_vm.TAG_FLAT_MAP == 3 - assert doeff_vm.TAG_WITH_HANDLER == 4 - assert doeff_vm.TAG_PERFORM == 5 - assert doeff_vm.TAG_RESUME == 6 - assert doeff_vm.TAG_TRANSFER == 7 - assert doeff_vm.TAG_DELEGATE == 8 - assert doeff_vm.TAG_GET_CONTINUATION == 9 - assert doeff_vm.TAG_GET_HANDLERS == 10 - assert doeff_vm.TAG_GET_CALL_STACK == 11 - assert doeff_vm.TAG_EVAL == 12 - assert doeff_vm.TAG_CREATE_CONTINUATION == 13 - assert doeff_vm.TAG_RESUME_CONTINUATION == 14 - assert doeff_vm.TAG_ASYNC_ESCAPE == 15 - assert doeff_vm.TAG_APPLY == 16 - assert doeff_vm.TAG_EXPAND == 17 - assert doeff_vm.TAG_DISCONTINUE == 22 - assert doeff_vm.TAG_EFFECT == 128 - assert doeff_vm.TAG_UNKNOWN == 255 - assert not hasattr(doeff_vm, "TAG_GET_TRACE") - - -def test_tag_on_concrete_instances(): - """R13-I: Concrete DoCtrl instances have correct tag values.""" - from doeff_vm import ( - TAG_APPLY, - TAG_EXPAND, - TAG_GET_CALL_STACK, - TAG_GET_HANDLERS, - TAG_PURE, - Apply, - Expand, - GetCallStack, - GetHandlers, - Pure, - ) - - meta = { - "function_name": "test_fn", - "source_file": __file__, - "source_line": 1, - } - - pure = Pure(42) - assert pure.tag == TAG_PURE - - gh = GetHandlers() - assert gh.tag == TAG_GET_HANDLERS - - apply = Apply(lambda x: x, [1], {}, meta) - assert apply.tag == TAG_APPLY - - expand = Expand(lambda x: x, [1], {}, meta) - assert expand.tag == TAG_EXPAND - - gcs = GetCallStack() - assert gcs.tag == TAG_GET_CALL_STACK - - -def test_tag_on_effect_base(): - """R13-I: EffectBase subclasses have Effect tag.""" - from doeff_vm import TAG_EFFECT - - from doeff import Get, Put - - get = Get("key") - assert get.tag == TAG_EFFECT - - put = Put("key", 42) - assert put.tag == TAG_EFFECT - - -def test_doeff_generator_fn_call_wraps_generator_with_factory() -> None: - import doeff_vm - - def make_gen(): - yield 1 - - def get_frame(g): - return g.gi_frame - - factory = doeff_vm.DoeffGeneratorFn( - callable=make_gen, - function_name="make_gen", - source_file=__file__, - source_line=1, - get_frame=get_frame, - ) - wrapped = factory() - - assert isinstance(wrapped, doeff_vm.DoeffGenerator) - assert wrapped.factory is factory - assert wrapped.function_name == "make_gen" - - -def test_tag_dispatch_does_not_break_existing_programs(): - """R13-I: Tag dispatch is transparent — existing programs still work.""" - from doeff_vm import PyVM - - vm = PyVM() - - # Pure return (no effects) works through tag dispatch - @do - def pure_prog() -> Program[int]: - return 99 - yield - - result = vm.run(pure_prog()) - assert result == 99 - - -## -- ISSUE-VM-001: Transfer abandonment + forwarding decomposition ------------ - - -class SecondCustomEffect(doeff_vm.EffectBase): - """A second custom effect for substitution tests.""" - - def __init__(self, value): - self.value = value - - -def test_transfer_abandons_handler(): - """ISSUE-VM-001: After yield Transfer(k, v), handler code must NOT execute. - - Transfer is a tail-call: control goes to the continuation and the handler - is abandoned. Any code after `yield Transfer(...)` must never run. - """ - from doeff_vm import ( - Delegate as VMDelegate, - ) - from doeff_vm import ( - Perform as VMPerform, - ) - from doeff_vm import ( - PyVM, - ) - from doeff_vm import ( - Transfer as VMTransfer, - ) - from doeff_vm import ( - WithHandler as VMWithHandler, - ) - - vm = PyVM() - handler_continued = {"ran": False} - - @do - def handler(effect: Effect, k): - if isinstance(effect, CustomEffect): - yield VMTransfer(k, effect.value * 10) - # This line must NEVER execute after Transfer - handler_continued["ran"] = True - yield VMDelegate() - - @do - def body() -> Program[int]: - result = yield CustomEffect(7) - return result - - @do - def main() -> Program[int]: - result = yield VMWithHandler(handler, VMPerform(CustomEffect(7))) - return result - - # body yields CustomEffect(7), handler transfers k with 70 - # Transfer abandons the handler — handler_continued["ran"] must stay False - result = vm.run(main()) - assert result == 70 - assert handler_continued["ran"] is False, ( - "Handler code after Transfer was executed — Transfer should abandon handler" - ) - - -def test_delegate_substitution_via_reperform_and_resume(): - """ISSUE-VM-001: substitute via re-perform, then resume continuation. - - Inner handler receives CustomEffect, performs SecondCustomEffect directly, - then resumes the original continuation with the outer handler's result. - """ - from doeff_vm import ( - Delegate as VMDelegate, - ) - from doeff_vm import ( - Perform as VMPerform, - ) - from doeff_vm import ( - PyVM, - ) - from doeff_vm import ( - Resume as VMResume, - ) - from doeff_vm import ( - WithHandler as VMWithHandler, - ) - - vm = PyVM() - - @do - def outer_handler(effect: Effect, k): - if isinstance(effect, SecondCustomEffect): - result = yield VMResume(k, f"outer_got:{effect.value}") - return result - yield VMDelegate() - - @do - def inner_handler(effect: Effect, k): - if isinstance(effect, CustomEffect): - substituted = yield SecondCustomEffect(effect.value + 100) - return (yield VMResume(k, substituted)) - yield VMDelegate() - - @do - def body() -> Program[str]: - result = yield CustomEffect(42) - return result - - @do - def main() -> Program[str]: - inner = VMWithHandler(inner_handler, VMPerform(CustomEffect(42))) - result = yield VMWithHandler(outer_handler, inner) - return result - - result = vm.run(main()) - assert result in ("outer_got:142", None) - - -def test_delegate_and_pass_reject_effect_arguments(): - from doeff_vm import Delegate as VMDelegate - from doeff_vm import Pass as VMPass - - with pytest.raises(TypeError): - VMDelegate(CustomEffect(1)) - - with pytest.raises(TypeError): - VMPass(CustomEffect(1)) - - -## -- ISSUE-VM-003: Scheduler Spawn/Gather/Race coverage ----------------------- - - -def test_scheduler_spawn_creates_task(): - """ISSUE-VM-003: SpawnEffect should create a task via the scheduler handler.""" - from doeff_vm import PyVM - - vm = PyVM() - - @do - def child() -> Program[int]: - return 99 - yield - - @do - def body() -> Program[dict]: - task = yield SpawnEffect(program=child(), handlers=[]) # noqa: F821 - legacy removed API reference is intentionally preserved - return task - - result = vm.run(_with_handlers(body(), doeff_vm.scheduler)) - # SpawnEffect should return a TaskHandle dict - assert isinstance(result, dict) - assert result["type"] == "Task" - assert "task_id" in result - - -def test_scheduler_gather_recognized_by_handler(): - """ISSUE-VM-003: GatherEffect is recognized by the scheduler handler. + assert run(doeff_vm.WithHandler(throwing_handler, body())) == "boom" - Verifies that GatherEffect is classified correctly and dispatched to the - scheduler handler (not rejected as UnhandledEffect or TypeError). - The full spawn→gather→collect flow requires the async scheduler loop. - """ - from doeff_vm import PyVM - - vm = PyVM() - - @do - def body() -> Program[object]: - # GatherEffect with empty items — scheduler should handle it - result = yield GatherEffect(items=[]) # noqa: F821 - legacy removed API reference is intentionally preserved - return result - - result = vm.run_with_result(_with_handlers(body(), doeff_vm.scheduler)) - # Should NOT be TypeError/UnhandledEffect — scheduler handles it - if result.is_err(): - err_str = str(result.result) - assert "UnhandledEffect" not in err_str, ( - f"GatherEffect not recognized by scheduler: {err_str}" - ) - assert "TypeError" not in err_str, f"GatherEffect misclassified: {err_str}" - - -def test_scheduler_race_recognized_by_handler(): - """ISSUE-VM-003: RaceEffect is recognized by the scheduler handler. - - Verifies that RaceEffect is classified correctly and dispatched to the - scheduler handler (not rejected as UnhandledEffect or TypeError). - """ - from doeff.effects.race import RaceEffect - from doeff_vm import PyVM - - vm = PyVM() - - @do - def body() -> Program[object]: - # RaceEffect with empty futures — scheduler should handle it - result = yield RaceEffect(futures=[]) - return result - - result = vm.run_with_result(_with_handlers(body(), doeff_vm.scheduler)) - # Should NOT be TypeError/UnhandledEffect — scheduler handles it - if result.is_err(): - err_str = str(result.result) - assert "UnhandledEffect" not in err_str, ( - f"RaceEffect not recognized by scheduler: {err_str}" - ) - assert "TypeError" not in err_str, f"RaceEffect misclassified: {err_str}" - - -def test_flatmap_rejects_non_doexpr_binder_return(): - """ISSUE-VM-004: FlatMap binder must return a DoExpr (Program/Effect/DoCtrl). - - SPEC-TYPES-001 CP-07: If the binder returns a non-DoExpr value (e.g. a - plain int), the VM should raise a runtime TypeError rather than silently - propagating the raw value. - """ - from doeff_vm import PyVM - - vm = PyVM() - - def bad_binder(x): - return x * 2 # returns 20, a plain int — NOT a DoExpr +def test_unhandled_effect_raises_typed_exception() -> None: @do - def body() -> Program[int]: - result = yield Program.flat_map(Program.pure(10), bad_binder) - return result - - try: - result = vm.run(body()) - # If run() returns 20 (the raw int), that means the binder's - # non-DoExpr return was silently propagated — spec violation. - assert result != 20, ( - "FlatMap binder returned a plain int and it was silently propagated; " - "expected TypeError per TYPES-001 CP-07" - ) - except TypeError as e: - # Good — the VM rejected the non-DoExpr return - assert "flat_map" in str(e).lower() or "DoExpr" in str(e), ( # noqa: PT017 - legacy assertion style is kept unchanged for baseline cleanup - f"TypeError raised but message doesn't mention flat_map/DoExpr: {e}" - ) - - -def test_flatmap_requires_explicit_binder_metadata() -> None: - """VM-PROTO-005: FlatMap constructor must reject missing binder_meta.""" - from doeff_vm import FlatMap, Pure + def body(): + return (yield CustomEffect(1)) - def binder(x): - return Pure(x + 1) + with pytest.raises(doeff_vm.UnhandledEffect, match="unhandled effect"): + run(body()) - with pytest.raises(TypeError, match="binder_meta is required"): - FlatMap(Pure(1), binder) +def test_pyvm_rejects_raw_generator_top_level() -> None: + def raw_generator(): + yield doeff_vm.Pure(1) -def test_call_resolves_doexpr_args(): - """Call DoCtrl resolves DoExpr positional args before invoking the callable.""" - from doeff_vm import run as vm_run + with pytest.raises(TypeError, match="expected DoExpr or EffectBase"): + doeff_vm.PyVM().run(raw_generator()) - @do - def adder(a, b): - return a + b +def test_yielded_plain_value_fails_loudly() -> None: @do def body(): - result = yield adder(Pure(10), Pure(20)) - return result + yield 42 - result = vm_run(body()) - assert result.is_ok(), f"Call arg resolution failed: {result.result}" - assert result.value == 30 + with pytest.raises(RuntimeError, match="expected DoExpr or EffectBase"): + doeff_vm.PyVM().run(body()) -def test_call_with_mixed_value_and_expr_args(): - """Call DoCtrl handles mixed literal and DoExpr arguments.""" - from doeff_vm import run as vm_run +def test_current_state_reader_writer_handlers_use_installer_api() -> None: + @do + def state_body(): + value = yield Get("counter") + yield Put("counter", value + 1) + return (yield Get("counter")) @do - def mixer(a, b, c): - return a + b + c + def reader_body(): + return (yield Ask("name")) @do - def body(): - result = yield mixer(42, Pure(10), Pure(20)) - return result + def writer_body(): + yield Tell("starting") + yield Tell("done") + return "ok" - result = vm_run(body()) - assert result.is_ok(), f"Mixed Call argument resolution failed: {result.result}" - assert result.value == 72 + writer_handler = writer() + assert run(state(initial={"counter": 1})(state_body())) == 2 + assert run(reader(env={"name": "Ada"})(reader_body())) == "Ada" + assert run(writer_handler(writer_body())) == "ok" + assert writer_handler.log == ["starting", "done"] -def test_call_with_all_value_args(): - """Call DoCtrl still works when all args are already plain values.""" - from doeff_vm import run as vm_run +def test_scheduled_spawn_gather_runs_via_doeff_facade() -> None: @do - def adder(a, b): - return a + b + def child(value: int): + return value * 2 @do def body(): - result = yield adder(10, 20) - return result - - result = vm_run(body()) - assert result.is_ok(), f"All-value Call invocation failed: {result.result}" - assert result.value == 30 + left = yield Spawn(child(2)) + right = yield Spawn(child(3)) + return list((yield Gather(left, right))) + assert run(scheduled(body())) == [4, 6] -def test_call_resolves_nested_effectful_args_left_to_right(): - """Nested Call DoCtrl args resolve effectful expressions in deterministic order.""" - from doeff_vm import run as vm_run - - @do - def inner(v): - return v + 1 - - @do - def outer(v): - return v * 3 - - @do - def body(): - value = yield outer(inner(Ask("key"))) - return value - result = vm_run(doeff_vm.WithHandler(doeff_vm.reader, body()), env={"key": 4}) - assert result.is_ok(), f"Nested Call DoCtrl resolution failed: {result.result}" - assert result.value == 15 +def test_vm_live_counts_return_to_baseline_after_run() -> None: + before = doeff_vm.vm_live_counts() + assert run(simple_program()) == 42 -if __name__ == "__main__": - pytest.main([__file__, "-v"]) + assert doeff_vm.vm_live_counts() == before diff --git a/packages/doeff-vm/tests/test_pyvm_no_enum_catchall.py b/packages/doeff-vm/tests/test_pyvm_no_enum_catchall.py index c87c900f4..040bc3df4 100644 --- a/packages/doeff-vm/tests/test_pyvm_no_enum_catchall.py +++ b/packages/doeff-vm/tests/test_pyvm_no_enum_catchall.py @@ -1,45 +1,40 @@ +"""Source guards for exhaustive matches in the Python VM bridge.""" + from pathlib import Path def _runtime_source() -> str: source_path = Path(__file__).resolve().parents[1] / "src" / "pyvm.rs" - src = source_path.read_text(encoding="utf-8") - return src.split("#[cfg(test)]", 1)[0] - - -def _between(src: str, start: str, end: str) -> str: - return src.split(start, 1)[1].split(end, 1)[0] + return source_path.read_text(encoding="utf-8").split("#[cfg(test)]", 1)[0] -def test_pyvm_runtime_has_no_enum_catchall_match_arms() -> None: - runtime_src = _runtime_source() +def _between(source: str, start: str, end: str) -> str: + assert start in source, f"start marker not found: {start}" + assert end in source, f"end marker not found: {end}" + return source.split(start, 1)[1].split(end, 1)[0] - vmerror_fn = _between( - runtime_src, - "fn vmerror_to_pyerr_with_traceback_data", - "fn vmerror_to_pyerr", - ) - assert "_ =>" not in vmerror_fn - - pending_fn = _between(runtime_src, "fn pending_generator", "fn step_generator") - assert "_ =>" not in pending_fn - value_fn = _between( - runtime_src, - "fn value_to_runtime_pyobject", - "fn call_metadata_to_dict", +def test_pyvm_runtime_has_no_catchall_match_arms() -> None: + runtime_source = _runtime_source() + guarded_sections = ( + _between(runtime_source, "fn convert_vm_error(", "fn make_unhandled_effect_error"), + _between(runtime_source, "fn describe_effect(", "fn step_loop("), + _between(runtime_source, "fn step_loop(", "// classify_program"), ) - assert "_ =>" not in value_fn + for section in guarded_sections: + assert "_ =>" not in section + assert "other =>" not in section -def test_pyvm_runtime_explicitly_mentions_all_target_enum_variants() -> None: - runtime_src = _runtime_source() - vmerror_fn = _between( - runtime_src, - "fn vmerror_to_pyerr_with_traceback_data", - "fn vmerror_to_pyerr", +def test_pyvm_error_conversion_explicitly_mentions_all_vmerror_variants() -> None: + runtime_source = _runtime_source() + convert_vm_error = _between( + runtime_source, + "fn convert_vm_error(", + "fn make_unhandled_effect_error", ) + for variant in ( "VMError::OneShotViolation", "VMError::UnhandledEffect", @@ -52,42 +47,24 @@ def test_pyvm_runtime_explicitly_mentions_all_target_enum_variants() -> None: "VMError::TypeError", "VMError::UncaughtException", ): - assert variant in vmerror_fn + assert variant in convert_vm_error - pending_fn = _between(runtime_src, "fn pending_generator", "fn step_generator") - for variant in ( - "PendingPython::EvalExpr", - "PendingPython::CallFuncReturn", - "PendingPython::StepUserGenerator", - "PendingPython::ExpandReturn", - "PendingPython::RustProgramContinuation", - "PendingPython::AsyncEscape", - "None =>", - ): - assert variant in pending_fn - value_fn = _between( - runtime_src, - "fn value_to_runtime_pyobject", - "fn call_metadata_to_dict", - ) +def test_pyvm_external_call_driver_names_all_non_callable_value_variants() -> None: + runtime_source = _runtime_source() + step_loop = _between(runtime_source, "fn step_loop(", "// classify_program") + + assert "Value::Callable(callable)" in step_loop for variant in ( - "Value::Python", "Value::Unit", "Value::Int", - "Value::String", "Value::Bool", + "Value::String", "Value::None", + "Value::Stream", "Value::Continuation", - "Value::Handlers", - "Value::Kleisli", - "Value::Task", - "Value::Promise", - "Value::ExternalPromise", - "Value::CallStack", - "Value::Trace", - "Value::Traceback", - "Value::ActiveChain", + "Value::Var", "Value::List", + "Value::Opaque", ): - assert variant in value_fn + assert variant in step_loop diff --git a/pyproject.toml b/pyproject.toml index 825bebfba..62eaa380e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ timeout_method = "thread" timeout_func_only = false asyncio_mode = "strict" asyncio_default_fixture_loop_scope = "function" -testpaths = ["tests"] +testpaths = ["tests", "packages/doeff-vm/tests"] markers = [ "e2e: tests that exercise external integrations and may require real API keys", "requires_opencode: tests that require a running OpenCode server",