Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/doeff-vm/doeff_vm/__init__.pyi
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from __future__ import annotations

from typing import Any, Generic, SupportsIndex, TypeVar

_T = TypeVar("_T")
Expand All @@ -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 ---

Expand Down Expand Up @@ -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]: ...
50 changes: 47 additions & 3 deletions packages/doeff-vm/src/pyvm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ impl PyVM {
context: Option<Vec<Value>>,
) -> 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
Expand Down Expand Up @@ -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))
}
}
}

Expand Down Expand Up @@ -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(<anonymous>)".to_string()),
Value::Stream(_) => "Stream(<opaque>)".to_string(),
Value::Continuation(_) => "Continuation(<detached>)".to_string(),
Value::Var(var) => format!("Var({:?})", var),
Value::List(items) => format!("List(len={})", items.len()),
}
}

Expand Down Expand Up @@ -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",
));
Expand Down
161 changes: 37 additions & 124 deletions packages/doeff-vm/tests/test_memory_stats.py
Original file line number Diff line number Diff line change
@@ -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
99 changes: 60 additions & 39 deletions packages/doeff-vm/tests/test_package_exports.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading