From 9ca8579bcb4a7236c0c1a0bc8c2fe26e0c9a6a03 Mon Sep 17 00:00:00 2001 From: RAX4096 Date: Tue, 28 Jul 2026 16:40:55 +0800 Subject: [PATCH 1/8] Feat(performance): weakcache and __slots__ --- pyproject.toml | 2 +- src/amrita_sense/hook/fun_typing.py | 12 +-- src/amrita_sense/hook/matcher.py | 24 ++++- src/amrita_sense/weakcache.py | 67 ++++++++++---- tests/test_weakcache.py | 138 ++++++++++++++++++++++++++++ 5 files changed, 212 insertions(+), 31 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2801815..bba5eb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "amrita-sense" -version = "0.5.0" +version = "0.5.1" description = "Next-Gen event stream and workflow engine." readme = "README.md" requires-python = ">=3.10, <3.16" diff --git a/src/amrita_sense/hook/fun_typing.py b/src/amrita_sense/hook/fun_typing.py index fe11be2..2083afa 100644 --- a/src/amrita_sense/hook/fun_typing.py +++ b/src/amrita_sense/hook/fun_typing.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import inspect from collections.abc import Awaitable, Callable from dataclasses import dataclass @@ -7,8 +9,6 @@ if TYPE_CHECKING: from .matcher import DependsFactory, Matcher -else: - DependsFactory = None class _Empty: @@ -28,7 +28,7 @@ class ParamDescriptor(TypedDict): class DependencyMeta(TypedDict): params: dict[str, ParamDescriptor] # arg name -> ParamDescriptor - factory_map: dict[str, "DependsFactory"] # Arg name -> DependsFactory + factory_map: dict[str, DependsFactory] # Arg name -> DependsFactory @dataclass @@ -37,7 +37,7 @@ class FunctionData: signature: DependencyMeta = Field() frame: FrameType = Field() priority: int = Field() - matcher: "Matcher" = Field() + matcher: Matcher = Field() def sign_func(func: Callable[..., Any]): @@ -62,8 +62,8 @@ def sign_func(func: Callable[..., Any]): if anno is None: raise ValueError( f"Cannot resolve annotation {anno} for parameter {name}," - + " please disable `__future__.annotations` and use a normal type hint instead." - + "Make sure the import is not only in `TYPE_CHECKING` blocks." + + " please disable `__future__.annotations` and use a normal type hint instead.\n" + + "Make sure the import block is in the global scope instead of only in `if TYPE_CHECKING:...` blocks" ) types[name] = ParamDescriptor(type_hint=anno, kind=kind, default=default) diff --git a/src/amrita_sense/hook/matcher.py b/src/amrita_sense/hook/matcher.py index 512de22..5ce0c38 100644 --- a/src/amrita_sense/hook/matcher.py +++ b/src/amrita_sense/hook/matcher.py @@ -146,9 +146,19 @@ class DependsFactory(Generic[T]): _depency_func: Callable[..., T | Awaitable[T]] _sign: DependencyMeta + __cacheable: bool - def __init__(self, depency: Callable[..., T | Awaitable[T]]): + __slots__ = ("__cacheable", "_depency_func", "_sign") + + @property + def cacheable(self) -> bool: + return self.__cacheable + + def __init__( + self, depency: Callable[..., T | Awaitable[T]], cacheable: bool = False + ): self._depency_func = depency + self.__cacheable = cacheable self._sign = sign_func(self._depency_func) async def resolve(self, *args, **kwargs) -> T | None: @@ -179,11 +189,18 @@ async def resolve(self, *args, **kwargs) -> T | None: return rs -def Depends(dependency: Callable[..., T | Awaitable[T]]) -> Any: +def Depends( + dependency: Callable[..., T | Awaitable[T]], cacheable: bool = False +) -> Any: """Dependency injection decorator. + *NOTE*: Cacheing is only available for workflows not event matchers. + + **IMPORTANT**: For database sessions(or ORM frameworks like SQLAlchemy), DI-Cache may cause the leaks of database connections. + Args: dependency: The dependency function to inject + cacheable (bool, optional): Whether to cache the resolved dependency. Defaults to False. Returns: DependsFactory: A factory for dependency injection @@ -201,7 +218,7 @@ async def a_function_with_dependencies( # If DependendsFactory's return is None, this function won't be called. ``` """ - return DependsFactory[T](dependency) + return DependsFactory[T](dependency, cacheable) class FailedEnum(Enum): @@ -222,7 +239,6 @@ class MatcherFactory: _lock_pool: ClassVar[WeakValueLRUCache[str, aiologic.Lock]] = WeakValueLRUCache( capacity=1024, loose_mode=True ) - @classmethod def _repo_lock(cls, category: str) -> aiologic.Lock: if (lock := cls._lock_pool.get(category)) is None: diff --git a/src/amrita_sense/weakcache.py b/src/amrita_sense/weakcache.py index 8b2a71d..4054164 100644 --- a/src/amrita_sense/weakcache.py +++ b/src/amrita_sense/weakcache.py @@ -14,7 +14,30 @@ class WeakValueLRUCache(Generic[K, V]): """Weak reference LRU cache implementation. - Always used for locks pool. + Typical usage as a lock pool:: + + pool: WeakValueLRUCache[str, threading.Lock] = WeakValueLRUCache( + capacity=64, loose_mode=True + ) + + def get_lock(key: str) -> threading.Lock: + lock: threading.Lock | None = pool.get(key) + if lock is None: + lock = threading.Lock() + pool[key] = lock + return lock + + The weak-value semantics ensure that locks are automatically cleaned up + when no external strong references remain, avoiding unbounded growth. + + .. warning:: + + This cache is **not** suitable for persistent storage — values + disappear as soon as the last strong reference is lost. Likewise, + **immutable objects** (strings, integers, tuples, etc.) cannot be + meaningfully stored because they are either interned, cached by the + interpreter, or lack stable external references, making weak + references to them immediately expire. """ __marker = object() @@ -124,6 +147,15 @@ def get(self, key: K, default: T = None) -> V | T: def put(self, key: K, value: V) -> None: """Put a value into cache. + LRU eviction with weak-reference awareness: + - If the key already exists, remove the old entry first (no eviction). + - Otherwise, if adding would exceed `capacity`, scan from oldest to newest + up to ``len(self._cache)`` steps: + * **loose_mode** + alive → ``move_to_end`` (skip, keep it). + * Otherwise → ``pop`` (evict expired or force-evict in normal mode). + - Eviction stops once enough slots are freed. The bounded for-loop + prevents infinite looping when loose_mode keeps all entries alive. + Args: key (K): Key in this cache. value (V): Value in this cache. @@ -133,40 +165,35 @@ def put(self, key: K, value: V) -> None: raise ValueError("Cannot store None value in WeakValueLRUCache") with self._lock: weak_ref: weakref.ReferenceType[V] = weakref.ref(value) - capa = self._capacity + capa: int = self._capacity if key in self._cache: self._cache.pop(key) - else: - should_expire_count = max(0, (len(self._cache) + 1) - capa) - collected = 0 - for _ in range(len(self._cache)): - if collected >= should_expire_count: - break + elif should_expire_count := max(0, (len(self._cache) + 1) - capa): + collected: int = 0 + for _ in range(len(self._cache)): # limit max expiring steps oldest_key: K = next(iter(self._cache)) - oldest_ref = self._cache[oldest_key] - if oldest_ref() is None or not self._loose_mode: - collected += 1 - self._cache.pop(oldest_key) - elif self._loose_mode: + if self._loose_mode and self._cache[oldest_key](): self._cache.move_to_end(oldest_key) - + else: + self._cache.pop(oldest_key) + collected += 1 + if collected >= should_expire_count: + break self._cache[key] = weak_ref def expire(self, length: int | None = None) -> None: """Expire cache of given length Args: - length (int | None, optional): Length. Defaults to None. + length (int | None, optional): Length. Defaults to None (20% of cache size). """ with self._lock: if length is None: length = int(len(self._cache) * (1 / 5)) - keys_to_check = list(self._cache.keys())[: min(length, len(self._cache))] - expired_keys = [key for key in keys_to_check if self._cache[key]() is None] - - for key in expired_keys: - self._cache.pop(key, None) + for key in list(self._cache.keys())[: min(length, len(self._cache))]: + if self._cache[key]() is None: + self._cache.pop(key, None) def __getitem__(self, key: K) -> V: value = self.get(key) diff --git a/tests/test_weakcache.py b/tests/test_weakcache.py index d78a554..d5724ad 100644 --- a/tests/test_weakcache.py +++ b/tests/test_weakcache.py @@ -508,3 +508,141 @@ def test_put_existing_key_moves_to_end(self): assert cache.get("key2") is None # Should be evicted assert cache.get("key3") is obj3 assert cache.get("key4") is obj4 + + # ── Boundary / edge-case tests for put() eviction ────────────────── + + def test_put_loose_mode_all_alive_no_eviction(self): + """loose_mode=True, all refs alive: no eviction, cache exceeds capacity.""" + cache = WeakValueLRUCache(capacity=2, loose_mode=True) + objs = [TestObject(i) for i in range(5)] + for i, obj in enumerate(objs): + cache.put(f"k{i}", obj) + # All alive → none evicted, capacity exceeded + assert cache.size() == 5 + for i, obj in enumerate(objs): + assert cache.get(f"k{i}") is obj + + def test_put_loose_mode_expired_evicted_first(self): + """loose_mode=True with some expired refs: expired evicted first, then alive kept.""" + cache = WeakValueLRUCache(capacity=3, loose_mode=True) + + obj1 = TestObject("1") + obj2 = TestObject("2") + obj3 = TestObject("3") + cache.put("k1", obj1) + cache.put("k2", obj2) + cache.put("k3", obj3) + + # expire obj1 and obj3 + del obj1, obj3 + gc.collect() + + obj4 = TestObject("4") + cache.put("k4", obj4) + # should have evicted k1 and k3 (expired), kept k2 and k4 + assert cache.get("k1") is None + assert cache.get("k3") is None + assert cache.get("k2") is obj2 + assert cache.get("k4") is obj4 + + def test_put_loose_mode_no_dead_loop(self): + """loose_mode=True with all alive: for-range bounded, never infinite loop.""" + cache = WeakValueLRUCache(capacity=1, loose_mode=True) + objs = [TestObject(i) for i in range(100)] + for i, obj in enumerate(objs): + cache.put(f"k{i}", obj) + # If no dead loop, this finishes. Capacity is exceeded due to loose_mode. + assert cache.size() == 100 + + def test_put_normal_mode_all_expired_clears_all(self): + """Normal mode: when all old refs are expired, they should all be evicted.""" + cache = WeakValueLRUCache(capacity=2) + + obj1 = TestObject("1") + obj2 = TestObject("2") + cache.put("k1", obj1) + cache.put("k2", obj2) + + del obj1, obj2 + gc.collect() + + obj3 = TestObject("3") + cache.put("k3", obj3) + assert cache.get("k1") is None + assert cache.get("k2") is None + assert cache.get("k3") is obj3 + assert cache.size() == 1 + + def test_put_normal_mode_partial_expired(self): + """Normal mode: only expired entries are evicted; alive ones stay and count.""" + cache = WeakValueLRUCache(capacity=3) + + obj1 = TestObject("1") + obj2 = TestObject("2") + obj3 = TestObject("3") + cache.put("k1", obj1) + cache.put("k2", obj2) + cache.put("k3", obj3) + + # expire only the oldest (k1) + del obj1 + gc.collect() + + obj4 = TestObject("4") + cache.put("k4", obj4) + # k1 evicted (expired), k2/k3 alive → need 1 more to reach capacity=3 + assert cache.get("k1") is None + assert cache.get("k2") is obj2 + assert cache.get("k3") is obj3 + assert cache.get("k4") is obj4 + assert cache.size() == 3 + + def test_put_zero_capacity_normal_mode(self): + """capacity=0 in normal mode: for-loop range(0) skips, items accumulate.""" + cache = WeakValueLRUCache(capacity=0) + obj = TestObject("x") + cache.put("k", obj) + # range(0) → no eviction on first put, item stays + assert cache.get("k") is obj + assert cache.size() == 1 + + def test_put_zero_capacity_loose_mode(self): + """capacity=0 in loose_mode: still keeps items (loose permits overflow).""" + cache = WeakValueLRUCache(capacity=0, loose_mode=True) + obj = TestObject("x") + cache.put("k", obj) + assert cache.get("k") is obj + assert cache.size() == 1 + + def test_put_loose_mode_only_one_alive_rest_expired(self): + """loose_mode: only 1 alive among many expired → expired swept, alive kept with new.""" + cache = WeakValueLRUCache(capacity=2, loose_mode=True) + + alive = TestObject("alive") + cache.put("alive", alive) + + # fill with garbage-collectable items + for i in range(10): + tmp = TestObject(f"tmp{i}") + cache.put(f"tmp{i}", tmp) + gc.collect() + + new_obj = TestObject("new") + cache.put("new", new_obj) + # expired tmp* should be cleaned; alive & new should remain + assert cache.get("alive") is alive + assert cache.get("new") is new_obj + + def test_put_eviction_respects_should_expire_count(self): + """When capacity shrinks drastically, eviction removes exactly needed count.""" + cache = WeakValueLRUCache(capacity=5) + objs = [TestObject(i) for i in range(5)] + for i, obj in enumerate(objs): + cache.put(f"k{i}", obj) + + cache.resize(1) + new_obj = TestObject("new") + cache.put("new", new_obj) + # After resize to 1 and a new put, only 1 item should remain + assert cache.size() == 1 + assert cache.get("new") is new_obj From 8a1c4169e7a844723d73ae08d3c12ba1552d4ee4 Mon Sep 17 00:00:00 2001 From: RAX4096 Date: Tue, 28 Jul 2026 16:54:53 +0800 Subject: [PATCH 2/8] feat(workflow): add ava_args and ava_kwargs parameters to fork for merging arguments --- src/amrita_sense/runtime/workflow.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/amrita_sense/runtime/workflow.py b/src/amrita_sense/runtime/workflow.py index c7fdefe..641288e 100644 --- a/src/amrita_sense/runtime/workflow.py +++ b/src/amrita_sense/runtime/workflow.py @@ -312,6 +312,8 @@ def fork_interpreter( | None | object = UNSET, object_io: io_T | None = None, + ava_args: tuple | None = None, + ava_kwargs: dict[str, Any] | None = None, ) -> "WorkflowInterpreter[io_T]": """Fork a new sub-interpreter. @@ -333,6 +335,10 @@ def fork_interpreter( If None, reuses the parent interpreter's ``object_io`` instance. Safe sharing is guaranteed for ``SuspendObjectStream`` (CLCA-safe since v0.3.2); other ``io_T`` subtypes must ensure their own thread safety if passed explicitly. + ava_args (tuple | None): The arguments to be **merge** and passed to the sub-interpreter. + If None, it will use the same arguments as the parent. + ava_kwargs (dict[str, Any] | None): The keyword arguments to **merge** and be passed to the sub-interpreter. + If None, it will use the same keyword arguments as the parent. Returns: A new WorkflowInterpreter instance representing the sub-interpreter. @@ -349,13 +355,21 @@ def fork_interpreter( mdw = middleware if compose is None: compose = self._graph + if ava_args is not None: + args = ava_args + self.__ava_args[1:] # Exclude self from args + else: + args = self.__ava_args + if ava_kwargs is not None: + kwargs = self.__ava_kwargs | ava_kwargs + else: + kwargs = self.__ava_kwargs return WorkflowInterpreter[io_T]( compose, object_io=object_io or self.object_io, exception_ignored=self._exc_ignored, middleware=mdw, - extra_args=self.__ava_args[1:], # Exclude self from args - extra_kwargs=self.__ava_kwargs.copy(), + extra_args=args, + extra_kwargs=kwargs, parent_interpreter=self, ) From 44ee2394789b58a76cad98a79e59b9d251accbe9 Mon Sep 17 00:00:00 2001 From: RAX4096 Date: Tue, 28 Jul 2026 19:44:32 +0800 Subject: [PATCH 3/8] Feat: native instructions --- demos/16_subgraph_isolation.py | 4 +- demos/20_batch_run.py | 6 +- demos/21_native_if.py | 108 ++++++ docs/guide/advanced/locating_and_space.md | 77 ++++ src/amrita_sense/debugger/step.py | 8 +- src/amrita_sense/hook/matcher.py | 1 + src/amrita_sense/instructions/__init__.py | 4 + .../instructions/native/__init__.py | 15 + src/amrita_sense/instructions/native/_core.py | 305 +++++++++++++++ .../instructions/native/do_native.py | 99 +++++ .../instructions/native/if_native.py | 206 ++++++++++ .../instructions/native/while_native.py | 96 +++++ tests/test_func_block.py | 4 +- tests/test_native_instructions.py | 354 ++++++++++++++++++ tests/test_weakcache.py | 2 +- uv.lock | 2 +- 16 files changed, 1278 insertions(+), 13 deletions(-) create mode 100644 demos/21_native_if.py create mode 100644 src/amrita_sense/instructions/native/__init__.py create mode 100644 src/amrita_sense/instructions/native/_core.py create mode 100644 src/amrita_sense/instructions/native/do_native.py create mode 100644 src/amrita_sense/instructions/native/if_native.py create mode 100644 src/amrita_sense/instructions/native/while_native.py create mode 100644 tests/test_native_instructions.py diff --git a/demos/16_subgraph_isolation.py b/demos/16_subgraph_isolation.py index b2700b7..f9f47c5 100644 --- a/demos/16_subgraph_isolation.py +++ b/demos/16_subgraph_isolation.py @@ -12,7 +12,7 @@ from amrita_sense import ALIAS, NOP, Node, WorkflowInterpreter -# --- Sub-workflow --- +### Sub-workflow ### @Node() @@ -33,7 +33,7 @@ async def sub_step2() -> None: sub_comp = sub_start >> sub_step1 >> sub_step2 >> ALIAS(NOP, "done") -# --- Main node --- +### Main node ### @Node() diff --git a/demos/20_batch_run.py b/demos/20_batch_run.py index e4abad5..a85d986 100644 --- a/demos/20_batch_run.py +++ b/demos/20_batch_run.py @@ -9,7 +9,7 @@ from amrita_sense import Node, WorkflowInterpreter from amrita_sense.instructions.batch import BATCH_RUN -# --- Parallel bare nodes --- +### Parallel bare nodes ### @Node() @@ -30,7 +30,7 @@ async def fetch_products() -> None: print(" [products] fetched") -# --- Parallel subgraphs --- +### Parallel subgraphs ### @Node() @@ -53,7 +53,7 @@ async def transform() -> None: print(" [transform] done") -# --- fail_fast demo --- +### fail_fast demo ### @Node() diff --git a/demos/21_native_if.py b/demos/21_native_if.py new file mode 100644 index 0000000..52ed718 --- /dev/null +++ b/demos/21_native_if.py @@ -0,0 +1,108 @@ +"""21_native_if.py — NATIVE_IF / NATIVE_WHILE / NATIVE_DO demo. + +Usage: + python demos/21_native_if.py +""" + +import asyncio + +from amrita_sense import Node, WorkflowInterpreter +from amrita_sense.instructions.native import NATIVE_DO, NATIVE_IF, NATIVE_WHILE + + +@Node() +async def cond_true() -> bool: + print(" cond → True") + return True + + +@Node() +async def cond_false() -> bool: + print(" cond → False") + return False + + +@Node() +async def if_body() -> None: + print(" IF body executed") + + +@Node() +async def else_body() -> None: + print(" ELSE body executed") + + +@Node() +async def while_body() -> None: + print(" WHILE body iteration") + + +@Node() +async def do_body() -> None: + print(" DO body iteration") + + +async def main() -> None: + ### NATIVE_IF single node ### + print("=== NATIVE_IF (condition True) ===") + comp = NATIVE_IF(cond_true, if_body).ELSE(else_body) + await WorkflowInterpreter(comp.extract().render()).run() + + print("\n=== NATIVE_IF (condition False → ELSE) ===") + comp = NATIVE_IF(cond_false, if_body).ELSE(else_body) + await WorkflowInterpreter(comp.extract().render()).run() + + ### NATIVE_WHILE single node (1 iteration then false) ### + print("\n=== NATIVE_WHILE (1 iteration) ===") + counter = [0] + + @Node() + async def wh_cond() -> bool: + counter[0] += 1 + print(f" WHILE cond iteration {counter[0]}") + return counter[0] <= 1 + + @Node() + async def wh_body() -> None: + print(f" WHILE body iteration {counter[0]}") + + comp = NATIVE_WHILE(wh_cond).ACTION(wh_body) + await WorkflowInterpreter(comp.extract().render()).run() + + ### NATIVE_DO single node ### + print("\n=== NATIVE_DO (1 iteration) ===") + counter2 = [0] + + @Node() + async def do_cond() -> bool: + counter2[0] += 1 + print(f" DO cond iteration {counter2[0]}") + return counter2[0] < 1 # execute twice then stop + + @Node() + async def do_bd() -> None: + print(f" DO body iteration {counter2[0] + 1}") + + comp = NATIVE_DO(do_bd).WHILE(do_cond) + await WorkflowInterpreter(comp.extract().render()).run() + + ### NATIVE_IF with NodeCompose body (bubble) ### + print("\n=== NATIVE_IF bubble body ===") + from amrita_sense import NodeCompose + + @Node() + async def bubble_step_a() -> None: + print(" bubble step A") + + @Node() + async def bubble_step_b() -> None: + print(" bubble step B") + + comp = NATIVE_IF(cond_true, NodeCompose(bubble_step_a, bubble_step_b)) + await WorkflowInterpreter(comp.extract().render()).run() + + print("\n=== ALL DONE ===") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/guide/advanced/locating_and_space.md b/docs/guide/advanced/locating_and_space.md index ea6076a..09d10e7 100644 --- a/docs/guide/advanced/locating_and_space.md +++ b/docs/guide/advanced/locating_and_space.md @@ -38,3 +38,80 @@ labeled_action = ALIAS(action, "main_action") workflow = IF(some_condition, GOTO("main_action")) >> labeled_action ``` + +## 4.2.2 Runtime resolution: GOTO unconditional jump + +`GOTO` is the most direct control flow jump instruction in AmritaSense. At runtime, it looks up the target address from the alias table and performs a single pointer rewrite so the interpreter directly executes the target node. + +### Jump target validation + +`GOTO`'s `JumpNode` completes address resolution during compile-time `_post_compile`. This design allows errors to be caught **before runtime**: + +- When using an alias, `_post_compile` checks whether the alias exists in `alias2vector_map`. +- If the alias is not found, `JumpNode` lists all registered aliases, performs a fuzzy match with `difflib`, and raises an error with a "did you mean X?" suggestion. +- When using an absolute address (`list[int]`), the address is directly validated to ensure it points to a valid node. + +### Jump marker mechanism + +All jump methods (`jump_to`, `jump_near`, `jump_offset`, etc.) use the `@markup` decorator. Its purpose is to set the `_jump_marked` flag after a jump occurs, which prevents the interpreter from performing the normal pointer advance immediately after the jump. This ensures that **jumping and stepping are mutually exclusive** — execution is either explicitly moved or automatically advanced, never both. + +### Best practices + +1. **Prefer aliases over raw addresses**: `GOTO("target")` is more readable than `GOTO([1, 3, 5])`, and the alias table provides uniqueness validation at compile time. +2. **Do not use GOTO as a substitute for loops**: GOTO does not push a return address onto the call stack and is not suitable for cases requiring a return. Use `CALL` for subroutine calls that need to return — this will be covered in detail in [the next chapter](./child_node.md). +3. **Mind Bubble boundaries**: GOTO can jump across any nesting level, but overusing cross-level jumps makes control flow difficult to trace. Prefer `jump_near` within the same Bubble and `jump_to` for cross-Bubble jumps. + +## 4.2.3 CALL instruction: the entry point for subroutine invocation + +In addition to `GOTO`'s one-way jump, AmritaSense also provides the `CALL` instruction for **calling a subroutine and automatically returning after execution**. `CALL` shares the same alias-based addressing system as `GOTO` — both rely on `ALIAS` for symbol registration, and both complete address resolution and spelling correction during the compile-time `_post_compile` phase. + +### Core differences + +| Feature | GOTO | CALL | +| ----------------------- | -------------------------------------- | ---------------------------------------- | +| Saves return address? | No | Yes (pushes onto `_ret_addr_stack`) | +| After-execution behavior | Continues advancing from the target | Automatically pops the stack and returns | +| Use cases | One-way jumps, branch merging | Subroutine reuse, interrupt handling | + +> **Further reading** +> The complete `CALL` mechanism — including call stack management, the `ARCHIVED_NODES` storage structure, `SubprogramJumpNode` skip logic, and interrupt vector table implementation — will be covered in detail in [Chapter 4.3: Calling Subroutines](./child_node.md). + +## 4.2.4 Scope isolation: Bubble scopes and near addressing + +AmritaSense uses `PointerVector` to manage multi-level nested address spaces. Every node group wrapped in parentheses `()` forms an independent `NodeComposeRendered` after compilation, with its own internal `near` address space — this is a **Bubble**. + +### Address vector structure + +`PointerVector` is a variable-length integer array. Each dimension corresponds to a nesting level, and the value at that dimension is the absolute offset index within that level. For example, `[0, 2, 1]` means: top-level element 0 → descend into its sub-Bubble → element 2 inside that sub-Bubble → descend into its sub-Bubble → element 1. + +### Addressing modes + +AmritaSense provides three levels of addressing operations: + +| Method | Behavior | Use case | +| -------------- | ----------------------------------------------------- | ---------------------------------- | +| `near_to(n)` | Replace the current level's index with `n` | Jumps within the same Bubble | +| `offset(n)` | Add `n` to the current level's index | Relative jumps within a Bubble | +| `far_to(addr)` | Replace the entire pointer with a full address vector | Cross-Bubble jumps | + +### Scope isolation + +The core value of Bubble lies in **scope isolation**: + +- Each Bubble has its own `near` address space. Jump instructions within a Bubble are only valid inside that Bubble and do not affect the outer scope. +- When a Bubble finishes executing, the interpreter automatically pops the last dimension of the pointer vector and returns to the parent Bubble to continue. +- Exception and interrupt propagation also follows Bubble hierarchy — they can penetrate nesting, but internal state does not leak to the outer scope. + +### Application + +In complex conditional chains, different branches may each contain nested workflow structures. Bubble scoping ensures that jumps inside each branch are isolated from one another: + +```python +complex_flow = ( + IF(cond1, GOTO("exit")) + >> ALIAS(nested_workflow, "branch_a") + >> ALIAS(NOP, "exit") +) +``` + +`nested_workflow` is an independent Bubble; its internal GOTO, CALL, and other operations do not affect the address space of `complex_flow`. This isolation is key to AmritaSense's ability to safely handle deeply nested workflows — developers can bracket scope boundaries just like writing code, and the interpreter automatically manages entry and exit. diff --git a/src/amrita_sense/debugger/step.py b/src/amrita_sense/debugger/step.py index c4d1e94..61f9c27 100644 --- a/src/amrita_sense/debugger/step.py +++ b/src/amrita_sense/debugger/step.py @@ -32,20 +32,20 @@ async def _step_one(inter: WorkflowInterpreter[SuspendObjectStream]) -> None: """Execute exactly **one** node using direct ``_call()`` + lock.""" - # --- recover from panic (mimics run_step_by preamble) --- + ### recover from panic (mimics run_step_by preamble) ### if inter._panic_exc is not None: inter._panic_exc = None - # --- per-node suspension check --- + ### per-node suspension check ### await inter.object_io._wait_for_continue(PC_CHECKPOINT) - # --- initialise pointer if empty --- + ### initialise pointer if empty ### if not inter._pointer: if not inter.get_graph(): return inter._pointer.append(0) - # --- execute ONE node with lock --- + ### execute ONE node with lock ### try: async with inter._interpret_lock: if inter._middleware is not None: diff --git a/src/amrita_sense/hook/matcher.py b/src/amrita_sense/hook/matcher.py index 5ce0c38..cd85a43 100644 --- a/src/amrita_sense/hook/matcher.py +++ b/src/amrita_sense/hook/matcher.py @@ -239,6 +239,7 @@ class MatcherFactory: _lock_pool: ClassVar[WeakValueLRUCache[str, aiologic.Lock]] = WeakValueLRUCache( capacity=1024, loose_mode=True ) + @classmethod def _repo_lock(cls, category: str) -> aiologic.Lock: if (lock := cls._lock_pool.get(category)) is None: diff --git a/src/amrita_sense/instructions/__init__.py b/src/amrita_sense/instructions/__init__.py index fa45ce3..db9bafb 100644 --- a/src/amrita_sense/instructions/__init__.py +++ b/src/amrita_sense/instructions/__init__.py @@ -6,6 +6,7 @@ from .jump import GOTO from .loop.do_while import DO from .loop.while_clause import WHILE +from .native import NATIVE_DO, NATIVE_IF, NATIVE_WHILE from .ret2 import PUSH_AND_GOTO, PUSH_STACK, RET_FAR from .subprogram import ARCHIVED_NODES, CALL from .trigger_event import TRIGGER_EVENT @@ -24,6 +25,9 @@ "INTERRUPT", "INTERRUPT_INTO", "INTERRUPT_RET", + "NATIVE_DO", + "NATIVE_IF", + "NATIVE_WHILE", "NOP", "POP_CONTEXT", "PUSH_AND_GOTO", diff --git a/src/amrita_sense/instructions/native/__init__.py b/src/amrita_sense/instructions/native/__init__.py new file mode 100644 index 0000000..89e0093 --- /dev/null +++ b/src/amrita_sense/instructions/native/__init__.py @@ -0,0 +1,15 @@ +"""Native fast-path control-flow instructions. + +These replace the traditional ``call_sub``-based branching with the +lightweight ``PUSH / JMP / RET_FAR`` pattern, avoiding lock acquisition, +middleware invocation, and DI resolution on every branch entry. + +Single-node bodies stay on the fast ``call_offset`` path with zero +additional overhead. +""" + +from .do_native import NATIVE_DO +from .if_native import NATIVE_IF +from .while_native import NATIVE_WHILE + +__all__ = ("NATIVE_DO", "NATIVE_IF", "NATIVE_WHILE") diff --git a/src/amrita_sense/instructions/native/_core.py b/src/amrita_sense/instructions/native/_core.py new file mode 100644 index 0000000..606542c --- /dev/null +++ b/src/amrita_sense/instructions/native/_core.py @@ -0,0 +1,305 @@ +"""Native control-flow core nodes — lightweight jump+RET_FAR based branching. + +These nodes replace ``call_sub`` for branch bodies with the +``PUSH / JMP / RET_FAR`` pattern, avoiding lock acquisition, middleware +invocation, and DI resolution on every branch entry. Compile-time +``_is_single`` dispatch keeps single-node bodies on the fast ``call_offset`` +path with zero additional overhead. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from types import FrameType +from typing import Any, Literal, overload + +from amrita_sense.hook.fun_typing import DependencyMeta +from amrita_sense.node.core import BaseNode, NodeCompose +from amrita_sense.node.self_compile import SelfCompileInstruction +from amrita_sense.runtime.workflow import WorkflowInterpreter +from amrita_sense.types import PointerVector + + +@overload +def _classify_body( + payload: BaseNode, +) -> tuple[BaseNode, Literal[True]]: ... +@overload +def _classify_body( + payload: NodeCompose | SelfCompileInstruction, +) -> tuple[NodeCompose, Literal[False]]: ... +def _classify_body( + payload: BaseNode | NodeCompose | SelfCompileInstruction, +) -> tuple[BaseNode | NodeCompose, bool]: + """Classify *payload* as single-node or compose. + + Returns + ------- + (body, is_single) + ``is_single`` is ``True`` when *payload* is a ``BaseNode`` + (call_offset path). ``SelfCompileInstruction`` is extracted + to ``NodeCompose`` first. The compose itself is returned + as-is — ``render()`` will recursively expand nested layers. + """ + if isinstance(payload, BaseNode): + return payload, True + + if isinstance(payload, SelfCompileInstruction): + payload = payload.extract() + + if isinstance(payload, NodeCompose): + return payload, False + + raise TypeError(f"Unsupported payload type: {type(payload).__name__}") + + +# NativeIfJumpNode + + +class NativeIfJumpNode(BaseNode): + """IF-condition jump node for native fast-path branching. + + Layout (after ``extract()``): + + - ``[pos]`` NativeIfJumpNode + - ``[pos+1]`` condition_node — ``call_offset(1)`` + - ``[pos+2]`` do_slot (single node or bubble) + - ``[pos+3]`` NOP (merge / false target) + + **Single-node path:** ``CALL condi`` → True: ``CALL do; jmp_near ret`` | False: ``jmp false``. + + **Bubble path:** ``CALL condi`` → True: ``PUSH ret; jump_far_ptr([do_pos,0])`` | False: ``jmp false``. + """ + + tag: str + func: Callable[..., Any] + wrap_to_async: bool + address_able: bool + fun_frame: FrameType + fun_sign: DependencyMeta + + _condi_offset: int + _do_offset: int + _do_pos: int + _ret_pos: int + _false_pos: int + _is_single: bool + + __slots__ = ( + "_condi_offset", + "_do_offset", + "_do_pos", + "_false_pos", + "_is_single", + "_ret_pos", + "address_able", + "fun_frame", + "fun_sign", + "func", + "tag", + "wrap_to_async", + ) + + def __init__( + self, + condi_offset: int, + do_offset: int, + do_pos: int, + ret_pos: int, + false_pos: int, + is_single: bool, + ) -> None: + frame = inspect.currentframe() + if not frame: + raise RuntimeError("No frame found") + self._init( + self.__call__, tag=None, wrap_to_async=True, address_able=True, frame=frame + ) + self._condi_offset = condi_offset + self._do_offset = do_offset + self._do_pos = do_pos + self._ret_pos = ret_pos + self._false_pos = false_pos + self._is_single = is_single + + async def __call__(self, pc: WorkflowInterpreter) -> None: + if await pc.call_offset(self._condi_offset): + if self._is_single: + await pc.call_offset(self._do_offset) + pc.jump_near(self._ret_pos) + else: + parent = list(pc._pointer.base_addr[:-1]) + pc._ret_addr_stack.push(PointerVector([*parent, self._ret_pos])) + pc.jump_far_ptr([*parent, self._do_pos, 0]) + else: + pc.jump_near(self._false_pos) + + +# NativeWhileNode + + +class NativeWhileNode(BaseNode): + """WHILE-condition jump node for native fast-path loops. + + Layout: ``[pos]`` self, ``[pos+1]`` cond, ``[pos+2]`` body slot, ``[pos+3]`` NOP exit. + + The bubble variant pushes ``[pos]`` so that ``RET_FAR`` jumps back + to **this node** rather than past it, re‑evaluating the condition. + """ + + tag: str + func: Callable[..., Any] + wrap_to_async: bool + address_able: bool + fun_frame: FrameType + fun_sign: DependencyMeta + + _condi_offset: int + _body_offset: int + _body_pos: int + _self_pos: int + _exit_pos: int + _is_single: bool + + __slots__ = ( + "_body_offset", + "_body_pos", + "_condi_offset", + "_exit_pos", + "_is_single", + "_self_pos", + "address_able", + "fun_frame", + "fun_sign", + "func", + "tag", + "wrap_to_async", + ) + + def __init__( + self, + condi_offset: int, + body_offset: int, + body_pos: int, + self_pos: int, + exit_pos: int, + is_single: bool, + ) -> None: + frame = inspect.currentframe() + if not frame: + raise RuntimeError("No frame found") + self._init( + self.__call__, tag=None, wrap_to_async=True, address_able=True, frame=frame + ) + self._condi_offset = condi_offset + self._body_offset = body_offset + self._body_pos = body_pos + self._self_pos = self_pos + self._exit_pos = exit_pos + self._is_single = is_single + + async def __call__(self, pc: WorkflowInterpreter) -> None: + if await pc.call_offset(self._condi_offset): + if self._is_single: + await pc.call_offset(self._body_offset) + pc.jump_near(self._self_pos) + else: + parent = list(pc._pointer.base_addr[:-1]) + pc._ret_addr_stack.push(PointerVector([*parent, self._self_pos])) + pc.jump_far_ptr([*parent, self._body_pos, 0]) + else: + pc.jump_near(self._exit_pos) + + +# NativeDoWhileNode + + +class NativeDoWhileNode(BaseNode): + """DO‑WHILE back-edge node. Body is reached naturally (or via + ``NativeDoFastEnterNode`` for bubbles); this node only checks the + condition and loops back or falls through. + """ + + tag: str + func: Callable[..., Any] + wrap_to_async: bool + address_able: bool + fun_frame: FrameType + fun_sign: DependencyMeta + + _condi_offset: int + _loop_pos: int + _exit_pos: int + + __slots__ = ( + "_condi_offset", + "_exit_pos", + "_loop_pos", + "address_able", + "fun_frame", + "fun_sign", + "func", + "tag", + "wrap_to_async", + ) + + def __init__(self, condi_offset: int, loop_pos: int, exit_pos: int) -> None: + frame = inspect.currentframe() + if not frame: + raise RuntimeError("No frame found") + self._init( + self.__call__, tag=None, wrap_to_async=True, address_able=True, frame=frame + ) + self._condi_offset = condi_offset + self._loop_pos = loop_pos + self._exit_pos = exit_pos + + async def __call__(self, pc: WorkflowInterpreter) -> None: + if await pc.call_offset(self._condi_offset): + pc.jump_near(self._loop_pos) + # False: natural advance to exit + + +# NativeBubbleEnterNode (bubble entry helper for DO / ELSE) + + +class NativeBubbleEnterNode(BaseNode): + """Helper node that enters a body bubble. + + Used when a native instruction's body is a ``NodeCompose`` that must be + reached via a jump (DO-body, ELSE-body). The single‑node path reaches + the body naturally without this hop. + """ + + tag: str + func: Callable[..., Any] + wrap_to_async: bool + address_able: bool + fun_frame: FrameType + fun_sign: DependencyMeta + + _body_pos: int + + __slots__ = ( + "_body_pos", + "address_able", + "fun_frame", + "fun_sign", + "func", + "tag", + "wrap_to_async", + ) + + def __init__(self, body_pos: int) -> None: + frame = inspect.currentframe() + if not frame: + raise RuntimeError("No frame found") + self._init( + self.__call__, tag=None, wrap_to_async=False, address_able=True, frame=frame + ) + self._body_pos = body_pos + + def __call__(self, pc: WorkflowInterpreter) -> None: + parent = list(pc._pointer.base_addr[:-1]) + pc.jump_far_ptr([*parent, self._body_pos, 0]) diff --git a/src/amrita_sense/instructions/native/do_native.py b/src/amrita_sense/instructions/native/do_native.py new file mode 100644 index 0000000..9162cf0 --- /dev/null +++ b/src/amrita_sense/instructions/native/do_native.py @@ -0,0 +1,99 @@ +"""Native DO-WHILE — natural-flow fast-path loop. + +Usage:: + + NATIVE_DO(body).WHILE(condition) + +``body`` accepts: + +* ``BaseNode`` — single node, reached naturally (no extra hop). +* ``NodeCompose`` | ``SelfCompileInstruction`` — wrapped with a + ``NativeBubbleEnterNode`` so the engine enters the bubble, then + exits via the normal ``advance_pointer`` mechanism (no ``RET_FAR``). +""" + +from __future__ import annotations + +from typing_extensions import Self, override + +from amrita_sense.instructions.native._core import ( + NativeBubbleEnterNode, + NativeDoWhileNode, + _classify_body, +) +from amrita_sense.instructions.workfl_ctrl import NOP +from amrita_sense.node.core import BaseNode, Node, NodeCompose +from amrita_sense.node.self_compile import SelfCompileInstruction + + +class NativeDoClause(SelfCompileInstruction): + """Fast-path DO-WHILE loop. + + **Single-node layout:** ``[0]`` body, ``[1]`` NativeDoWhileNode, ``[2]`` cond, ``[3]`` NOP exit. + + **Bubble layout:** ``[0]`` NativeBubbleEnterNode, ``[1]`` body bubble, ``[2]`` NativeDoWhileNode, ``[3]`` cond, ``[4]`` NOP exit. + """ + + _body: BaseNode | NodeCompose | SelfCompileInstruction + _condition: Node[bool] | None + + __slots__ = ("_body", "_condition") + + def __init__(self, body: BaseNode | NodeCompose | SelfCompileInstruction) -> None: + self._body = body + self._condition = None + + ### Fluent API ### + + def WHILE(self, condition: Node[bool]) -> Self: + """Set the loop condition.""" + self._condition = condition + return self + + ### Compile ### + + @override + def extract(self) -> NodeCompose: + if self._condition is None: + raise RuntimeError("NATIVE_DO requires .WHILE(condition) before use") + + flat_cond, _ = _classify_body(self._condition) + body, is_single = _classify_body(self._body) + + if is_single: + return NodeCompose( + body, + NativeDoWhileNode( + condi_offset=1, + loop_pos=0, + exit_pos=3, + ), + flat_cond, + NOP, + ) + + # Bubble body: [0]=enter, [1]=flat_bubble, [2]=do_while, [3]=cond, [4]=NOP + assert isinstance(body, NodeCompose) + return NodeCompose( + NativeBubbleEnterNode(body_pos=1), + NodeCompose(*body._graph), + NativeDoWhileNode( + condi_offset=1, + loop_pos=0, + exit_pos=4, + ), + flat_cond, + NOP, + ) + + +def NATIVE_DO(body: BaseNode | NodeCompose | SelfCompileInstruction) -> NativeDoClause: + """Create a native fast-path DO-WHILE loop. + + Args: + body: Loop body — single ``BaseNode`` or a composition. + + Returns: + ``NativeDoClause`` — call ``.WHILE(condition)`` to set the condition. + """ + return NativeDoClause(body) diff --git a/src/amrita_sense/instructions/native/if_native.py b/src/amrita_sense/instructions/native/if_native.py new file mode 100644 index 0000000..04676fd --- /dev/null +++ b/src/amrita_sense/instructions/native/if_native.py @@ -0,0 +1,206 @@ +"""Native IF / ELIF / ELSE — jump+RET_FAR fast-path branching. + +Usage:: + + NATIVE_IF(cond, body) + NATIVE_IF(cond, body).ELIF(elif_cond, elif_body) + NATIVE_IF(cond, body).ELIF(elif_cond, elif_body).ELSE(else_body) + +``body`` / ``elif_body`` / ``else_body`` accept: + +* ``BaseNode`` — single node, executed via ``call_offset`` (no overhead vs vanilla). +* ``NodeCompose`` | ``SelfCompileInstruction`` — wrapped into a **bubble** + with automatic ``RET_FAR`` (IF/ELIF) or ``NativeBubbleEnterNode`` (ELSE). +""" + +from __future__ import annotations + +from typing_extensions import Self, override + +from amrita_sense.instructions.native._core import ( + NativeBubbleEnterNode, + NativeIfJumpNode, + _classify_body, +) +from amrita_sense.instructions.ret2 import RET_FAR +from amrita_sense.instructions.workfl_ctrl import NOP +from amrita_sense.node.core import BaseNode, Node, NodeCompose +from amrita_sense.node.self_compile import SelfCompileInstruction + + +class _ELIFClause: + """Internal holder for a single ELIF (condition, body) pair.""" + + __slots__ = ("body", "condition") + + def __init__( + self, + condition: Node[bool], + body: BaseNode | NodeCompose | SelfCompileInstruction, + ) -> None: + self.condition = condition + self.body = body + + +class NativeIfClause(SelfCompileInstruction): + """Fast-path IF with optional .ELIF / .ELSE chains.""" + + _condition: Node[bool] + _body: BaseNode | NodeCompose | SelfCompileInstruction + _elifs: list[_ELIFClause] + _else_body: BaseNode | NodeCompose | SelfCompileInstruction | None + + def __init__( + self, + condition: Node[bool], + body: BaseNode | NodeCompose | SelfCompileInstruction, + ) -> None: + self._condition = condition + self._body = body + self._elifs = [] + self._else_body = None + + ### Fluent API ### + + def ELIF( + self, + condition: Node[bool], + body: BaseNode | NodeCompose | SelfCompileInstruction, + ) -> Self: + """Append an ELIF branch.""" + self._elifs.append(_ELIFClause(condition, body)) + return self + + def ELSE( + self, + body: BaseNode | NodeCompose | SelfCompileInstruction, + ) -> Self: + """Append an ELSE branch.""" + self._else_body = body + return self + + ### Compile ### + + @override + def extract(self) -> NodeCompose: + """Build the flat native IF layout. + + 3 slots per IF/ELIF branch (condi_offset=1, do_offset=2) + optional + else slot + final NOP merge. All true branches RET_FAR-jump to the + merge point; false targets chain to the next ELIF, else, or merge. + """ + ### helpers ### + + def _wrap_if_body( + payload: BaseNode | NodeCompose | SelfCompileInstruction, + ) -> tuple[BaseNode | NodeCompose, bool]: + """IF/ELIF body: single node OR flat NodeCompose ending with RET_FAR.""" + body, is_single = _classify_body(payload) + if is_single: + return body, True + assert isinstance(body, NodeCompose) + return NodeCompose(*body._graph, RET_FAR()), False + + def _wrap_else( + payload: BaseNode | NodeCompose | SelfCompileInstruction, + ) -> tuple[BaseNode | NodeCompose, bool]: + """ELSE body: single node OR NodeCompose (no RET_FAR, natural flow).""" + return _classify_body(payload) + + ### build all pieces ### + + # Main IF + if_body, if_single = _wrap_if_body(self._body) + if_cond, _ = _classify_body(self._condition) + + # ELIFs + elif_specs: list[ + tuple[BaseNode | NodeCompose, BaseNode | NodeCompose, bool] + ] = [] # (cond, body_slot, is_single) + for elif_ in self._elifs: + ec, _ = _classify_body(elif_.condition) + eb, es = _wrap_if_body(elif_.body) + elif_specs.append((ec, eb, es)) + + # ELSE + has_else = self._else_body is not None + if has_else: + else_body, else_is_single = _wrap_else(self._else_body) # type: ignore[arg-type] + else: + else_body, else_is_single = NOP, True + + n_elif = len(elif_specs) + + # merge position (0-indexed, before building nodes) + # IF(3) + ELIFs(3*n) + else_slot_width + NOP(1) + else_slot_width = 1 if else_is_single else 2 # single node or [enter, bubble] + merge_pos = 3 + 3 * n_elif + else_slot_width + + nodes: list[BaseNode | NodeCompose] = [] + + ### Main IF chunk (positions 0-2) ### + false_next = 3 if (n_elif > 0 or has_else) else merge_pos + nodes.append( + NativeIfJumpNode( + condi_offset=1, + do_offset=2, + do_pos=2, + ret_pos=merge_pos, + false_pos=false_next, + is_single=if_single, + ) + ) + nodes.append(if_cond) + nodes.append(if_body) + + ### ELIF chunks ### + for i, (ec, eb, es) in enumerate(elif_specs): + base = 3 + 3 * i # chunk start + # false: next ELIF, else, or merge + if i + 1 < n_elif or has_else: + false_next = base + 3 + else: + false_next = merge_pos + nodes.append( + NativeIfJumpNode( + condi_offset=1, + do_offset=2, + do_pos=base + 2, + ret_pos=merge_pos, + false_pos=false_next, + is_single=es, + ) + ) + nodes.append(ec) + nodes.append(eb) + + # --- ELSE slot --- + if has_else and not else_is_single: + assert isinstance(else_body, NodeCompose) + else_pos = len(nodes) + nodes.append(NativeBubbleEnterNode(else_pos + 1)) + nodes.append(NodeCompose(*else_body._graph)) + else: + nodes.append(else_body) + + ### Merge ### + nodes.append(NOP) + + return NodeCompose(*nodes) + + +def NATIVE_IF( + condition: Node[bool], + body: BaseNode | NodeCompose | SelfCompileInstruction, +) -> NativeIfClause: + """Create a native fast-path IF clause. + + Args: + condition: Boolean condition node (called via ``call_offset``). + body: Branch body — single ``BaseNode`` or a composition + (wrapped as a bubble with automatic ``RET_FAR``). + + Returns: + ``NativeIfClause`` with fluent ``.ELIF`` / ``.ELSE`` chain support. + """ + return NativeIfClause(condition, body) diff --git a/src/amrita_sense/instructions/native/while_native.py b/src/amrita_sense/instructions/native/while_native.py new file mode 100644 index 0000000..a5c60fb --- /dev/null +++ b/src/amrita_sense/instructions/native/while_native.py @@ -0,0 +1,96 @@ +"""Native WHILE — jump+RET_FAR fast-path loop. + +Usage:: + + NATIVE_WHILE(condition).ACTION(body) + +``body`` accepts: + +* ``BaseNode`` — single node, ``call_offset`` + ``jump_near`` loop. +* ``NodeCompose`` | ``SelfCompileInstruction`` — wrapped into a **bubble** + with automatic ``RET_FAR`` that jumps back to re‑evaluate the condition. +""" + +from __future__ import annotations + +from typing_extensions import Self, override + +from amrita_sense.instructions.native._core import ( + NativeWhileNode, + _classify_body, +) +from amrita_sense.instructions.ret2 import RET_FAR +from amrita_sense.instructions.workfl_ctrl import NOP +from amrita_sense.node.core import BaseNode, Node, NodeCompose +from amrita_sense.node.self_compile import SelfCompileInstruction + + +class NativeWhileClause(SelfCompileInstruction): + """Fast-path WHILE loop. + + Layout: ``[0]`` self, ``[1]`` cond, ``[2]`` body slot, ``[3]`` NOP exit. + + The bubble variant pushes ``[0]`` so that ``RET_FAR`` re‑enters the + while node, re‑checking the condition. + """ + + _condition: Node[bool] + _body: BaseNode | NodeCompose | SelfCompileInstruction | None + + __slots__ = ("_body", "_condition") + + def __init__(self, condition: Node[bool]) -> None: + self._condition = condition + self._body = None + + ### Fluent API ### + + def ACTION(self, body: BaseNode | NodeCompose | SelfCompileInstruction) -> Self: + """Set the loop body.""" + if self._body is not None: + raise RuntimeError("ACTION already set on NativeWhileClause") + self._body = body + return self + + ### Compile ### + + @override + def extract(self) -> NodeCompose: + if self._body is None: + raise RuntimeError("NATIVE_WHILE requires .ACTION(body) before use") + + flat_cond, _ = _classify_body(self._condition) + body, is_single = _classify_body(self._body) + + if is_single: + body_slot: BaseNode | NodeCompose = body + else: + assert isinstance(body, NodeCompose) + body_slot = NodeCompose(*body._graph, RET_FAR()) + + # Layout: [0]=while_node, [1]=cond, [2]=body, [3]=exit + return NodeCompose( + NativeWhileNode( + condi_offset=1, + body_offset=2, + body_pos=2, + self_pos=0, + exit_pos=3, + is_single=is_single, + ), + flat_cond, + body_slot, + NOP, + ) + + +def NATIVE_WHILE(condition: Node[bool]) -> NativeWhileClause: + """Create a native fast-path WHILE loop. + + Args: + condition: Boolean condition node. + + Returns: + ``NativeWhileClause`` — call ``.ACTION(body)`` to set the loop body. + """ + return NativeWhileClause(condition) diff --git a/tests/test_func_block.py b/tests/test_func_block.py index 6acd4ae..298b7f6 100644 --- a/tests/test_func_block.py +++ b/tests/test_func_block.py @@ -37,7 +37,7 @@ async def main_end() -> None: pass -# -- Unit tests --------------------------------------------------------------- +### Unit tests ### def test_fun_block_returns_funcblock(): @@ -66,7 +66,7 @@ def test_fun_block_node_attrs(): assert fb.address_able is True -# -- Integration tests -------------------------------------------------------- +### Integration tests ### @pytest.mark.asyncio diff --git a/tests/test_native_instructions.py b/tests/test_native_instructions.py new file mode 100644 index 0000000..871b68e --- /dev/null +++ b/tests/test_native_instructions.py @@ -0,0 +1,354 @@ +"""Unit tests for native fast-path control-flow instructions.""" + +import asyncio + +from amrita_sense import Node, NodeCompose, WorkflowInterpreter +from amrita_sense.instructions.native import NATIVE_DO, NATIVE_IF, NATIVE_WHILE + + +def _run(coro): + return asyncio.run(coro) + + +@Node(wrap_to_async=False) +def ret_true() -> bool: + return True + + +@Node(wrap_to_async=False) +def ret_false() -> bool: + return False + + +# NATIVE_IF + + +class TestNativeIf: + def test_if_true_single(self): + executed = [False] + + @Node(wrap_to_async=False) + def body(): + executed[0] = True + + async def _test(): + comp = NATIVE_IF(ret_true, body).extract().render() + await WorkflowInterpreter(comp).run() + assert executed[0] is True + + _run(_test()) + + def test_if_false_single(self): + executed = [False] + + @Node(wrap_to_async=False) + def body(): + executed[0] = True + + async def _test(): + comp = NATIVE_IF(ret_false, body).extract().render() + await WorkflowInterpreter(comp).run() + assert executed[0] is False + + _run(_test()) + + def test_if_else_true(self): + if_done, else_done = [False], [False] + + @Node(wrap_to_async=False) + def if_body(): + if_done[0] = True + + @Node(wrap_to_async=False) + def else_body(): + else_done[0] = True + + async def _test(): + comp = NATIVE_IF(ret_true, if_body).ELSE(else_body).extract().render() + await WorkflowInterpreter(comp).run() + assert if_done[0] is True + assert else_done[0] is False + + _run(_test()) + + def test_if_else_false(self): + if_done, else_done = [False], [False] + + @Node(wrap_to_async=False) + def if_body(): + if_done[0] = True + + @Node(wrap_to_async=False) + def else_body(): + else_done[0] = True + + async def _test(): + comp = NATIVE_IF(ret_false, if_body).ELSE(else_body).extract().render() + await WorkflowInterpreter(comp).run() + assert if_done[0] is False + assert else_done[0] is True + + _run(_test()) + + def test_if_elif_second_true(self): + results = [] + + @Node(wrap_to_async=False) + def body1(): + results.append("if") + + @Node(wrap_to_async=False) + def body2(): + results.append("elif") + + @Node(wrap_to_async=False) + def body3(): + results.append("else") + + async def _test(): + comp = ( + NATIVE_IF(ret_false, body1) + .ELIF(ret_true, body2) + .ELSE(body3) + .extract() + .render() + ) + await WorkflowInterpreter(comp).run() + assert results == ["elif"] + + _run(_test()) + + def test_if_bubble_body(self): + results = [] + + @Node(wrap_to_async=False) + def step_a(): + results.append("a") + + @Node(wrap_to_async=False) + def step_b(): + results.append("b") + + async def _test(): + comp = NATIVE_IF(ret_true, NodeCompose(step_a, step_b)).extract().render() + await WorkflowInterpreter(comp).run() + assert results == ["a", "b"] + + _run(_test()) + + def test_if_bubble_else(self): + results = [] + + @Node(wrap_to_async=False) + def if_a(): + results.append("if") + + @Node(wrap_to_async=False) + def else_a(): + results.append("else") + + async def _test(): + comp = ( + NATIVE_IF(ret_false, NodeCompose(if_a)) + .ELSE(NodeCompose(else_a)) + .extract() + .render() + ) + await WorkflowInterpreter(comp).run() + assert results == ["else"] + + _run(_test()) + + def test_if_elif_bubble(self): + results = [] + + @Node(wrap_to_async=False) + def body1(): + results.append("if") + + @Node(wrap_to_async=False) + def body2(): + results.append("elif") + + async def _test(): + comp = ( + NATIVE_IF(ret_false, NodeCompose(body1)) + .ELIF(ret_true, NodeCompose(body2)) + .extract() + .render() + ) + await WorkflowInterpreter(comp).run() + assert results == ["elif"] + + _run(_test()) + + +# NATIVE_WHILE + + +class TestNativeWhile: + def test_while_false_skips(self): + executed = [False] + + @Node(wrap_to_async=False) + def body(): + executed[0] = True + + async def _test(): + comp = NATIVE_WHILE(ret_false).ACTION(body).extract().render() + await WorkflowInterpreter(comp).run() + assert executed[0] is False + + _run(_test()) + + def test_while_three_iterations(self): + counter = [0] + + @Node(wrap_to_async=False) + def cond(): + return counter[0] < 3 + + @Node(wrap_to_async=False) + def body(): + counter[0] += 1 + + async def _test(): + comp = NATIVE_WHILE(cond).ACTION(body).extract().render() + await WorkflowInterpreter(comp).run() + assert counter[0] == 3 + + _run(_test()) + + def test_while_bubble_body(self): + counter = [0] + + @Node(wrap_to_async=False) + def cond(): + return counter[0] < 2 + + @Node(wrap_to_async=False) + def step_a(): + counter[0] += 1 + + @Node(wrap_to_async=False) + def step_b(): + pass + + async def _test(): + comp = ( + NATIVE_WHILE(cond) + .ACTION(NodeCompose(step_a, step_b)) + .extract() + .render() + ) + await WorkflowInterpreter(comp).run() + assert counter[0] == 2 + + _run(_test()) + + def test_while_continues_after(self): + results = [] + counter = [0] + + @Node(wrap_to_async=False) + def cond(): + return counter[0] < 1 + + @Node(wrap_to_async=False) + def body(): + counter[0] += 1 + results.append("body") + + @Node(wrap_to_async=False) + def after(): + results.append("after") + + async def _test(): + comp = (NATIVE_WHILE(cond).ACTION(body) >> after).render() + await WorkflowInterpreter(comp).run() + assert results == ["body", "after"] + + _run(_test()) + + +# NATIVE_DO + + +class TestNativeDo: + def test_do_at_least_once(self): + results = [] + + @Node(wrap_to_async=False) + def body(): + results.append("body") + + async def _test(): + comp = NATIVE_DO(body).WHILE(ret_false).extract().render() + await WorkflowInterpreter(comp).run() + assert results == ["body"] + + _run(_test()) + + def test_do_three_iterations(self): + counter = [0] + + @Node(wrap_to_async=False) + def body(): + counter[0] += 1 + + @Node(wrap_to_async=False) + def cond(): + return counter[0] < 3 + + async def _test(): + comp = NATIVE_DO(body).WHILE(cond).extract().render() + await WorkflowInterpreter(comp).run() + assert counter[0] == 3 + + _run(_test()) + + def test_do_bubble_body(self): + counter = [0] + + @Node(wrap_to_async=False) + def step_a(): + counter[0] += 1 + + @Node(wrap_to_async=False) + def step_b(): + pass + + @Node(wrap_to_async=False) + def cond(): + return counter[0] < 2 + + async def _test(): + comp = NATIVE_DO(NodeCompose(step_a, step_b)).WHILE(cond).extract().render() + await WorkflowInterpreter(comp).run() + assert counter[0] == 2 + + _run(_test()) + + def test_do_continues_after(self): + results = [] + counter = [0] + + @Node(wrap_to_async=False) + def body(): + counter[0] += 1 + results.append("body") + + @Node(wrap_to_async=False) + def cond(): + return counter[0] < 2 + + @Node(wrap_to_async=False) + def after(): + results.append("after") + + async def _test(): + comp = (NATIVE_DO(body).WHILE(cond) >> after).render() + await WorkflowInterpreter(comp).run() + assert results == ["body", "body", "after"] + + _run(_test()) diff --git a/tests/test_weakcache.py b/tests/test_weakcache.py index d5724ad..e850c46 100644 --- a/tests/test_weakcache.py +++ b/tests/test_weakcache.py @@ -509,7 +509,7 @@ def test_put_existing_key_moves_to_end(self): assert cache.get("key3") is obj3 assert cache.get("key4") is obj4 - # ── Boundary / edge-case tests for put() eviction ────────────────── + # Boundary / edge-case tests for put() eviction def test_put_loose_mode_all_alive_no_eviction(self): """loose_mode=True, all refs alive: no eviction, cache exceeds capacity.""" diff --git a/uv.lock b/uv.lock index d3bad67..f971144 100644 --- a/uv.lock +++ b/uv.lock @@ -18,7 +18,7 @@ wheels = [ [[package]] name = "amrita-sense" -version = "0.5.0" +version = "0.5.1" source = { editable = "." } dependencies = [ { name = "aiologic" }, From 0801265cab6e15845ce57254961c47cf4f829f6c Mon Sep 17 00:00:00 2001 From: RAX4096 Date: Tue, 28 Jul 2026 19:54:18 +0800 Subject: [PATCH 4/8] Docs: ensure the content --- docs/.vitepress/theme/var.css | 4 +- docs/guide/advanced/locating_and_space.md | 20 +-- docs/guide/concepts/exec_and_interrupt.md | 3 +- docs/reference/api/core-nodes.md | 128 ++++++++++---- docs/reference/api/exceptions.md | 164 ++++++++++++++++-- docs/reference/api/self-compile.md | 118 ++++++++++--- docs/zh/guide/advanced/custom_node.md | 17 ++ docs/zh/reference/api/types.md | 25 +++ .../instructions/native/do_native.py | 2 + .../instructions/native/if_native.py | 2 + .../instructions/native/while_native.py | 2 +- 11 files changed, 390 insertions(+), 95 deletions(-) diff --git a/docs/.vitepress/theme/var.css b/docs/.vitepress/theme/var.css index 2188f08..05e0bb2 100644 --- a/docs/.vitepress/theme/var.css +++ b/docs/.vitepress/theme/var.css @@ -153,13 +153,13 @@ --vp-custom-block-danger-border: rgba(224, 85, 85, 0.3); /* Status colors — dark */ - --vp-c-tip-1: #e6C17A; + --vp-c-tip-1: #e6c17a; --vp-c-tip-2: #d4a84d; --vp-c-tip-3: #c8a454; --vp-c-tip-soft: rgba(230, 193, 122, 0.12); --vp-c-warning-1: #f0d18a; - --vp-c-warning-2: #e6C17A; + --vp-c-warning-2: #e6c17a; --vp-c-warning-3: #d4a84d; --vp-c-warning-soft: rgba(230, 193, 122, 0.12); diff --git a/docs/guide/advanced/locating_and_space.md b/docs/guide/advanced/locating_and_space.md index 09d10e7..5105604 100644 --- a/docs/guide/advanced/locating_and_space.md +++ b/docs/guide/advanced/locating_and_space.md @@ -67,11 +67,11 @@ In addition to `GOTO`'s one-way jump, AmritaSense also provides the `CALL` instr ### Core differences -| Feature | GOTO | CALL | -| ----------------------- | -------------------------------------- | ---------------------------------------- | -| Saves return address? | No | Yes (pushes onto `_ret_addr_stack`) | -| After-execution behavior | Continues advancing from the target | Automatically pops the stack and returns | -| Use cases | One-way jumps, branch merging | Subroutine reuse, interrupt handling | +| Feature | GOTO | CALL | +| ------------------------ | ----------------------------------- | ---------------------------------------- | +| Saves return address? | No | Yes (pushes onto `_ret_addr_stack`) | +| After-execution behavior | Continues advancing from the target | Automatically pops the stack and returns | +| Use cases | One-way jumps, branch merging | Subroutine reuse, interrupt handling | > **Further reading** > The complete `CALL` mechanism — including call stack management, the `ARCHIVED_NODES` storage structure, `SubprogramJumpNode` skip logic, and interrupt vector table implementation — will be covered in detail in [Chapter 4.3: Calling Subroutines](./child_node.md). @@ -88,11 +88,11 @@ AmritaSense uses `PointerVector` to manage multi-level nested address spaces. Ev AmritaSense provides three levels of addressing operations: -| Method | Behavior | Use case | -| -------------- | ----------------------------------------------------- | ---------------------------------- | -| `near_to(n)` | Replace the current level's index with `n` | Jumps within the same Bubble | -| `offset(n)` | Add `n` to the current level's index | Relative jumps within a Bubble | -| `far_to(addr)` | Replace the entire pointer with a full address vector | Cross-Bubble jumps | +| Method | Behavior | Use case | +| -------------- | ----------------------------------------------------- | ------------------------------ | +| `near_to(n)` | Replace the current level's index with `n` | Jumps within the same Bubble | +| `offset(n)` | Add `n` to the current level's index | Relative jumps within a Bubble | +| `far_to(addr)` | Replace the entire pointer with a full address vector | Cross-Bubble jumps | ### Scope isolation diff --git a/docs/guide/concepts/exec_and_interrupt.md b/docs/guide/concepts/exec_and_interrupt.md index 1135369..984cb1d 100644 --- a/docs/guide/concepts/exec_and_interrupt.md +++ b/docs/guide/concepts/exec_and_interrupt.md @@ -153,12 +153,13 @@ Python's `except Exception` does not catch `BaseException` subclasses. Therefore ```python except InterruptNotice as e: + logger.info(f"Interrupt notice at {self._pointer} :{e.message}") self._ret_addr_stack.clear() # Clear entire call stack self._pointer.clear() # Reset pointer vector self._jump_marked = False ``` -**This is termination, not suspension**. The call stack and pointer are fully cleared; workflow cannot resume. +**This is termination, not suspension**. The call stack and pointer are fully cleared; the workflow exits and cannot be resumed from the interruption point. To re-execute, the workflow must be re-rendered and a new interpreter instance created. ### Internal Interrupt Summary diff --git a/docs/reference/api/core-nodes.md b/docs/reference/api/core-nodes.md index 164cf7f..70fb75b 100644 --- a/docs/reference/api/core-nodes.md +++ b/docs/reference/api/core-nodes.md @@ -1,71 +1,129 @@ # Core Node Classes -AmritaSense represents workflow logic as node objects. The core node classes are `BaseNode`, `Node`, `NodeCompose`, and `NodeComposeRendered`. +AmritaSense's node system is the foundation of the entire workflow engine. All workflow elements -- whether business functions, control flow instructions, or composition containers -- are ultimately instances or compositions of nodes. ## BaseNode -`BaseNode` is the abstract base class for all workflow nodes. It provides shared metadata, a tag, the underlying callable, and a dependency signature. +`BaseNode` is the abstract base class for all workflow nodes. It defines the common interface and minimum metadata set for nodes in the AmritaSense runtime. Developers typically do not subclass `BaseNode` directly, instead using the `@Node()` decorator or subclassing `Node`, though it may be needed when implementing low-level jump nodes for custom instructions. -Key attributes: +```python +class BaseNode: + func: Callable[..., Any] + tag: str + wrap_to_async: bool + address_able: bool + fun_frame: FrameType + fun_sign: inspect.Signature +``` + +### Attributes -- `tag`: Human-readable node identifier. -- `func`: Underlying callable function or coroutine. -- `wrap_to_async`: Whether sync functions should be executed in an async-friendly way. -- `address_able`: Whether the node can be referenced by alias. -- `fun_sign`: Dependency injection metadata extracted from the callable’s signature. +- `func`: The underlying callable. This is what the interpreter ultimately calls when executing the node. +- `tag`: The node's string identifier. Also serves as the breakpoint name for flow suspension -- external callers can suspend before this node executes via `wait_to_suspend(tag)`. Defaults to `NodeSuspend::{function_name}` if not specified at creation time. +- `wrap_to_async`: If `True` and `func` is synchronous, the interpreter automatically uses `asyncio.to_thread` to execute it in a thread pool, preventing event-loop blockage. +- `address_able`: If `True`, the node can be referenced by `ALIAS`. Only addressable nodes can become targets for jump instructions like `GOTO` and `CALL`. +- `fun_sign`: The function signature extracted by `inspect.signature(func)`, used by the dependency injection system for parameter resolution at runtime. +- `fun_frame`: The stack frame object at node creation time, primarily used for debugging and log location. -`BaseNode` also exposes two lifecycle hooks that subclasses can override: +### Methods -- `_post_compile(compose: NodeComposeRendered) -> None`: Called after the workflow graph is fully compiled. Subclasses use this to resolve aliases and validate addresses at compile time (e.g., `JumpNode`, `CallNode`, `PUSH_CONTEXT`, `INTERRUPT_INTO`). -- `_pre_check(pointer: WorkflowInterpreter) -> None`: Called before each execution. Used for runtime checks that depend on the interpreter state (e.g., `BatchRun` forks child interpreters here). -- `as_compose() -> NodeCompose`: Convenience method to wrap this node in a `NodeCompose`, enabling `node.as_compose().render()` as a one-liner. +- `_post_compile(compose: NodeComposeRendered) -> None`: Post-compilation hook, called after the workflow graph is fully compiled. Subclasses resolve aliases and validate addresses here (e.g., `JumpNode`, `CallNode`, `PUSH_CONTEXT`, `INTERRUPT_INTO`), moving runtime overhead to compile time. +- `_pre_check(pointer: WorkflowInterpreter) -> None`: Pre-execution hook, called by the interpreter before each node execution. Used for runtime checks that depend on interpreter state (e.g., `BatchRun` forks child interpreters here). +- `as_compose() -> NodeCompose`: Convenience method that wraps the node in a `NodeCompose`, enabling `node.as_compose().render()` as a one-liner. +- `_init(func, tag, wrap_to_async, address_able, frame)`: Internal construction method that uniformly sets the node's metadata. +- `__call__(*args, **kwargs)`: Abstract method that subclasses must implement. Defines the node's execution behavior. + +**Important**: `BaseNode` and its subclasses use `__slots__` for attribute definitions, avoiding the default `__dict__` overhead and making each node instance as compact as possible. The memory advantage is significant for workflows that may contain hundreds or thousands of nodes. ## Node -`Node` is the concrete wrapper around a Python callable. It is created by the `@Node()` decorator and is the most common executable unit in AmritaSense. +`Node` is the generic concrete implementation of `BaseNode` and the node type developers interact with most frequently. Nodes created with the `@Node()` decorator are `Node` instances. It wraps an ordinary Python function or coroutine into a basic workflow execution unit. -A `Node` preserves the original function signature and adds workflow metadata. Sync functions can be automatically wrapped to async if `wrap_to_async=True`. +```python +class Node(BaseNode, Generic[NODE_T]): + ... +``` -Example: +### Creation ```python -@Node() -def my_node(value: int): - print(value) +@Node(tag="custom_tag", wrap_to_async=True, address_able=True) +def my_function(arg1: str) -> str: + return arg1.upper() ``` -Because `Node` implements `__rshift__`, nodes can be composed using `>>`. +### Construction parameters -## NodeCompose +- `tag: str | None`: The node's breakpoint label. Auto-generated by default. +- `wrap_to_async: bool`: If `True` and the function is synchronous, executes in a thread pool. Defaults to `True`. +- `address_able: bool`: Whether the node can be bound by `ALIAS`. Defaults to `True`. + +### Features + +- The generic parameter `NODE_T` preserves the original function's return type for type checker tracking. +- Can be chained with any `BaseNode`, `NodeCompose`, or `SelfCompileInstruction` in `>>` composition. +- If the node's function signature declares a parameter of type `WorkflowInterpreter` (injected via `POINTER_DEPENDS`), the node receives full access to the current interpreter instance at execution time. +- The node's `__call__` attribute directly returns the original function object, so `node(...)` is equivalent to `func(...)`. -`NodeCompose` is a container for a sequence of nodes or nested compositions. It is built using the `>>` operator and represents a workflow before rendering. +## NodeCompose -Example: +`NodeCompose` is a container for node composition. It maintains an ordered list of nodes and supports `>>` chain appending via `__rshift__`. `NodeCompose` is also the standard return type of `SelfCompileInstruction.extract()` -- self-compile instructions ultimately expand their semantics into a `NodeCompose`. ```python -a = NodeType(lambda: 1, wrap_to_async=False, address_able=False, tag=None) -b = NodeType(lambda x: x + 1, wrap_to_async=False, address_able=False, tag=None) -workflow = a >> b +class NodeCompose: + _graph: list[BaseNode | NodeCompose | SelfCompileInstruction] ``` -`NodeCompose.render()` compiles the composition into a `NodeComposeRendered` instance. +### Features + +- **Chain appending**: `compose >> new_node` is equivalent to `compose._graph.append(new_node)`. +- **Nesting capability**: Elements in `_graph` can themselves be `NodeCompose` instances -- this is the data foundation of Bubble scoping. +- **Compilation entry point**: Calling the `render()` method triggers the compilation pipeline and produces a `NodeComposeRendered`. + +### Methods + +- `__rshift__(other)`: Implements the `>>` operator. Appends `other` to the internal list and returns `self`. +- `render() -> NodeComposeRendered`: Compiles the current composition structure. All `SelfCompileInstruction` instances are expanded during this phase, recursive `NodeCompose` instances are resolved into nested `NodeComposeRendered`, and the final executable graph with address mappings is produced. + +### Usage example + +```python +workflow = NodeCompose(node_a, node_b) +# Or more concisely: +workflow = node_a >> node_b >> node_c +``` ## NodeComposeRendered -`NodeComposeRendered` is the compiled, executable workflow graph. It converts nested compositions, alias declarations, and self-compile instructions into a final node structure. +`NodeComposeRendered` is the final compilation product -- a fully resolved, optimized, address-mapped executable workflow graph. This is the type accepted by `WorkflowInterpreter`. + +```python +class NodeComposeRendered: + _graph: list[BaseNode | NodeComposeRendered] + alias2vector_map: dict[str, list[int]] +``` + +### Compilation process + +`render()` triggers `_build()` to recursively traverse the original `NodeCompose` structure: + +1. Expands all `SelfCompileInstruction` instances (calling their `extract()` method). +2. Recursively renders nested `NodeCompose` instances into `NodeComposeRendered`. +3. Assigns a `PointerVector` address to each node. +4. Collects all `AliasNode` instances, storing alias-to-address mappings into `alias2vector_map`. -Important features: +### Key attributes -- `alias2vector_map`: maps alias names to absolute `PointerVector` addresses. Jump instructions resolve aliases at compile time via `_post_compile`. -- `calc: AddressCalculator`: the compiled graph's address calculator, providing `resolve_alias()`, `find_addr()`, `find_addr_safe()`, and `advance()` methods. Available only on the top-level `NodeComposeRendered`. -- `__getitem__`: access nodes by index in the rendered graph. -- `__iter__`: iterate over compiled nodes. +- `_graph: list[BaseNode | NodeComposeRendered]`: The compiled node sequence. Elements may be concrete nodes or sub-containers. +- `alias2vector_map: dict[str, list[int]]`: The alias-to-pointer-vector mapping table. Jump instructions like GOTO and CALL look up addresses from this table at compile time via `_post_compile`. +- `calc: AddressCalculator`: The compiled graph's address calculator, providing `resolve_alias()`, `find_addr()`, `find_addr_safe()`, and `advance()` methods. Available only on the top-level `NodeComposeRendered`. +- `__bool__()`: Checks whether `_graph` has been built, used to determine whether compilation has completed. -Example: +### Obtaining an instance ```python rendered = workflow.render() -node = rendered[0] +# workflow can be a NodeCompose or any SelfCompileInstruction ``` -`NodeComposeRendered` is the input expected by `WorkflowInterpreter` for execution. +**Important**: `NodeComposeRendered` is immutable -- once compilation completes, its structure and address mappings should not be modified. All runtime state is managed by `WorkflowInterpreter`; `NodeComposeRendered` exists only as a read-only "code segment". diff --git a/docs/reference/api/exceptions.md b/docs/reference/api/exceptions.md index 23d3a27..46dd491 100644 --- a/docs/reference/api/exceptions.md +++ b/docs/reference/api/exceptions.md @@ -1,15 +1,38 @@ # Exception System -AmritaSense defines a small set of runtime exceptions used by the interpreter, dependency injection, and control flow primitives. +AmritaSense defines a lean, purpose-specific exception hierarchy for handling various errors and interruptions during workflow execution. Each exception type has clear semantic boundaries and usage constraints. ## InterruptNotice -`InterruptNotice` is a `BaseException` subclass used to terminate workflow execution immediately. It bypasses normal `Exception` handlers and is caught by the interpreter at the top level. +```python +class InterruptNotice(BaseException): + """Special exception for immediate workflow termination. + + Raised by the INTERRUPT node or external systems. As a BaseException + subclass, it bypasses regular CATCH blocks and penetrates directly to + the interpreter's top-level handler, ensuring clean and unconditional + termination. + """ +``` -Use cases: +**Why inherit from `BaseException`** + +Python's `except Exception` does not catch `BaseException` subclasses. Therefore, no `TRY/CATCH` block in the workflow can intercept `InterruptNotice` by default. This is an intentional design choice — `INTERRUPT` must be an "uncatchable" emergency termination signal. The only exception is explicitly adding `InterruptNotice` to `exception_ignored`, at which point it becomes catchable as a regular exception. + +**Trigger methods** + +- **Composition level**: Automatically raised when execution reaches an `INTERRUPT` node. +- **External injection**: External systems can directly `raise InterruptNotice()`; the interpreter catches and terminates at the next node boundary. -- external stop requests -- emergency termination points in the workflow +**Interpreter response** + +When the interpreter main loop catches `InterruptNotice`: + +1. Logs the current pointer position and notification message +2. Clears `_ret_addr_stack` (the call stack) +3. Resets `_pointer` (the pointer vector) +4. Resets the `_jump_marked` flag +5. The workflow exits cleanly with no residual state ## InterruptKeepContext (v0.4.x+) @@ -23,27 +46,61 @@ Use cases: ## NullPointerException -Raised when a node cannot be found at a specified address or when an alias does not exist. +```python +class NullPointerException(Exception): + """Raised when a node cannot be located at a given address. + + This occurs when jump operations reference non-existent nodes, + invalid address vectors, or aliases that failed to resolve. + """ +``` + +**Trigger scenarios** + +- A GOTO, CALL, or other jump instruction's target address does not exist in the `NodeComposeRendered`. +- An alias lookup fails in `alias2vector_map` (at which point `JumpNode` and `CallNode`'s `_post_compile` raises `AliasNotFoundError`). +- An out-of-bounds index is accessed at runtime via `find_addr`. + +**Relationship with alias validation** + +`NullPointerException` is the fallback exception for runtime address failures. In practice, when using `ALIAS` + `GOTO`/`CALL` for normal addressing, typos are caught at compile time during `_post_compile` with correction suggestions. Only raw `list[int]` addresses that are invalid at runtime will raise this exception. ## BreakLoop -Used internally by loop constructs to implement break semantics. Raising `BreakLoop` exits the current loop body and continues execution after the loop. +```python +class BreakLoop(Exception): + """Exception used to break out of WHILE or DO-WHILE loop constructs. -`BreakLoop` is automatically added to `_exc_ignored` on interpreter init, so it penetrates through all `CATCH` blocks. **v0.3.0+**: This auto-inclusion can be disabled via `__flags__.DISABLE_EXC_IGNORED = True` from `amrita_sense._unsafe`. + When raised within a loop body, the loop terminates immediately and + execution continues after the loop's NOP exit point. + """ +``` -## DependsException +**Automatic penetration mechanism** -Base exception for all dependency injection failures. +`BreakLoop` is automatically added to the `_exc_ignored` tuple during `WorkflowInterpreter` initialization. This means: -## AliasNotFoundError (v0.4.x+) +- Any `TRY/CATCH` block inside a loop body **cannot** catch `BreakLoop`. +- It penetrates all intermediate exception handling layers and reaches the innermost `WhileNode` or `DONode`. +- The loop node catches `BreakLoop` and executes `jump_near(NOP)`, exiting cleanly. -Raised when a GOTO or CALL instruction references an alias that does not exist in the workflow graph's alias registry. This is detected at compile time during `_post_compile`. Replaces the generic `RuntimeError` / `ValueError` that were previously used for alias resolution failures. +> **v0.3.0+**: This auto-inclusion can be disabled via `__flags__.DISABLE_EXC_IGNORED = True` from `amrita_sense._unsafe`. See [Unsafe Features](../../guide/advanced/unsafe.md) for details. -## DependsResolveFailed +**Usage** -Raised when dependency resolution fails for a node or callback. This can happen when a required dependency is missing or cannot be matched. +Simply raise it inside the `ACTION` or `DO` node of a loop body: -Inherits from: `DependsException` +```python +@Node() +def process_item(): + if item is None: + raise BreakLoop # No more data; exit the loop + if item.should_skip: + return # Equivalent to continue + handle(item) +``` + +**Note**: Developers should **not** manually add `BreakLoop` to `exception_ignored` — it is already added automatically during interpreter initialization. Adding it again has no additional effect; attempting to remove it would cause `CATCH` blocks inside loops to accidentally capture `BreakLoop`, breaking loop semantics. ## IllegalState (v0.3.0+) @@ -56,11 +113,54 @@ Raised when an operation is attempted in an invalid state. Common triggers: See [Subgraph Isolation](../../guide/practice/subgraph-isolation.md) for correct usage patterns. -## DependsInjectFailed +## DependsException and subclasses + +All dependency injection exceptions inherit from `DependsException`. + +```python +class DependsException(Exception): + """Base class for all dependency injection related exceptions.""" +``` + +### AliasNotFoundError (v0.4.x+) + +Raised when a GOTO or CALL instruction references an alias that does not exist in the workflow graph's alias registry. Detected at compile time during `_post_compile`. Replaces the generic `RuntimeError` / `ValueError` previously used for alias resolution failures. + +### DependsResolveFailed + +```python +class DependsResolveFailed(Exception): + """Raised when a node's dependencies cannot be resolved. + + This occurs when required parameters in the function signature + cannot be matched to any available dependency source. + """ +``` + +**Trigger conditions** + +- A node's function signature has an unmatched parameter (no dependency source of matching type, and no default value). +- Multiple dependency sources match the same parameter and cannot be disambiguated. -Raised when dependencies are resolved successfully but cannot be injected into the target function due to mismatched parameters or runtime resolution failures. +### DependsInjectFailed -Inherits from: `DependsException` +```python +class DependsInjectFailed(Exception): + """Raised when runtime dependency injection fails during node execution. + + This typically occurs when a Depends factory function raises an + exception that is not in the exception_ignored tuple. + """ +``` + +**Trigger conditions** + +- A `Depends` factory function raises an exception at runtime that is not in `_exc_ignored`. +- When resolving multiple dependencies concurrently, all failures are collected into an `ExceptionGroup` and re-raised. + +**Critical behavior: `Depends` returning `None` terminates immediately** + +Unlike the event system's "return `None` to skip" behavior, in node execution, if a dependency factory declared via `Depends` returns `None`, the workflow **raises an exception and terminates immediately**. Nodes are atomic execution units, and dependency resolution failure means the node cannot run — this is not a "skip" scenario. Therefore, dependency factory functions designed for nodes should always return a valid value (or raise an explicit exception when unable to provide one, rather than returning `None`). ## GraphBuildError (v0.4.x+) @@ -79,6 +179,34 @@ Raised when a `SuspendObjectStream` operation is attempted in an invalid state. - Setting a callback function twice - Calling `get_response_generator()` when already being consumed +## Exception Hierarchy + +```text +BaseException +├── InterruptNotice # Inherits BaseException; uncatchable by CATCH by default +│ └── InterruptKeepContext # Interrupts but preserves interpreter context + +Exception +├── IllegalState # Invalid state operation +├── NullPointerException # Invalid address +├── BreakLoop # Loop exit signal +├── AliasNotFoundError # Alias resolution failure +├── GraphBuildError # Graph build/render failure +├── StreamStateError # Stream operation in illegal state +└── DependsException # Dependency injection base class + ├── DependsResolveFailed # Dependencies cannot be resolved + └── DependsInjectFailed # Exception during dependency injection +``` + +**Design principles**: + +- `InterruptNotice` inherits from `BaseException`, achieving natural uncatchability. +- `InterruptKeepContext` inherits from `InterruptNotice`, preserving context for later recovery. +- `BreakLoop` inherits from `Exception`, but gains equivalent penetration capability through automatic inclusion in `_exc_ignored`. +- `IllegalState` was added in v0.3.0 to protect interpreter tree API call validity and instruction syntax constraints. +- `AliasNotFoundError` / `GraphBuildError` / `StreamStateError` are fine-grained exception types added in v0.4.x+. +- All dependency-related exceptions inherit from `DependsException`, allowing users to catch the entire dependency error category as needed. + ## `search_exceptions()` (v0.3.0+) ```python diff --git a/docs/reference/api/self-compile.md b/docs/reference/api/self-compile.md index 31c8d98..1177ce6 100644 --- a/docs/reference/api/self-compile.md +++ b/docs/reference/api/self-compile.md @@ -1,47 +1,109 @@ # Self-Compile Instructions -Self-compile instructions are a special extension point that can expand into executable node compositions at render time. +Self-compile instructions are the core mechanism through which AmritaSense implements high-level control flow. They automatically expand into standard low-level node compositions during the workflow rendering phase, allowing developers to use their own encapsulated complex composition patterns just like built-in instructions. ## SelfCompileInstruction -`SelfCompileInstruction` is an abstract base class for instructions that can compile themselves into a workflow fragment. - ```python class SelfCompileInstruction(ABC): - @abstractmethod - def extract(self) -> NodeCompose: ... + """Abstract base class for all self-compiling instructions. + + Self-compiling instructions are expanded into NodeCompose structures during + the workflow rendering phase. This allows for compile-time optimization + and static address calculation without requiring changes to the interpreter. + """ +``` - def __rshift__(self, other) -> NodeCompose: - """Create a node composition using the right-shift operator. +`SelfCompileInstruction` is the abstract base class for all self-compiling instructions. Its single core responsibility is to map high-level semantics to low-level node compositions. This mapping occurs during the `render()` phase, so all address calculations and structural expansion happen before execution, leaving zero runtime overhead. - This enables the `instruction >> node` syntax for composing - self-compile instructions with regular nodes. +**Core methods** - Returns: - A new NodeCompose containing this instruction and the other element. - """ - return NodeCompose(self, other) -``` +- `extract() -> NodeCompose`: Expands the instruction into a low-level node composition. Must return a `NodeCompose` instance, which the framework will recursively render automatically. +- `__rshift__(other) -> NodeCompose`: Supports the `>>` operator so that self-compile instructions can be composed directly with regular nodes. Syntactic sugar: `instruction >> node` is equivalent to `NodeCompose(instruction, node)`. + +**Design philosophy** + +Self-compile instructions are the cornerstone of AmritaSense's extensibility. Built-in instructions like `IF`, `WHILE`, and `TRY` all implement this interface, and developers can create their own control flow primitives by subclassing it — encapsulating **composition patterns**, not concrete business logic. + +## Built-in Self-Compile Instructions + +All of AmritaSense's built-in instructions are subclasses of `SelfCompileInstruction`. The following lists only their compile-time expansion structure; for detailed syntax and runtime behavior, see Section 4.5: Built-in Instruction Set. + +### Conditional branching instructions + +- **`IFClause`**: `IF(cond, do)` → `[ConditionJumpNode, condition, do, NOP]` +- **`ELIFClause`**: Extends `IFClause` with an ELIF chain. Each ELIF appends a `[ConditionJumpNode, condition, do]` group; the final `NOP` serves as the unified exit. +- **`ELSEClause`**: Appends `[ELSENode, else_do, NOP]` after an IF or IF-ELIF chain. + +### Loop instructions + +- **`WhileClause`**: `WHILE(condition).ACTION(action)` → `[WhileNode, condition, action, CheckUpNode, NOP]` +- **`DoWhileClause`**: `DO(do).WHILE(condition)` → `[DONode, do, DowhileNode, condition, NOP]` + +### Exception handling instructions + +- **`TryClause`**: Expands to `[TryNode, try_body, ...catch_handler_i, catch_body_i..., FinNode(optional), fin_body, NOP]`. `TryNode` manages the runtime logic of the entire exception handling chain. + +### Subprogram storage instructions + +- **`SubprogramStorage`** (the underlying implementation of `ARCHIVED_NODES`): Expands to `[SubprogramJumpNode, node_1, node_2, ..., NOP]`. Accepts arbitrary `BaseNode` instances (not limited to `ALIAS`). `SubprogramJumpNode` unconditionally skips the entire storage block during normal execution. Internal nodes can be accessed via `CALL` or `GOTO` (if tagged with `ALIAS`). -When a `SelfCompileInstruction` is encountered during `NodeCompose.render()`, the interpreter calls `extract()` to obtain a `NodeCompose` fragment. That fragment is then rendered and inserted into the final workflow graph. +### Note: CALL is not a self-compile instruction -This allows developers to implement custom control flow constructs that appear as first-class instructions during composition, while still executing as ordinary nodes at runtime. +The `CallNode` corresponding to the `CALL` instruction inherits directly from `BaseNode` — it is a **regular node**. It does not expand at compile time but exists as a single node in the workflow array. Aliases are resolved at compile time via `_post_compile`, and the call is executed at runtime via `pc.call_sub`. This is consistent with the design of `GOTO` (`JumpNode`) — both are "atomic" jump nodes rather than self-compiling structures. -## Typical use +## Custom Self-Compile Instructions -- build reusable higher-level workflow primitives -- expand domain-specific instructions into standard `NodeCompose` patterns -- preserve runtime performance by resolving structure during render time +### Implementation steps -Example: +1. **Subclass `SelfCompileInstruction`** +2. **Accept construction parameters in `__init__`**: These parameters determine how the node composition will expand. +3. **Implement `extract() -> NodeCompose`**: Perform all address calculation and node assembly inside this method; return the final `NodeCompose`. + +### Simple example ```python -class CustomInstruction(SelfCompileInstruction): - def extract(self): - return NodeCompose( - NodeType(lambda: ..., wrap_to_async=False, address_able=False, tag=None), - NodeType(lambda: ..., wrap_to_async=False, address_able=False, tag=None), - ) +class LoggedNode(SelfCompileInstruction): + """Add a log message before and after a node's execution.""" + def __init__(self, node: BaseNode, name: str): + self._node = node + self._name = name + + def extract(self) -> NodeCompose: + @Node() + def log_start(): + print(f"[{self._name}] start") + + @Node() + def log_end(): + print(f"[{self._name}] done") + + return NodeCompose(log_start, self._node, log_end) ``` -When `CustomInstruction()` appears in a composition, its extracted nodes become part of the rendered graph. +Usage: `LoggedNode(process_data, "data processing")` is equivalent to writing `log_start >> process_data >> log_end` by hand. + +### Design principles + +**Compile-time vs. runtime** + +- **Compile-time**: Address calculation, structure expansion, and static optimization all happen inside `extract()`. All jump offsets are finalized during this phase. +- **Runtime**: The expanded regular nodes are executed normally by the interpreter with no additional compilation overhead. + +**Address calculation strategy** + +- If the expanded structure contains jumps (e.g., `ConditionJumpNode`), address offsets must be statically calculated inside `extract()` based on the length of the node list. +- If the expanded structure reuses built-in instructions like `IF` or `WHILE`, address calculation is handled by those instructions automatically — no manual management required. + +**Encapsulate patterns, not logic** + +Custom instructions should encapsulate recurring **composition patterns** (such as retry, timeout, conditional execution), not concrete business logic. Business logic should remain inside node functions. + +**Error handling** + +- Validate parameters in `__init__`; fail early. +- Exceptions raised inside `extract()` will prevent workflow rendering. + +**Composability** + +Custom instructions can contain other self-compile instructions (including built-in ones) with no nesting depth limit. diff --git a/docs/zh/guide/advanced/custom_node.md b/docs/zh/guide/advanced/custom_node.md index 3b4b544..946ab02 100644 --- a/docs/zh/guide/advanced/custom_node.md +++ b/docs/zh/guide/advanced/custom_node.md @@ -144,6 +144,23 @@ def my_node(pc: WorkflowInterpreter = Depends(POINTER_DEPENDS)): 因此,**只在必要时注入 `POINTER_DEPENDS`**。大多数节点应优先通过节点内部的 Python 逻辑和编排层面的指令(IF、WHILE、CALL)来完成控制流,只在指令无法表达时才直接操作解释器。 +## 4.6.5 安全的运行时集成 + +需要运行时上下文的自定义节点应使用 `POINTER_DEPENDS` 获取 `WorkflowInterpreter`,而非直接操作解释器内部状态。解释器暴露了以下安全 API: + +- `jump_to(addr)` +- `jump_near(addr)` +- `jump_offset(offset)` +- `call_sub(addr, *args, interrupt=False)` +- `get_graph().calc.resolve_alias(alias)` +- `object_io` + +由于 `object_io` 是泛型 `SuspendObjectStream` 子类,自定义节点也可以参与外部的挂起/恢复或流式 I/O 模式,而无需修改核心解释器循环。 + +::: tip +如果只需要数据依赖,优先使用 `Depends(...)` 配合提供者函数。仅在工作流控制或底层运行时检查时才使用 `POINTER_DEPENDS`。 +::: + ### 小结 自定义节点是 AmritaSense 的“细胞”。它们保持了最简单本质——一个被薄封装的 Python 函数——同时通过 `Depends` 和 `POINTER_DEPENDS` 获得了对 Amrita 生态完整能力的访问。在下一节中,我们将探讨如何把重复出现的节点组合模式封装为新的自编译指令,进一步扩展工作流的表达能力。 diff --git a/docs/zh/reference/api/types.md b/docs/zh/reference/api/types.md index 22c2c9f..46cf5f1 100644 --- a/docs/zh/reference/api/types.md +++ b/docs/zh/reference/api/types.md @@ -73,6 +73,31 @@ class PointerVector: - **相对寻址**: 在同一层级内进行偏移 - **近寻址**: 修改当前层级索引,保持其他层级不变 +## InterpreterContext(v0.4.x+) + +`InterpreterContext` 是一个数据类,存储解释器执行状态的完整快照。由 `PUSH_CONTEXT`/`POP_CONTEXT` 和 `INTERRUPT_INTO`/`INTERRUPT_RET` 用于保存/恢复工作流。 + +```python +@dataclass +class InterpreterContext: + ptr: PointerVector + exception_ignored: tuple[type[BaseException], ...] + s_args: tuple | None = None + s_kwargs: dict[str, Any] | None = None + extra: dict[str, Any] = field(default_factory=dict) + stack: Stack[PointerVector] | None = None + exception: Exception | None = None +``` + +字段说明: + +- `ptr`:执行指针(`PointerVector`)的快照。 +- `exception_ignored`:绕过 TRY/CATCH 的异常类型快照。 +- `s_args` / `s_kwargs`:依赖注入参数的快照。若在 `dump_interpreter()` 中排除则为 `None`。 +- `extra`:扩展数据字典,供自定义使用。 +- `stack`:返回地址栈的快照。若排除则为 `None`。 +- `exception`:panic 异常的快照,无 panic 则为 `None`。 + ## DICache(v0.4.2+) `DICache` 是管理 `WorkflowInterpreter` 中依赖注入结果缓存的数据类。它将参数指纹与 LRU 缓存结合,避免重复 DI 解析。 diff --git a/src/amrita_sense/instructions/native/do_native.py b/src/amrita_sense/instructions/native/do_native.py index 9162cf0..37a4cb3 100644 --- a/src/amrita_sense/instructions/native/do_native.py +++ b/src/amrita_sense/instructions/native/do_native.py @@ -47,6 +47,8 @@ def __init__(self, body: BaseNode | NodeCompose | SelfCompileInstruction) -> Non def WHILE(self, condition: Node[bool]) -> Self: """Set the loop condition.""" + if self._condition is not None: + raise TypeError("NATIVE_DO can only have a single .WHILE(condition)") self._condition = condition return self diff --git a/src/amrita_sense/instructions/native/if_native.py b/src/amrita_sense/instructions/native/if_native.py index 04676fd..62b48a3 100644 --- a/src/amrita_sense/instructions/native/if_native.py +++ b/src/amrita_sense/instructions/native/if_native.py @@ -76,6 +76,8 @@ def ELSE( body: BaseNode | NodeCompose | SelfCompileInstruction, ) -> Self: """Append an ELSE branch.""" + if self._else_body is not None: + raise TypeError("ELSE branch already defined") self._else_body = body return self diff --git a/src/amrita_sense/instructions/native/while_native.py b/src/amrita_sense/instructions/native/while_native.py index a5c60fb..64d8892 100644 --- a/src/amrita_sense/instructions/native/while_native.py +++ b/src/amrita_sense/instructions/native/while_native.py @@ -48,7 +48,7 @@ def __init__(self, condition: Node[bool]) -> None: def ACTION(self, body: BaseNode | NodeCompose | SelfCompileInstruction) -> Self: """Set the loop body.""" if self._body is not None: - raise RuntimeError("ACTION already set on NativeWhileClause") + raise TypeError("ACTION already set on NativeWhileClause") self._body = body return self From 4a0221816fb4905fa238065e0e673d0d1564c89f Mon Sep 17 00:00:00 2001 From: RAX4096 Date: Tue, 28 Jul 2026 21:15:47 +0800 Subject: [PATCH 5/8] docs: add native control flow documentation and update demo to use chain operator --- demos/21_native_if.py | 3 +- docs/.vitepress/config.mts | 18 +- .../native_instructions.md | 242 ++++++++++++++++++ docs/guide/advanced/native_control_flow.md | 204 +++++++++++++++ docs/guide/concepts/flow_control.md | 6 +- docs/guide/introduction/index.md | 2 +- docs/guide/introduction/key-features.md | 2 + .../native_instructions.md | 242 ++++++++++++++++++ docs/zh/guide/advanced/native_control_flow.md | 200 +++++++++++++++ docs/zh/guide/concepts/flow_control.md | 6 +- docs/zh/guide/introduction/index.md | 2 +- docs/zh/guide/introduction/key-features.md | 2 + src/amrita_sense/instructions/__init__.py | 3 +- .../instructions/native/__init__.py | 3 +- src/amrita_sense/instructions/native/_core.py | 25 +- .../instructions/native/break_loop.py | 70 +++++ .../instructions/native/do_native.py | 22 +- src/amrita_sense/runtime/workflow.py | 6 +- tests/test_native_break_loop.py | 151 +++++++++++ 19 files changed, 1183 insertions(+), 26 deletions(-) create mode 100644 docs/guide/advanced/built-in_instruction_set/native_instructions.md create mode 100644 docs/guide/advanced/native_control_flow.md create mode 100644 docs/zh/guide/advanced/built-in_instruction_set/native_instructions.md create mode 100644 docs/zh/guide/advanced/native_control_flow.md create mode 100644 src/amrita_sense/instructions/native/break_loop.py create mode 100644 tests/test_native_break_loop.py diff --git a/demos/21_native_if.py b/demos/21_native_if.py index 52ed718..b7c2169 100644 --- a/demos/21_native_if.py +++ b/demos/21_native_if.py @@ -88,7 +88,6 @@ async def do_bd() -> None: ### NATIVE_IF with NodeCompose body (bubble) ### print("\n=== NATIVE_IF bubble body ===") - from amrita_sense import NodeCompose @Node() async def bubble_step_a() -> None: @@ -98,7 +97,7 @@ async def bubble_step_a() -> None: async def bubble_step_b() -> None: print(" bubble step B") - comp = NATIVE_IF(cond_true, NodeCompose(bubble_step_a, bubble_step_b)) + comp = NATIVE_IF(cond_true, bubble_step_a >> bubble_step_b) await WorkflowInterpreter(comp.extract().render()).run() print("\n=== ALL DONE ===") diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index edf2dec..8261b9e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -155,6 +155,10 @@ export default withMermaid({ text: "External Interrupt", link: "/guide/advanced/external_interrupt", }, + { + text: "Native Control Flow", + link: "/guide/advanced/native_control_flow", + }, { text: "Built-in Instruction Set", items: [ @@ -182,6 +186,10 @@ export default withMermaid({ text: "Context Snapshot & Interrupt Transfer (PUSH_CONTEXT/INTERRUPT_INTO)", link: "/guide/advanced/built-in_instruction_set/context_clause", }, + { + text: "Native Instructions (NATIVE_IF/WHILE/DO/BREAK_LOOP)", + link: "/guide/advanced/built-in_instruction_set/native_instructions", + }, ], }, { text: "Custom Nodes", link: "/guide/advanced/custom_node" }, @@ -223,7 +231,6 @@ export default withMermaid({ text: "REPL Debugging", link: "/guide/practice/repl-debugging", }, - { text: "Under Construction..." }, ], }, { @@ -333,6 +340,10 @@ export default withMermaid({ text: "外部中断", link: "/zh/guide/advanced/external_interrupt", }, + { + text: "原生控制流", + link: "/zh/guide/advanced/native_control_flow", + }, { text: "内置指令集", items: [ @@ -360,6 +371,10 @@ export default withMermaid({ text: "上下文与中断转移 (PUSH_CONTEXT/INTERRUPT_INTO)", link: "/zh/guide/advanced/built-in_instruction_set/context_clause", }, + { + text: "原生特性指令 (NATIVE_IF/WHILE/DO/BREAK_LOOP)", + link: "/zh/guide/advanced/built-in_instruction_set/native_instructions", + }, ], }, { text: "自定义节点", link: "/zh/guide/advanced/custom_node" }, @@ -401,7 +416,6 @@ export default withMermaid({ text: "REPL 调试", link: "/zh/guide/practice/repl-debugging", }, - { text: "正在施工中......" }, ], }, { diff --git a/docs/guide/advanced/built-in_instruction_set/native_instructions.md b/docs/guide/advanced/built-in_instruction_set/native_instructions.md new file mode 100644 index 0000000..637d9d0 --- /dev/null +++ b/docs/guide/advanced/built-in_instruction_set/native_instructions.md @@ -0,0 +1,242 @@ +# Native Instructions (NATIVE_IF / NATIVE_WHILE / NATIVE_DO / BREAK_LOOP) + +The native control flow instruction set introduced in AmritaSense v0.5.1 is based on the `PUSH / JMP / RET_FAR` pointer operation pattern — an **orthogonal extension** to the traditional `call_sub` instructions. + +## Overview + +Traditional instructions (`IF` / `WHILE` / `DO`) use `call_sub` to enter branch bodies, which involves lock acquisition, middleware invocation, and DI resolution. Native instructions replace this with lightweight pointer jumps: + +``` +call_sub path: lock → middleware → DI → execute → return +PUSH/JMP path: push → jump → execute → RET_FAR → pop +``` + +`RET_FAR` inside a bubble body pops the stack and returns to the address recorded by `PUSH`. The compiler **always** auto-appends a `RET_FAR` at the end of every bubble body as a fallback — you never need to write it yourself. Use `RET_FAR` mid-body only for early exit. + +### RET_FAR vs BREAK_LOOP + +| Instruction | Purpose | Auto-inserted? | +| ------------ | ----------------------------------------------- | -------------------- | +| `RET_FAR` | End bubble execution (normal or early) | ✅ compiler fallback | +| `BREAK_LOOP` | Terminate a loop (pop stack + jump to sentinel) | ❌ manual only | + +The key difference: `RET_FAR` pops the stack and returns to the address recorded by `PUSH` (for loops, that's the condition node — the loop continues), while `BREAK_LOOP` pops the stack and jumps directly to the parent bubble's last sentinel `NOP`, cleanly ending the loop. + +### Compile-Time Optimization: \_is_single Dispatch + +The compiler distinguishes payload types via `_classify_body`: + +| payload type | `_is_single` | Mechanism | +| ---------------------------------------- | ------------ | ---------------------------------------- | +| `BaseNode` | `True` | `call_offset` (auto-return) | +| `NodeCompose` / `SelfCompileInstruction` | `False` | Wrap in bubble (compiler auto-`RET_FAR`) | + +## NATIVE_IF + +### Import + +```python +from amrita_sense.instructions.native import NATIVE_IF +``` + +### Signature + +```python +NATIVE_IF( + condition: Node[bool], + body: BaseNode | NodeCompose | SelfCompileInstruction, +) -> NativeIfClause +``` + +### Methods + +| Method | Signature | Description | +| ------------------------ | --------- | -------------------------------- | +| `.ELIF(condition, body)` | → `Self` | Append ELIF branch; chainable | +| `.ELSE(body)` | → `Self` | Append ELSE branch; at most once | + +### Compiled Layout + +#### Single IF (bubble body) + +```mermaid +graph LR + jump["[0] NativeIfJumpNode"] + cond["[1] condition node"] + body["[2] body bubble"] + nop["[3] NOP (merge point)"] + jump --> cond --> body --> nop +``` + +#### IF-ELIF-ELSE Chain + +```mermaid +graph LR + if0["[0] NativeIfJumpNode"] + if_cond["[1] IF cond"] + if_body["[2] IF body"] + elif0["[3] NativeIfJumpNode"] + elif_cond["[4] ELIF cond"] + elif_body["[5] ELIF body"] + else_body["[6] ELSE body"] + nop["[merge] NOP"] + if0 --> if_cond + elif0 --> elif_cond + if0 -.->|false| elif0 + elif0 -.->|false| else_body + if_body -.->|RET_FAR| nop + elif_body -.->|RET_FAR| nop + else_body --> nop +``` + +The ELSE branch body does not append `RET_FAR`; it flows naturally to the merge point. + +### Underlying Nodes + +- **`NativeIfJumpNode`** (`_core.py`): Condition jump node for IF/ELIF. The `_is_single` flag determines the single-node (`call_offset`) vs bubble (`PUSH + jump_far_ptr`) path. + +## NATIVE_WHILE + +### Import + +```python +from amrita_sense.instructions.native import NATIVE_WHILE +``` + +### Signature + +```python +NATIVE_WHILE( + condition: Node[bool], +) -> NativeWhileClause +``` + +### Methods + +| Method | Signature | Description | +| --------------- | --------- | ---------------------------------- | +| `.ACTION(body)` | → `Self` | Set loop body; required, once only | + +### Compiled Layout + +```mermaid +graph LR + while["[0] NativeWhileNode"] + cond["[1] condition node"] + body["[2] body slot"] + nop["[3] NOP (exit)"] + while --> cond --> body --> nop +``` + +### Underlying Nodes + +- **`NativeWhileNode`** (`_core.py`): Condition evaluation + dispatch node. When `_is_single=True`, `call_offset` body then `jump_near(self_pos)` back to itself; when `_is_single=False`, `PUSH self_pos` then `jump_far_ptr` into bubble, with `RET_FAR` popping back. + +## NATIVE_DO + +### Import + +```python +from amrita_sense.instructions.native import NATIVE_DO +``` + +### Signature + +```python +NATIVE_DO( + body: BaseNode | NodeCompose | SelfCompileInstruction, +) -> NativeDoClause +``` + +### Methods + +| Method | Signature | Description | +| ------------------- | --------- | --------------------------------------- | +| `.WHILE(condition)` | → `Self` | Set loop condition; required, once only | + +### Compiled Layout + +#### Single Node + +```mermaid +graph LR + body["[0] body (single node)"] + do["[1] NativeDoWhileNode"] + cond["[2] condition node"] + nop["[3] NOP"] + body --> do --> cond --> nop +``` + +#### Bubble Body + +```mermaid +graph LR + enter["[0] NativeBubbleEnterNode"] + body["[1] body bubble"] + do["[2] NativeDoWhileNode"] + cond["[3] condition node"] + nop["[4] NOP"] + enter --> body --> do --> cond --> nop +``` + +### Underlying Nodes + +- **`NativeDoWhileNode`** (`_core.py`): DO-WHILE back-edge node. When condition is true, `jump_near(loop_pos)` back to body entry (`NativeBubbleEnterNode` handles re-entry); when false, `jump_near(exit_pos)` to exit. +- **`NativeBubbleEnterNode`** (`_core.py`): Bubble entry helper. If constructed with `ret_pos`, `PUSH ret_pos` before `JMP` into bubble; otherwise just `JMP` (ELSE scenario). + +## BREAK_LOOP + +### Import + +```python +from amrita_sense.instructions.native import BREAK_LOOP +``` + +### Signature + +```python +BREAK_LOOP: _BreakLoopNode # singleton +``` + +### How It Works + +1. Get current address `[a, b, c]` from `pc._pointer.base_addr` +2. `pc.get_graph().calc.find_addr([a])` resolve parent bubble → `NodeComposeRendered` +3. Compute target: `len(parent_bubble) - 1` (last sentinel NOP) +4. `pc._ret_addr_stack.pop()` clean up return address pushed on loop entry +5. `pc.jump_far_ptr([a, target])` jump to exit + +### Constraints + +- **Bubble body only**: single-node bodies exit naturally via `return` +- **Must be inside a native loop body**: insufficient address depth raises `RuntimeError` +- **Normal execution continues after break**: the next node after the loop executes as usual + +### Example + +```python +from amrita_sense.instructions.native import BREAK_LOOP, NATIVE_WHILE + +NATIVE_WHILE(cond).ACTION( + process_item + >> BREAK_LOOP + >> log_item +) +``` + +## Comparison with Traditional Instructions + +| | `IF` | `NATIVE_IF` | `WHILE` | `NATIVE_WHILE` | `DO` | `NATIVE_DO` | +| ------------- | ----------------- | ----------------------------------- | ----------------- | ----------------------------------- | ----------------- | ----------------------------------- | +| Entry | `call_sub` | `PUSH+JMP` or `call_offset` | `call_sub` | `PUSH+JMP` or `call_offset` | `call_sub` | `PUSH+JMP` or `call_offset` | +| Return | auto (`call_sub`) | `RET_FAR` (bubble) or auto (single) | auto (`call_sub`) | `RET_FAR` (bubble) or auto (single) | auto (`call_sub`) | `RET_FAR` (bubble) or auto (single) | +| Break | `raise BreakLoop` | `BREAK_LOOP` | `raise BreakLoop` | `BREAK_LOOP` | `raise BreakLoop` | `BREAK_LOOP` | +| Middleware | invoked | not invoked | invoked | not invoked | invoked | not invoked | +| DI resolution | invoked | not invoked | invoked | not invoked | invoked | not invoked | + +## Notes + +1. **Bubble bodies must end with RET_FAR**: the compiler auto-appends `RET_FAR`, but if you manually construct `NodeCompose` as a branch body, ensure `RET_FAR` is at the end +2. **BREAK_LOOP is not an exception**: it's a synchronous pointer operation (`wrap_to_async=False`) and does not trigger exception handling +3. **Native instructions are composable**: fully interoperable with `>>`, `NodeCompose`, and traditional instructions +4. **ELSE does not push**: `NATIVE_IF`'s ELSE branch naturally flows in via `NativeBubbleEnterNode` without pushing a return address (no return needed) diff --git a/docs/guide/advanced/native_control_flow.md b/docs/guide/advanced/native_control_flow.md new file mode 100644 index 0000000..8b4480b --- /dev/null +++ b/docs/guide/advanced/native_control_flow.md @@ -0,0 +1,204 @@ +# Native Control Flow + +The **native control flow instruction set** introduced in AmritaSense v0.5.1 — `NATIVE_IF`, `NATIVE_WHILE`, `NATIVE_DO`, and `BREAK_LOOP` — is an **orthogonal extension** to the traditional `IF`/`WHILE`/`DO` control flow primitives. + +## Design Philosophy + +Native instructions are **not** performance replacements for traditional ones — they are **orthogonal extensions**. The core difference lies in the underlying implementation pattern: + +| | Traditional (`IF`/`WHILE`/`DO`) | Native (`NATIVE_IF`/`NATIVE_WHILE`/`NATIVE_DO`) | +| ----------------- | ---------------------------------------------- | ------------------------------------------------------------- | +| Mechanism | `call_sub` (lock + middleware + DI resolution) | `PUSH / JMP / RET_FAR` (pure pointer ops) | +| Branch entry | Interpreter auto-manages call stack | Developer explicitly controls jumps and returns | +| Bubble return | Automatic (`call_sub` has built-in return) | Compiler auto-appends `RET_FAR` as fallback | +| Loop break | Raise `BreakLoop` exception | `BREAK_LOOP` instruction (pop stack + jump to sentinel) | +| Early bubble exit | N/A | Manual `RET_FAR` (compiler always inserts fallback, optional) | +| Use case | General control flow, works out of box | Precise pointer control, bypass middleware/DI | + +> **RET_FAR vs BREAK_LOOP**: +> +> - **`RET_FAR`** marks the end of bubble execution. The compiler **always** auto-appends a `RET_FAR` at the end of every bubble body as a fallback — you never need to write it yourself. Insert `RET_FAR` mid-body only for early exit (skip subsequent nodes). +> - **`BREAK_LOOP`** is specifically for terminating a loop. It pops the return address pushed on loop entry, then jumps to the parent bubble's sentinel (`NOP`), cleanly ending the loop. +> +> Without `BREAK_LOOP`, a `RET_FAR` inside a loop body merely returns to the loop condition node — it cannot terminate the loop. + +## NATIVE_IF + +`NATIVE_IF` has an API identical to traditional `IF`, supporting `ELIF` / `ELSE` chaining: + +```python +NATIVE_IF(cond, body) # plain IF +NATIVE_IF(cond, body).ELSE(else_body) # IF-ELSE +NATIVE_IF(cond, body).ELIF(cond2, body2) # IF-ELIF chain +NATIVE_IF(cond, body).ELIF(cond2, body2).ELSE(else_body) # full chain +``` + +### Single-Node Branch Body + +When `body` is a single `BaseNode`, the compiler recognizes `_is_single=True` and the branch body is called via `call_offset` — zero extra overhead, behaving identically to traditional instructions: + +```python +NATIVE_IF(check_condition, my_action).ELSE(my_fallback) +``` + +Compiled layout (single IF): + +```mermaid +graph LR + jump["[0] NativeIfJumpNode"] + cond["[1] condition node"] + body["[2] body (single node)"] + nop["[3] NOP (merge point)"] + jump --> cond --> body --> nop +``` + +### Bubble Branch Body + +When `body` is a `NodeCompose`, the compiler wraps it as a **bubble** and auto-appends `RET_FAR` as a fallback (you never need to write it): + +```python +NATIVE_IF(cond, step_a >> step_b).ELSE(fallback_a >> fallback_b) +``` + +Compiled layout: + +```mermaid +graph LR + jump["[0] NativeIfJumpNode"] + cond["[1] condition node"] + body["[2] body bubble"] + nop["[3] NOP (merge point)"] + jump --> cond --> body --> nop +``` + +Execution: condition true → `PUSH` merge address → `JMP` into bubble → after execution `RET_FAR` pops stack back to merge. + +### ELIF / ELSE Chain Expansion + +Each `ELIF` appends a `[NativeIfJumpNode, cond, body_slot]` triplet at compile time. The `ELSE` branch body enters via `NativeBubbleEnterNode` (without `ret_pos`, no stack push) and flows naturally to the merge point. + +## NATIVE_WHILE + +`NATIVE_WHILE` shares the same semantics as traditional `WHILE` — **evaluate condition first, then execute body**: + +```python +NATIVE_WHILE(condition).ACTION(body) +``` + +### Single-Node Loop Body + +```python +NATIVE_WHILE(check_alive).ACTION(heartbeat) +``` + +Compiled layout: + +```mermaid +graph LR + while["[0] NativeWhileNode"] + cond["[1] condition node"] + body["[2] body (single node)"] + nop["[3] NOP (exit)"] + while --> cond --> body --> nop +``` + +`call_offset` evaluates condition → true: `call_offset` body → `jump_near(0)` back to while node → false: `jump_near(3)` to exit. + +### Bubble Loop Body + +```python +NATIVE_WHILE(cond).ACTION(step_a >> step_b) +``` + +The compiler appends `RET_FAR` at the end of the bubble. At runtime, `NativeWhileNode` `PUSH`es its own address `[0]` before entering the bubble, so `RET_FAR` pops back to re-evaluate the condition. + +### Breaking Out: BREAK_LOOP + +Inside a `WHILE` bubble body you cannot throw `BreakLoop` like the traditional `WHILE` — native instructions have no try/except wrapping. Instead use the **`BREAK_LOOP` instruction**: + +```python +NATIVE_WHILE(cond).ACTION( + step_a + >> BREAK_LOOP + >> step_b +) +``` + +How `BREAK_LOOP` works: + +1. Get current pointer address `[a, b, c]` +2. Resolve parent bubble at `[a]` → `NodeComposeRendered` +3. Compute target: `len(parent_bubble) - 1` (last sentinel `NOP`) +4. `_ret_addr_stack.pop()` to clean up the return address pushed by `NativeWhileNode` +5. `jump_far_ptr` to the exit + +> Single-node loop bodies don't need `BREAK_LOOP` — simply `return` from the body function to end the current iteration naturally; exit when the condition is false. + +## NATIVE_DO + +`NATIVE_DO` guarantees the **body executes at least once**, then checks the condition: + +```python +NATIVE_DO(body).WHILE(condition) +``` + +### Single-Node Loop Body + +```python +NATIVE_DO(send_request).WHILE(should_retry) +``` + +Compiled layout: + +```mermaid +graph LR + body["[0] body (single node)"] + do["[1] NativeDoWhileNode"] + cond["[2] condition node"] + nop["[3] NOP (exit)"] + body --> do --> cond --> nop +``` + +`NativeDoWhileNode` constructor params: `condi_offset=1, loop_pos=0, exit_pos=3`. + +Body executes first → `call_offset` condition → true: `jump_near(0)` back to body → false: `jump_near(3)` exit. + +### Bubble Loop Body + +```python +NATIVE_DO(step_a >> step_b).WHILE(cond) +``` + +Compiled layout: + +```mermaid +graph LR + enter["[0] NativeBubbleEnterNode"] + body["[1] body bubble"] + do["[2] NativeDoWhileNode"] + cond["[3] condition node"] + nop["[4] NOP (exit)"] + enter --> body --> do --> cond --> nop +``` + +First entry: `NativeBubbleEnterNode` PUSHes `[2]` (do-while node address), JMP into body bubble. +Loop re-entry: condition true → `NativeDoWhileNode` unconditionally `jump_near(0)`, triggering `NativeBubbleEnterNode` again to PUSH + JMP. +Loop exit: condition false → `jump_near(4)` to NOP exit. Or `BREAK_LOOP` for active break. + +> **Semantic alignment**: As of v0.5.1, DO and WHILE bubble bodies behave identically — both require `PUSH` + `RET_FAR`, and `BREAK_LOOP` handles both uniformly. + +## Selection Guide + +| Scenario | Recommendation | +| ------------------------------------------------ | ----------------------------------------------------- | +| General control flow, need DI/middleware | Traditional `IF` / `WHILE` / `DO` | +| Performance-sensitive paths, skip overhead | `NATIVE_*` single-node mode | +| Need precise pointer jump control | `NATIVE_*` bubble mode | +| Need exception penetration (`exception_ignored`) | Traditional instructions | +| Conditional break inside loop | Traditional: `raise BreakLoop` / Native: `BREAK_LOOP` | + +Native and traditional instructions can be **freely mixed** within the same workflow — they operate on the same pointer vector and call stack system at different abstraction levels. + +``` + +``` diff --git a/docs/guide/concepts/flow_control.md b/docs/guide/concepts/flow_control.md index 3bc71b4..dec4d6c 100644 --- a/docs/guide/concepts/flow_control.md +++ b/docs/guide/concepts/flow_control.md @@ -103,4 +103,8 @@ This design lets developers mark exceptions as “non-recoverable” or “globa ### Summary -From conditionals and loops to exception handling, AmritaSense’s flow control system covers all core structured programming paradigms. These capabilities are not “simulated” through an external DSL or graph topology; they are directly encoded as first-class primitives in the instruction set and interpreter. In the next chapter, we will explore execution and interrupt control at runtime. +From conditionals and loops to exception handling, AmritaSense's flow control system covers all core structured programming paradigms. These capabilities are not "simulated" through an external DSL or graph topology; they are directly encoded as first-class primitives in the instruction set and interpreter. + +> **Further reading**: v0.5.1 introduced the native control flow instruction set (`NATIVE_IF` / `NATIVE_WHILE` / `NATIVE_DO` / `BREAK_LOOP`), an **orthogonal extension** to traditional instructions. See [Native Control Flow](../advanced/native_control_flow.md). + +In the next chapter, we will explore execution and interrupt control at runtime. diff --git a/docs/guide/introduction/index.md b/docs/guide/introduction/index.md index 5897a48..ecf1dbf 100644 --- a/docs/guide/introduction/index.md +++ b/docs/guide/introduction/index.md @@ -14,7 +14,7 @@ Furthermore, AmritaSense includes a complete **event and dependency injection su We believe that **workflows should be designed for the work, not limited by the flow**. “Flow” is merely a presentation; it should never become a shackle when you design logic. -- **Natively Turing-complete** – You can implement complete, arbitrary control logic without defining complex boundary conditions inside your program. AmritaSense natively supports conditionals (`IF/ELIF/ELSE`), loops (`WHILE/DO‑WHILE`), unconditional jumps (`GOTO`), subroutine calls (`CALL`), and exception handling (`TRY/CATCH`)—no external graph engine or state machine required. +- **Natively Turing-complete** – You can implement complete, arbitrary control logic without defining complex boundary conditions inside your program. AmritaSense natively supports conditionals (`IF/ELIF/ELSE`), loops (`WHILE/DO‑WHILE`), unconditional jumps (`GOTO`), subroutine calls (`CALL`), and exception handling (`TRY/CATCH`)—no external graph engine or state machine required. v0.5.1 added native control flow instructions (`NATIVE_IF` / `NATIVE_WHILE` / `NATIVE_DO` / `BREAK_LOOP`) as an orthogonal extension. - **Virtual-machine addressing model** – AmritaSense uses a classic computer-style **addressing and execution model** (`PointerVector` + call stack). All high-level control flow is expanded into uniform pointer instructions at compile time. Only integer operations and function calls remain at runtime; scheduling overhead is nearly zero. - **Built for AI agents and complex business logic** – Whether it is tool-calling loops, nested sub‑workflows, pausing for user input, or exception recovery and rollback, AmritaSense can express it directly and execute it with extreme efficiency. diff --git a/docs/guide/introduction/key-features.md b/docs/guide/introduction/key-features.md index 3fde22a..003693d 100644 --- a/docs/guide/introduction/key-features.md +++ b/docs/guide/introduction/key-features.md @@ -23,6 +23,8 @@ AmritaSense provides a first-class control flow instruction set; no external gra - **Jump instructions** – `GOTO` with `ALIAS` for unconditional jumps, `CALL` with `ARCHIVED_NODES` for subroutine calls and returns. - **Exception handling** – `TRY…CATCH…THEN…FIN`, fully aligned with Python’s exception handling semantics, with controlled exception penetration. +v0.5.1 added native control flow instructions `NATIVE_IF` / `NATIVE_WHILE` / `NATIVE_DO` / `BREAK_LOOP`, an **orthogonal extension** providing lower-level control flow based on `PUSH/JMP/RET_FAR` for scenarios requiring precise pointer control. + All control flow instructions are expanded into low-level node compositions at compile time. Execution is performed entirely through pointer offsets; there is no graph traversal, string routing, or state-dictionary lookup. ## 1.2.4 Async-Native diff --git a/docs/zh/guide/advanced/built-in_instruction_set/native_instructions.md b/docs/zh/guide/advanced/built-in_instruction_set/native_instructions.md new file mode 100644 index 0000000..20cf8df --- /dev/null +++ b/docs/zh/guide/advanced/built-in_instruction_set/native_instructions.md @@ -0,0 +1,242 @@ +# 原生特性指令 (NATIVE_IF / NATIVE_WHILE / NATIVE_DO / BREAK_LOOP) + +AmritaSense v0.5.1 引入的原生控制流指令集,基于 `PUSH / JMP / RET_FAR` 指针操作模式,是对传统 `call_sub` 指令的**正交扩展**。 + +## 概述 + +传统指令(`IF` / `WHILE` / `DO`)使用 `call_sub` 进入分支体,这涉及解释锁获取、中间件调用和依赖注入解析。原生指令用轻量的指针跳转模式替代: + +``` +call_sub 路径: 锁 → 中间件 → DI → 执行 → 返回 +PUSH/JMP 路径: 压栈 → 跳转 → 执行 → RET_FAR → 弹栈 +``` + +Bubble 体内的 `RET_FAR` 会弹栈并回到 `PUSH` 时记录的地址。编译器**总是**在每个 Bubble 体末尾自动插入一个 `RET_FAR` 作为兜底——你无需手动编写。如果你需要提前退出 Bubble,可以在中途手动插入 `RET_FAR`。 + +### RET_FAR 与 BREAK_LOOP + +| 指令 | 用途 | 自动插入? | +| ------------ | ---------------------------------- | ----------------- | +| `RET_FAR` | 结束 Bubble 执行(提前或正常退出) | ✅ 编译器末尾兜底 | +| `BREAK_LOOP` | 终止循环(弹栈 + 跳到出口哨兵) | ❌ 需手动插入 | + +`BREAK_LOOP` 和 `RET_FAR` 的关键区别:`RET_FAR` 弹栈后回到 `PUSH` 记录的地址(对于循环即条件节点,循环会继续),而 `BREAK_LOOP` 弹栈后直接跳到父层 Bubble 的最后一个哨兵 `NOP`,干净地结束整个循环。 + +### 编译时优化:\_is_single 分发 + +编译期通过 `_classify_body` 区分 payload 类型: + +| payload 类型 | `_is_single` | 机制 | +| ---------------------------------------- | ------------ | --------------------------------------- | +| `BaseNode` | `True` | `call_offset` 调用(自动返回) | +| `NodeCompose` / `SelfCompileInstruction` | `False` | 包裹 Bubble(编译器自动末尾 `RET_FAR`) | + +## NATIVE_IF + +### 导入 + +```python +from amrita_sense.instructions.native import NATIVE_IF +``` + +### 签名 + +```python +NATIVE_IF( + condition: Node[bool], + body: BaseNode | NodeCompose | SelfCompileInstruction, +) -> NativeIfClause +``` + +### 方法 + +| 方法 | 签名 | 说明 | +| ------------------------ | -------- | ------------------------------ | +| `.ELIF(condition, body)` | → `Self` | 追加 ELIF 分支,可多次链式调用 | +| `.ELSE(body)` | → `Self` | 追加 ELSE 分支,最多一次 | + +### 编译布局 + +#### 单 IF(Bubble 体) + +```mermaid +graph LR + jump["[0] NativeIfJumpNode"] + cond["[1] 条件节点"] + body["[2] body Bubble"] + nop["[3] NOP(汇合点)"] + jump --> cond --> body --> nop +``` + +#### IF-ELIF-ELSE 链 + +```mermaid +graph LR + if0["[0] NativeIfJumpNode"] + if_cond["[1] IF 条件"] + if_body["[2] IF body"] + elif0["[3] NativeIfJumpNode"] + elif_cond["[4] ELIF 条件"] + elif_body["[5] ELIF body"] + else_body["[6] ELSE body"] + nop["[merge] NOP"] + if0 --> if_cond + elif0 --> elif_cond + if0 -.->|false| elif0 + elif0 -.->|false| else_body + if_body -.->|RET_FAR| nop + elif_body -.->|RET_FAR| nop + else_body --> nop +``` + +ELSE 分支体不追加 `RET_FAR`,自然流入汇合点。 + +### 底层节点 + +- **`NativeIfJumpNode`**(`_core.py`):IF/ELIF 的条件跳转节点。`_is_single` 标志决定单节点(`call_offset`)还是 Bubble(`PUSH + jump_far_ptr`)路径。 + +## NATIVE_WHILE + +### 导入 + +```python +from amrita_sense.instructions.native import NATIVE_WHILE +``` + +### 签名 + +```python +NATIVE_WHILE( + condition: Node[bool], +) -> NativeWhileClause +``` + +### 方法 + +| 方法 | 签名 | 说明 | +| --------------- | -------- | ---------------------------- | +| `.ACTION(body)` | → `Self` | 设置循环体,必须调用且仅一次 | + +### 编译布局 + +```mermaid +graph LR + while["[0] NativeWhileNode"] + cond["[1] 条件节点"] + body["[2] body slot"] + nop["[3] NOP(出口)"] + while --> cond --> body --> nop +``` + +### 底层节点 + +- **`NativeWhileNode`**(`_core.py`):条件判断 + 分派节点。`_is_single=True` 时 `call_offset` body 后 `jump_near(self_pos)` 回到自己;`_is_single=False` 时 `PUSH self_pos` 后 `jump_far_ptr` 进入 Bubble,由 `RET_FAR` 弹栈返回。 + +## NATIVE_DO + +### 导入 + +```python +from amrita_sense.instructions.native import NATIVE_DO +``` + +### 签名 + +```python +NATIVE_DO( + body: BaseNode | NodeCompose | SelfCompileInstruction, +) -> NativeDoClause +``` + +### 方法 + +| 方法 | 签名 | 说明 | +| ------------------- | -------- | ------------------------------ | +| `.WHILE(condition)` | → `Self` | 设置循环条件,必须调用且仅一次 | + +### 编译布局 + +#### 单节点 + +```mermaid +graph LR + body["[0] body(单节点)"] + do["[1] NativeDoWhileNode"] + cond["[2] 条件节点"] + nop["[3] NOP"] + body --> do --> cond --> nop +``` + +#### Bubble 体 + +```mermaid +graph LR + enter["[0] NativeBubbleEnterNode"] + body["[1] body Bubble"] + do["[2] NativeDoWhileNode"] + cond["[3] 条件节点"] + nop["[4] NOP"] + enter --> body --> do --> cond --> nop +``` + +### 底层节点 + +- **`NativeDoWhileNode`**(`_core.py`):DO-WHILE 回边节点。条件真时 `jump_near(loop_pos)` 回到 body 入口(`NativeBubbleEnterNode` 处理重新进入);条件假时 `jump_near(exit_pos)` 跳到出口。 +- **`NativeBubbleEnterNode`**(`_core.py`):Bubble 入口辅助节点。若构造时传入 `ret_pos`,先 `PUSH ret_pos` 再 `JMP` 进入 Bubble;否则仅 `JMP`(ELSE 场景)。 + +## BREAK_LOOP + +### 导入 + +```python +from amrita_sense.instructions.native import BREAK_LOOP +``` + +### 签名 + +```python +BREAK_LOOP: _BreakLoopNode # 单例 +``` + +### 工作原理 + +1. 从 `pc._pointer.base_addr` 获取当前地址 `[a, b, c]` +2. `pc.get_graph().calc.find_addr([a])` 定位父层 Bubble → `NodeComposeRendered` +3. 计算目标:`len(父Bubble) - 1`(最后一个哨兵 NOP) +4. `pc._ret_addr_stack.pop()` 清理循环进入时压入的返回地址 +5. `pc.jump_far_ptr([a, target])` 跳到出口 + +### 使用约束 + +- **仅在 Bubble 体内有效**:单节点体通过 `return` 自然跳出 +- **必须位于原生循环体内**:地址深度不足会抛出 `RuntimeError` +- **跳出后继续正常执行**:父 Bubble 的下一个节点被正常推进 + +### 示例 + +```python +from amrita_sense.instructions.native import BREAK_LOOP, NATIVE_WHILE + +NATIVE_WHILE(cond).ACTION( + process_item + >> BREAK_LOOP + >> log_item +) +``` + +## 与传统指令对照 + +| | `IF` | `NATIVE_IF` | `WHILE` | `NATIVE_WHILE` | `DO` | `NATIVE_DO` | +| -------- | ------------------- | ----------------------------------- | ------------------- | ----------------------------------- | ------------------- | ----------------------------------- | +| 进入方式 | `call_sub` | `PUSH+JMP` 或 `call_offset` | `call_sub` | `PUSH+JMP` 或 `call_offset` | `call_sub` | `PUSH+JMP` 或 `call_offset` | +| 返回方式 | `call_sub` 自动返回 | `RET_FAR`(Bubble)或自动(单节点) | `call_sub` 自动返回 | `RET_FAR`(Bubble)或自动(单节点) | `call_sub` 自动返回 | `RET_FAR`(Bubble)或自动(单节点) | +| Break | `raise BreakLoop` | `BREAK_LOOP` | `raise BreakLoop` | `BREAK_LOOP` | `raise BreakLoop` | `BREAK_LOOP` | +| 中间件 | 触发 | 不触发 | 触发 | 不触发 | 触发 | 不触发 | +| DI 解析 | 触发 | 不触发 | 触发 | 不触发 | 触发 | 不触发 | + +## 注意事项 + +1. **Bubble 体必须 RET_FAR**:编译器自动在 Bubble 末尾追加 `RET_FAR`,但如果你手动构造 `NodeCompose` 作为分支体,需要确保末尾有 `RET_FAR` +2. **BREAK_LOOP 不是异常**:它是同步指针操作指令,`wrap_to_async=False`,不会触发异常处理流程 +3. **原生指令可混用**:与 `>>`、`NodeCompose` 和传统指令完全互操作 +4. **ELSE 不压栈**:`NATIVE_IF` 的 ELSE 分支通过 `NativeBubbleEnterNode` 自然流入,不 `PUSH` 返回地址(因为不需要返回) diff --git a/docs/zh/guide/advanced/native_control_flow.md b/docs/zh/guide/advanced/native_control_flow.md new file mode 100644 index 0000000..cc0dac8 --- /dev/null +++ b/docs/zh/guide/advanced/native_control_flow.md @@ -0,0 +1,200 @@ +# 原生控制流 + +AmritaSense v0.5.1 引入的**原生控制流指令集**——`NATIVE_IF`、`NATIVE_WHILE`、`NATIVE_DO` 和 `BREAK_LOOP`——是传统 `IF`/`WHILE`/`DO` 控制流指令的**正交扩展**。 + +## 设计理念 + +原生指令**不是**传统指令的性能替代品,而是它们的**正交扩展**。两者的核心差异在于底层实现模式: + +| | 传统指令 (`IF`/`WHILE`/`DO`) | 原生指令 (`NATIVE_IF`/`NATIVE_WHILE`/`NATIVE_DO`) | +| --------------- | ----------------------------------- | ------------------------------------------------- | +| 底层机制 | `call_sub`(锁 + 中间件 + DI 解析) | `PUSH / JMP / RET_FAR`(纯指针操作) | +| 分支体进入 | 解释器自动管理调用栈 | 开发者显式控制跳转和返回 | +| Bubble 体返回 | 自动(`call_sub` 自带返回) | 编译器自动追加 `RET_FAR` 兜底 | +| 循环跳出 | 抛出 `BreakLoop` 异常 | `BREAK_LOOP` 指令(弹栈 + 跳到出口哨兵) | +| 提前退出 Bubble | N/A | 手动 `RET_FAR`(编译器总在末尾兜底,可选) | +| 适用场景 | 常规控制流,开箱即用 | 需要精确控制指针、跳过中间件/DI 的场景 | + +> **RET_FAR 与 BREAK_LOOP 的职责分离**: +> +> - **`RET_FAR`**:Bubble 执行的终点。编译器**总是**在每个 Bubble 体的末尾自动插入一个 `RET_FAR` 作为兜底——你无需手动编写。如果你想提前退出 Bubble(不执行后续节点),可以在中途手动插入 `RET_FAR`。 +> - **`BREAK_LOOP`**:专用于终止循环。它弹掉循环进入时压入的返回地址,然后直接跳到父层 Bubble 的出口哨兵(`NOP`),干净地结束整个循环。 +> +> 如果没有 `BREAK_LOOP`,循环体内的 `RET_FAR` 只会弹栈并返回到循环条件节点,导致循环继续——它无法终止循环。 + +## NATIVE_IF + +`NATIVE_IF` 的 API 与传统 `IF` 完全一致,支持 `ELIF` / `ELSE` 链式调用: + +```python +NATIVE_IF(cond, body) # 纯 IF +NATIVE_IF(cond, body).ELSE(else_body) # IF-ELSE +NATIVE_IF(cond, body).ELIF(cond2, body2) # IF-ELIF 链 +NATIVE_IF(cond, body).ELIF(cond2, body2).ELSE(else_body) # 完整链 +``` + +### 单节点分支体 + +当 `body` 是单个 `BaseNode` 时,编译期识别为 `_is_single=True`,分支体通过 `call_offset` 调用——零额外开销,行为与传统指令一致: + +```python +NATIVE_IF(check_condition, my_action).ELSE(my_fallback) +``` + +编译布局(以单 IF 为例): + +```mermaid +graph LR + jump["[0] NativeIfJumpNode"] + cond["[1] 条件节点"] + body["[2] body(单节点)"] + nop["[3] NOP(汇合点)"] + jump --> cond --> body --> nop +``` + +### Bubble 分支体 + +当 `body` 是 `NodeCompose` 时,编译器将其包裹为 **Bubble**,自动在末尾追加 `RET_FAR` 作为兜底(你无需手动编写): + +```python +NATIVE_IF(cond, step_a >> step_b).ELSE(fallback_a >> fallback_b) +``` + +编译布局: + +```mermaid +graph LR + jump["[0] NativeIfJumpNode"] + cond["[1] 条件节点"] + body["[2] body Bubble"] + nop["[3] NOP(汇合点)"] + jump --> cond --> body --> nop +``` + +执行流程:条件为真 → `PUSH` 汇合点地址 → `JMP` 进入 Bubble → 执行完毕后 `RET_FAR` 弹栈回到汇合点。 + +### ELIF / ELSE 链展开 + +每个 `ELIF` 在编译期追加一组 `[NativeIfJumpNode, cond, body_slot]` 三元组。`ELSE` 分支体通过 `NativeBubbleEnterNode`(无 `ret_pos`,不压栈)自然流入汇合点。 + +## NATIVE_WHILE + +`NATIVE_WHILE` 采用与传统 `WHILE` 相同的语义——**先判断条件,再执行循环体**: + +```python +NATIVE_WHILE(condition).ACTION(body) +``` + +### 单节点循环体 + +```python +NATIVE_WHILE(check_alive).ACTION(heartbeat) +``` + +编译布局: + +```mermaid +graph LR + while["[0] NativeWhileNode"] + cond["[1] 条件节点"] + body["[2] body(单节点)"] + nop["[3] NOP(出口)"] + while --> cond --> body --> nop +``` + +`call_offset` 调用条件 → 真:`call_offset` 调用 body → `jump_near(0)` 回到 while 节点重新判断 → 假:`jump_near(3)` 跳到出口。 + +### Bubble 循环体 + +```python +NATIVE_WHILE(cond).ACTION(step_a >> step_b) +``` + +编译器在 Bubble 末尾追加 `RET_FAR`。运行时 `NativeWhileNode` 在进入 Bubble 前 `PUSH` 自身地址 `[0]`,这样 `RET_FAR` 弹栈后回到条件重新判断。 + +### 跳出循环:BREAK_LOOP + +`WHILE` 的 Bubble 体内不能像传统 `WHILE` 那样抛 `BreakLoop` 异常——原生指令没有包裹 try/except。取而代之的是 **`BREAK_LOOP` 指令**: + +```python +NATIVE_WHILE(cond).ACTION( + step_a + >> BREAK_LOOP + >> step_b +) +``` + +`BREAK_LOOP` 的工作原理: + +1. 获取当前指针地址 `[a, b, c]` +2. 寻址父层 Bubble `[a]` 得到 `NodeComposeRendered` +3. 计算目标地址:`len(父Bubble) - 1`(父层最后一个节点,即 `NOP` 出口) +4. `_ret_addr_stack.pop()` 清理 `NativeWhileNode` 压入的返回地址 +5. `jump_far_ptr` 跳到出口 + +> 单节点循环体不需要 `BREAK_LOOP`——在 body 函数中 `return` 即自然结束本轮,条件假时跳出。 + +## NATIVE_DO + +`NATIVE_DO` 确保**循环体至少执行一次**,然后检查条件决定是否继续: + +```python +NATIVE_DO(body).WHILE(condition) +``` + +### 单节点循环体 + +```python +NATIVE_DO(send_request).WHILE(should_retry) +``` + +编译布局: + +```mermaid +graph LR + body["[0] body(单节点)"] + do["[1] NativeDoWhileNode"] + cond["[2] 条件节点"] + nop["[3] NOP(出口)"] + body --> do --> cond --> nop +``` + +`NativeDoWhileNode` 构造参数:`condi_offset=1, loop_pos=0, exit_pos=3`。 + +body 先执行 → `call_offset` 调用条件 → 真:`jump_near(0)` 回到 body → 假:`jump_near(3)` 离开。 + +### Bubble 循环体 + +```python +NATIVE_DO(step_a >> step_b).WHILE(cond) +``` + +编译布局: + +```mermaid +graph LR + enter["[0] NativeBubbleEnterNode"] + body["[1] body Bubble"] + do["[2] NativeDoWhileNode"] + cond["[3] 条件节点"] + nop["[4] NOP(出口)"] + enter --> body --> do --> cond --> nop +``` + +初次进入:`NativeBubbleEnterNode` PUSH `[2]`(do-while 节点地址),JMP 进入 body Bubble。 +循环重入:条件真 → `NativeDoWhileNode` 无条件 `jump_near(0)`,再次触发 `NativeBubbleEnterNode` PUSH + JMP。 +循环退出:条件假 → `jump_near(4)` 到 NOP 出口。或者 `BREAK_LOOP` 主动跳出。 + +> **语义对齐**:v0.5.1 重构后,DO 和 WHILE 的 Bubble 体行为完全一致——两者都需要 `PUSH` + `RET_FAR`,`BREAK_LOOP` 统一弹栈跳出。 + +## 选用指南 + +| 场景 | 推荐 | +| ----------------------------------- | -------------------------------------------- | +| 常规控制流,需要依赖注入/中间件 | 传统 `IF` / `WHILE` / `DO` | +| 高性能敏感路径,跳过多余开销 | `NATIVE_*` 单节点模式 | +| 需要精确控制指针跳转行为 | `NATIVE_*` Bubble 模式 | +| 需要异常穿透(`exception_ignored`) | 传统指令 | +| 循环内需要条件跳出 | 传统:`raise BreakLoop` / 原生:`BREAK_LOOP` | + +原生指令与传统指令可以**在同一工作流中自由混用**——它们是同一套指针向量和调用栈体系上的不同抽象层级。 diff --git a/docs/zh/guide/concepts/flow_control.md b/docs/zh/guide/concepts/flow_control.md index 14c6ac2..530a7cf 100644 --- a/docs/zh/guide/concepts/flow_control.md +++ b/docs/zh/guide/concepts/flow_control.md @@ -103,4 +103,8 @@ pc = WorkflowPC(nd, exception_ignored=(CriticalError,)) ### 小结 -AmritaSense 的流程控制体系,从条件分支、循环结构到异常处理,完整覆盖了结构化编程的所有核心范式。这些能力不是通过外部 DSL 或图拓扑“模拟”出来的,而是直接编码在指令集和解释器中的一等公民。在下一章中,我们将探讨这些流程在运行时的执行机制与中断控制。 +AmritaSense 的流程控制体系,从条件分支、循环结构到异常处理,完整覆盖了结构化编程的所有核心范式。这些能力不是通过外部 DSL 或图拓扑"模拟"出来的,而是直接编码在指令集和解释器中的一等公民。 + +> **延伸阅读**:v0.5.1 引入了原生控制流指令集(`NATIVE_IF` / `NATIVE_WHILE` / `NATIVE_DO` / `BREAK_LOOP`),作为传统指令的**正交扩展**。详见 [原生控制流](../advanced/native_control_flow.md)。 + +在下一章中,我们将探讨这些流程在运行时的执行机制与中断控制。 diff --git a/docs/zh/guide/introduction/index.md b/docs/zh/guide/introduction/index.md index fff3871..07f8da1 100644 --- a/docs/zh/guide/introduction/index.md +++ b/docs/zh/guide/introduction/index.md @@ -14,7 +14,7 @@ 我们认为:**工作流应该为“工作”设计,而不是被“流”所局限**。“流”只是一种呈现形式,不应当成为你设计逻辑时的枷锁。 -- **原生图灵完备**:你无需在程序内部定义各种复杂的边界条件,就能实现完整、任意的控制逻辑。AmritaSense 原生支持条件分支(`IF/ELIF/ELSE`)、循环(`WHILE/DO-WHILE`)、无条件跳转(`GOTO`)、子程序调用(`CALL`)和异常处理(`TRY/CATCH`),不依赖外部图引擎或状态机。 +- **原生图灵完备**:你无需在程序内部定义各种复杂的边界条件,就能实现完整、任意的控制逻辑。AmritaSense 原生支持条件分支(`IF/ELIF/ELSE`)、循环(`WHILE/DO-WHILE`)、无条件跳转(`GOTO`)、子程序调用(`CALL`)和异常处理(`TRY/CATCH`),不依赖外部图引擎或状态机。v0.5.1 新增了原生控制流指令(`NATIVE_IF` / `NATIVE_WHILE` / `NATIVE_DO` / `BREAK_LOOP`)作为正交扩展。 - **虚拟机寻址模型**:AmritaSense 使用类似经典计算机的**寻址与执行模型**(`PointerVector` + 调用栈),所有高级控制流在编译期就被展开为统一的指针指令。运行时只有整数运算和函数调用,调度开销几乎为零。 - **为 AI 智能体与复杂业务而生**:无论是工具调用循环、嵌套子工作流、挂起等待用户输入,还是异常恢复与回退,AmritaSense 都能以最直接的方式表达,并以极高的效率执行。 diff --git a/docs/zh/guide/introduction/key-features.md b/docs/zh/guide/introduction/key-features.md index f5ae221..8208248 100644 --- a/docs/zh/guide/introduction/key-features.md +++ b/docs/zh/guide/introduction/key-features.md @@ -23,6 +23,8 @@ AmritaSense 提供了一级公民的控制流指令集,无需依赖外部图 - **跳转指令**:`GOTO` 配合 `ALIAS` 实现无条件跳转,`CALL` 配合 `ARCHIVED_NODES` 实现子程序调用与返回 - **异常处理**:`TRY...CATCH...THEN...FIN`,完整对齐 Python 的异常处理语义,支持异常穿透控制 +v0.5.1 新增了原生控制流指令 `NATIVE_IF` / `NATIVE_WHILE` / `NATIVE_DO` / `BREAK_LOOP`,作为传统指令的**正交扩展**,提供基于 `PUSH/JMP/RET_FAR` 的更底层控制流,可在需要精确指针控制的场景中使用。 + 所有控制流指令在编译期展开为底层节点组合,运行时完全通过指针偏移完成,无需图遍历、字符串路由或状态字典查找。 ## 1.2.4 异步原生 diff --git a/src/amrita_sense/instructions/__init__.py b/src/amrita_sense/instructions/__init__.py index db9bafb..47cb084 100644 --- a/src/amrita_sense/instructions/__init__.py +++ b/src/amrita_sense/instructions/__init__.py @@ -6,7 +6,7 @@ from .jump import GOTO from .loop.do_while import DO from .loop.while_clause import WHILE -from .native import NATIVE_DO, NATIVE_IF, NATIVE_WHILE +from .native import BREAK_LOOP, NATIVE_DO, NATIVE_IF, NATIVE_WHILE from .ret2 import PUSH_AND_GOTO, PUSH_STACK, RET_FAR from .subprogram import ARCHIVED_NODES, CALL from .trigger_event import TRIGGER_EVENT @@ -17,6 +17,7 @@ "ALIAS", "ARCHIVED_NODES", "BATCH_RUN", + "BREAK_LOOP", "CALL", "DO", "FUN_BLOCK", diff --git a/src/amrita_sense/instructions/native/__init__.py b/src/amrita_sense/instructions/native/__init__.py index 89e0093..66abe4b 100644 --- a/src/amrita_sense/instructions/native/__init__.py +++ b/src/amrita_sense/instructions/native/__init__.py @@ -8,8 +8,9 @@ additional overhead. """ +from .break_loop import BREAK_LOOP from .do_native import NATIVE_DO from .if_native import NATIVE_IF from .while_native import NATIVE_WHILE -__all__ = ("NATIVE_DO", "NATIVE_IF", "NATIVE_WHILE") +__all__ = ("BREAK_LOOP", "NATIVE_DO", "NATIVE_IF", "NATIVE_WHILE") diff --git a/src/amrita_sense/instructions/native/_core.py b/src/amrita_sense/instructions/native/_core.py index 606542c..8bd763a 100644 --- a/src/amrita_sense/instructions/native/_core.py +++ b/src/amrita_sense/instructions/native/_core.py @@ -216,9 +216,14 @@ async def __call__(self, pc: WorkflowInterpreter) -> None: class NativeDoWhileNode(BaseNode): - """DO‑WHILE back-edge node. Body is reached naturally (or via - ``NativeDoFastEnterNode`` for bubbles); this node only checks the - condition and loops back or falls through. + """DO‑WHILE back-edge node with unified PUSH+RET_FAR semantics. + + Single-node path: ``call_offset`` condition → True: ``jump_near(body_pos)`` + loop back; False: ``jump_near(exit_pos)``. + + Bubble path: condition true → ``jump_near(body_pos)`` which hits + ``NativeBubbleEnterNode`` (PUSH+JMP into the body bubble). The body + bubble ends with ``RET_FAR``, returning here to re‑evaluate. """ tag: str @@ -258,7 +263,8 @@ def __init__(self, condi_offset: int, loop_pos: int, exit_pos: int) -> None: async def __call__(self, pc: WorkflowInterpreter) -> None: if await pc.call_offset(self._condi_offset): pc.jump_near(self._loop_pos) - # False: natural advance to exit + else: + pc.jump_near(self._exit_pos) # NativeBubbleEnterNode (bubble entry helper for DO / ELSE) @@ -270,6 +276,10 @@ class NativeBubbleEnterNode(BaseNode): Used when a native instruction's body is a ``NodeCompose`` that must be reached via a jump (DO-body, ELSE-body). The single‑node path reaches the body naturally without this hop. + + When *ret_pos* is provided (DO loops), a return address is pushed onto + ``_ret_addr_stack`` before entering the bubble so that ``RET_FAR`` (or + ``BREAK_LOOP``) can return/break to the correct position. """ tag: str @@ -280,9 +290,11 @@ class NativeBubbleEnterNode(BaseNode): fun_sign: DependencyMeta _body_pos: int + _ret_pos: int | None __slots__ = ( "_body_pos", + "_ret_pos", "address_able", "fun_frame", "fun_sign", @@ -291,7 +303,7 @@ class NativeBubbleEnterNode(BaseNode): "wrap_to_async", ) - def __init__(self, body_pos: int) -> None: + def __init__(self, body_pos: int, ret_pos: int | None = None) -> None: frame = inspect.currentframe() if not frame: raise RuntimeError("No frame found") @@ -299,7 +311,10 @@ def __init__(self, body_pos: int) -> None: self.__call__, tag=None, wrap_to_async=False, address_able=True, frame=frame ) self._body_pos = body_pos + self._ret_pos = ret_pos def __call__(self, pc: WorkflowInterpreter) -> None: parent = list(pc._pointer.base_addr[:-1]) + if self._ret_pos is not None: + pc._ret_addr_stack.push(PointerVector([*parent, self._ret_pos])) pc.jump_far_ptr([*parent, self._body_pos, 0]) diff --git a/src/amrita_sense/instructions/native/break_loop.py b/src/amrita_sense/instructions/native/break_loop.py new file mode 100644 index 0000000..aa1c819 --- /dev/null +++ b/src/amrita_sense/instructions/native/break_loop.py @@ -0,0 +1,70 @@ +"""BREAK_LOOP — pop stack and jump to the sentinel NOP of the current bubble. + +Usage:: + + from amrita_sense.instructions.native import BREAK_LOOP + + comp = NATIVE_WHILE(cond).ACTION(NodeCompose(step1, BREAK_LOOP, step2)) + comp = NATIVE_DO(NodeCompose(step1, BREAK_LOOP, step2)).WHILE(cond) + +``BREAK_LOOP`` only makes sense inside a native loop **bubble** body. +It pops the return address that was pushed by the loop's enter node and +jumps to the last sentinel (NOP) of the parent bubble, cleanly exiting +the loop. +""" + +from __future__ import annotations + +import inspect + +from amrita_sense.node.core import BaseNode, NodeComposeRendered +from amrita_sense.runtime.workflow import WorkflowInterpreter + + +class _BreakLoopNode(BaseNode): + """Pop stack and jump to parent bubble's sentinel NOP.""" + + __slots__ = () + + def __init__(self) -> None: + frame = inspect.currentframe() + if not frame: + raise RuntimeError("No frame found") + self._init( + self.__call__, + tag="__BREAK_LOOP__", + wrap_to_async=False, + address_able=True, + frame=frame, + ) + + def __call__(self, pc: WorkflowInterpreter) -> None: + addr: list[int] = list(pc._pointer.base_addr) + if len(addr) < 2: + raise RuntimeError( + "BREAK_LOOP must be used inside a native loop bubble body, " + f"got address {addr} (too shallow)" + ) + + # addr[:-2] is the coordinate of the *parent* bubble (the one + # containing the loop structure). We jump to its last sentinel + # (NOP) to cleanly exit the loop. + parent_addr: list[int] = addr[:-2] + parent_bubble = pc.get_graph().calc.find_addr(parent_addr) + if not isinstance(parent_bubble, NodeComposeRendered): + raise RuntimeError( + f"BREAK_LOOP: expected NodeComposeRendered at {parent_addr}, " + f"got {type(parent_bubble).__name__}" + ) + + target: int = len(parent_bubble) - 1 + pc._ret_addr_stack.pop() + pc.jump_far_ptr([*parent_addr, target]) + + +BREAK_LOOP: _BreakLoopNode = _BreakLoopNode() +"""Break out of a native WHILE/DO bubble body. + +Pops the return address pushed by the loop's enter node and jumps to the +last sentinel (NOP) of the parent bubble. +""" diff --git a/src/amrita_sense/instructions/native/do_native.py b/src/amrita_sense/instructions/native/do_native.py index 37a4cb3..3062d03 100644 --- a/src/amrita_sense/instructions/native/do_native.py +++ b/src/amrita_sense/instructions/native/do_native.py @@ -1,4 +1,4 @@ -"""Native DO-WHILE — natural-flow fast-path loop. +"""Native DO-WHILE — jump+RET_FAR fast-path loop. Usage:: @@ -6,10 +6,9 @@ ``body`` accepts: -* ``BaseNode`` — single node, reached naturally (no extra hop). -* ``NodeCompose`` | ``SelfCompileInstruction`` — wrapped with a - ``NativeBubbleEnterNode`` so the engine enters the bubble, then - exits via the normal ``advance_pointer`` mechanism (no ``RET_FAR``). +* ``BaseNode`` — single node, ``call_offset`` + ``jump_near`` loop. +* ``NodeCompose`` | ``SelfCompileInstruction`` — wrapped into a **bubble** + with automatic ``RET_FAR`` that jumps back to re‑evaluate the condition. """ from __future__ import annotations @@ -21,6 +20,7 @@ NativeDoWhileNode, _classify_body, ) +from amrita_sense.instructions.ret2 import RET_FAR from amrita_sense.instructions.workfl_ctrl import NOP from amrita_sense.node.core import BaseNode, Node, NodeCompose from amrita_sense.node.self_compile import SelfCompileInstruction @@ -31,7 +31,11 @@ class NativeDoClause(SelfCompileInstruction): **Single-node layout:** ``[0]`` body, ``[1]`` NativeDoWhileNode, ``[2]`` cond, ``[3]`` NOP exit. - **Bubble layout:** ``[0]`` NativeBubbleEnterNode, ``[1]`` body bubble, ``[2]`` NativeDoWhileNode, ``[3]`` cond, ``[4]`` NOP exit. + **Bubble layout:** ``[0]`` NativeBubbleEnterNode, ``[1]`` body bubble (+RET_FAR), + ``[2]`` NativeDoWhileNode, ``[3]`` cond, ``[4]`` NOP exit. + + The bubble variant pushes ``[2]`` so that ``RET_FAR`` returns to the + do-while node to re‑evaluate the condition. """ _body: BaseNode | NodeCompose | SelfCompileInstruction @@ -74,11 +78,11 @@ def extract(self) -> NodeCompose: NOP, ) - # Bubble body: [0]=enter, [1]=flat_bubble, [2]=do_while, [3]=cond, [4]=NOP + # Bubble body: [0]=enter(PUSH→[2]), [1]=body+RET_FAR, [2]=do_while, [3]=cond, [4]=NOP assert isinstance(body, NodeCompose) return NodeCompose( - NativeBubbleEnterNode(body_pos=1), - NodeCompose(*body._graph), + NativeBubbleEnterNode(body_pos=1, ret_pos=2), + NodeCompose(*body._graph, RET_FAR()), NativeDoWhileNode( condi_offset=1, loop_pos=0, diff --git a/src/amrita_sense/runtime/workflow.py b/src/amrita_sense/runtime/workflow.py index 641288e..4f6036a 100644 --- a/src/amrita_sense/runtime/workflow.py +++ b/src/amrita_sense/runtime/workflow.py @@ -1013,7 +1013,8 @@ def find_node_alias(self, alias: str) -> BaseNode | NodeComposeRendered: ) @deprecated( - "This method is no longer used, please use '.calc.find_addr(addr)' instead!" + "This method is no longer used, please use '.calc.find_addr(addr)' instead!", + category=DeprecationWarning, ) def find_addr(self, addr: list[int]) -> BaseNode | NodeComposeRendered: """Find a node at the specified address. @@ -1030,7 +1031,8 @@ def find_addr(self, addr: list[int]) -> BaseNode | NodeComposeRendered: return self.get_graph().calc.find_addr(addr) @deprecated( - "This method is no longer used, please use '.calc.resolve_alias(addr)' instead!" + "This method is no longer used, please use '.calc.resolve_alias(addr)' instead!", + category=DeprecationWarning, ) def find_addr_alias(self, alias: str) -> list[int]: """Find the address vector for a node by its alias. diff --git a/tests/test_native_break_loop.py b/tests/test_native_break_loop.py new file mode 100644 index 0000000..458ea36 --- /dev/null +++ b/tests/test_native_break_loop.py @@ -0,0 +1,151 @@ +"""Tests for BREAK_LOOP in native WHILE/DO bubble bodies.""" + +import pytest + +from amrita_sense import Node, NodeCompose, WorkflowInterpreter +from amrita_sense.instructions.native import BREAK_LOOP, NATIVE_DO, NATIVE_WHILE + + +class TestWhileBreakLoop: + @pytest.mark.asyncio + async def test_while_break_exits_loop(self): + results = [] + counter = [0] + + @Node(wrap_to_async=False) + def cond() -> bool: + return counter[0] < 10 + + @Node(wrap_to_async=False) + def step_a() -> None: + counter[0] += 1 + results.append(f"a{counter[0]}") + + @Node(wrap_to_async=False) + def step_b() -> None: + results.append(f"b{counter[0]}") + + @Node(wrap_to_async=False) + def after() -> None: + results.append("after") + + comp = ( + NATIVE_WHILE(cond).ACTION(NodeCompose(step_a, BREAK_LOOP, step_b)) >> after + ).render() + await WorkflowInterpreter(comp).run() + assert results == ["a1", "after"] + + @pytest.mark.asyncio + async def test_while_single_node_body_no_break_needed(self): + counter = [0] + + @Node(wrap_to_async=False) + def cond() -> bool: + return counter[0] < 3 + + @Node(wrap_to_async=False) + def body() -> None: + counter[0] += 1 + + comp = NATIVE_WHILE(cond).ACTION(body).extract().render() + await WorkflowInterpreter(comp).run() + assert counter[0] == 3 + + +class TestDoBreakLoop: + @pytest.mark.asyncio + async def test_do_break_exits_loop(self): + results = [] + counter = [0] + + @Node(wrap_to_async=False) + def cond() -> bool: + return True + + @Node(wrap_to_async=False) + def step_a() -> None: + counter[0] += 1 + results.append(f"a{counter[0]}") + + @Node(wrap_to_async=False) + def step_b() -> None: + results.append(f"b{counter[0]}") + + @Node(wrap_to_async=False) + def after() -> None: + results.append("after") + + comp = ( + NATIVE_DO(NodeCompose(step_a, BREAK_LOOP, step_b)).WHILE(cond).extract() + >> after + ).render() + await WorkflowInterpreter(comp).run() + assert results == ["a1", "after"] + + @pytest.mark.asyncio + async def test_do_break_then_continues_after(self): + results = [] + + @Node(wrap_to_async=False) + def cond() -> bool: + return True + + @Node(wrap_to_async=False) + def body() -> None: + results.append("body") + + @Node(wrap_to_async=False) + def after() -> None: + results.append("after") + + comp = ( + NATIVE_DO(NodeCompose(body, BREAK_LOOP)).WHILE(cond).extract() >> after + ).render() + await WorkflowInterpreter(comp).run() + assert results == ["body", "after"] + + @pytest.mark.asyncio + async def test_do_normal_loop_without_break(self): + counter = [0] + + @Node(wrap_to_async=False) + def body() -> None: + counter[0] += 1 + + @Node(wrap_to_async=False) + def cond() -> bool: + return counter[0] < 3 + + comp = NATIVE_DO(body).WHILE(cond).extract().render() + await WorkflowInterpreter(comp).run() + assert counter[0] == 3 + + +class TestBreakLoopContinues: + @pytest.mark.asyncio + async def test_while_break_many_nodes_after(self): + results = [] + counter = [0] + + @Node(wrap_to_async=False) + def cond() -> bool: + return counter[0] < 10 + + @Node(wrap_to_async=False) + def body() -> None: + counter[0] += 1 + results.append(f"body{counter[0]}") + + @Node(wrap_to_async=False) + def n1() -> None: + results.append("n1") + + @Node(wrap_to_async=False) + def n2() -> None: + results.append("n2") + + comp = ( + NATIVE_WHILE(cond).ACTION(NodeCompose(body, BREAK_LOOP)) >> n1 >> n2 + ).render() + await WorkflowInterpreter(comp).run() + assert results == ["body1", "n1", "n2"] From 2e94faf811e8bbfad1285b249d3888209e98a256 Mon Sep 17 00:00:00 2001 From: RAX4096 Date: Tue, 28 Jul 2026 21:18:50 +0800 Subject: [PATCH 6/8] CI/CD: add native-instructions benchmark --- benchmark.py | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/benchmark.py b/benchmark.py index c02b450..5b75516 100644 --- a/benchmark.py +++ b/benchmark.py @@ -33,6 +33,7 @@ from amrita_sense.instructions.batch import BATCH_RUN from amrita_sense.instructions.func_block import FUN_BLOCK from amrita_sense.instructions.loop.while_clause import WHILE +from amrita_sense.instructions.native import NATIVE_DO, NATIVE_IF, NATIVE_WHILE from amrita_sense.instructions.workfl_ctrl import NOP from amrita_sense.utils import TimeInsighter @@ -388,6 +389,92 @@ def bench_batch_run_forks() -> Result: ) +# Native instruction benchmarks + + +@benchmark +def bench_native_if_chain() -> Result: + """Native-IF chain (100 ELIF)""" + depth = 100 + + @Node() + def _body() -> None: + pass + + conds = [ + NodeType(lambda: False, wrap_to_async=False, address_able=False, tag=None) + for _ in range(depth) + ] + conds[-1] = NodeType( + lambda: True, wrap_to_async=False, address_able=False, tag=None + ) + + chain = NATIVE_IF(conds[0], _body) # type: ignore[arg-type] + for c in conds[1:]: + chain = chain.ELIF(c, _body) # type: ignore[arg-type] + chain = chain.ELSE(_body).extract() + + rendered, cs = _sense_compile(chain) + es = _sense_exec(rendered) + + return Result( + "Native-IF (100 ELIF)", + sense_compile_s=cs, + sense_exec_s=es, + extra={"depth": depth}, + ) + + +@benchmark +def bench_native_while_tight() -> Result: + """Native-WHILE tight loop""" + counter = [0] + + @Node() + def body() -> None: + counter[0] += 1 + + @Node() + def check() -> bool: + return counter[0] < LOOP_ITERS + + wf = NATIVE_WHILE(check).ACTION(body).extract() + rendered, cs = _sense_compile(wf) + es = _sense_exec(rendered) + + return Result( + "Native-WHILE tight", + sense_compile_s=cs, + sense_exec_s=es, + extra={"iters": LOOP_ITERS}, + ) + + +@benchmark +def bench_native_do_tight() -> Result: + """Native-DO tight loop""" + counter = [0] + + @Node() + def body() -> None: + counter[0] += 1 + + @Node() + def check() -> bool: + return counter[0] < LOOP_ITERS + + wf = NATIVE_DO(body).WHILE(check).extract() + rendered, cs = _sense_compile(wf) + es = _sense_exec(rendered) + + return Result( + "Native-DO tight", + sense_compile_s=cs, + sense_exec_s=es, + extra={"iters": LOOP_ITERS}, + ) + + # Entry point if __name__ == "__main__": From e1ea260caf7ffa718720a9bfc196b9946ba69668 Mon Sep 17 00:00:00 2001 From: RAX4096 Date: Tue, 28 Jul 2026 21:22:57 +0800 Subject: [PATCH 7/8] misc of comments --- src/amrita_sense/hook/matcher.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/amrita_sense/hook/matcher.py b/src/amrita_sense/hook/matcher.py index cd85a43..95d692f 100644 --- a/src/amrita_sense/hook/matcher.py +++ b/src/amrita_sense/hook/matcher.py @@ -142,16 +142,20 @@ def dead(self) -> bool: class DependsFactory(Generic[T]): """ Dependency factory class. + + .. note:: + ``cacheable`` is reserved for v0.5.2 and currently has no effect. """ _depency_func: Callable[..., T | Awaitable[T]] _sign: DependencyMeta - __cacheable: bool + __cacheable: bool # reserved for v0.5.2, currently unused __slots__ = ("__cacheable", "_depency_func", "_sign") @property def cacheable(self) -> bool: + """Reserved for v0.5.2 — currently returns the stored value but has no runtime effect.""" return self.__cacheable def __init__( @@ -194,13 +198,16 @@ def Depends( ) -> Any: """Dependency injection decorator. - *NOTE*: Cacheing is only available for workflows not event matchers. + .. note:: + The *cacheable* parameter is **not yet effective** — DI result + caching is planned for v0.5.2. - **IMPORTANT**: For database sessions(or ORM frameworks like SQLAlchemy), DI-Cache may cause the leaks of database connections. + **IMPORTANT**: For database sessions (or ORM frameworks like SQLAlchemy), + DI-caching may cause connection leaks. Args: - dependency: The dependency function to inject - cacheable (bool, optional): Whether to cache the resolved dependency. Defaults to False. + dependency: The dependency function to inject. + cacheable: Reserved for v0.5.2 DI result caching. Currently ignored. Returns: DependsFactory: A factory for dependency injection @@ -215,7 +222,7 @@ async def a_function_with_dependencies( dep: ExampleDependency = Depends(get_example_dependency), ): ... - # If DependendsFactory's return is None, this function won't be called. + # If DependsFactory's return is None, this function won't be called. ``` """ return DependsFactory[T](dependency, cacheable) From 5f7f4f0cf10d0a127212d65738197099503cb2ed Mon Sep 17 00:00:00 2001 From: RAX4096 Date: Tue, 28 Jul 2026 22:18:02 +0800 Subject: [PATCH 8/8] RUFF: suppress RUF036 --- .github/workflows/publish.yml | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ead5dad..4fdae77 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,7 +2,7 @@ name: Publish to PyPI on Release on: release: - types: [created] # 当有新Release创建时触发 + types: [created] jobs: release: diff --git a/pyproject.toml b/pyproject.toml index bba5eb1..e542b1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ ignore = [ "RUF001", # ambiguous-unicode-character-string "RUF002", # ambiguous-unicode-character-docstring "RUF003", # ambiguous-unicode-character-comment + "RUF036", # none-at-the-end-of-type-union ] [tool.ruff.lint.per-file-ignores] "demos/*.py" = ["ASYNC250"]