Просмотр исходного кода

feat: Paper III effect algebra + annotations on all 11 constructs (P0-2)

Implements the effect system from Paper III §4:
- Effect types: pure, llm(model), io, state(keys)
- Effect composition: serial (·), parallel (∥), iterate (εⁿ)
- Effect subtype lattice: pure ≤ state ≤ io ≤ llm (Definition 9)
- Auto-inference for all 11 constructs (Lam→llm, Tool→io, Compose→serial, etc.)
- parse_effect_annotation() for YAML effectAnnotation field
- Term.effect property with lazy inference and manual override
- AgentType now includes effect in repr: Str →^llm(claude) Int
- 35 passing tests + 44 prior type tests = 79 total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
kenny67nju 6 месяцев назад
Родитель
Сommit
0f54eed16c
4 измененных файлов с 712 добавлено и 1 удалено
  1. 13 0
      lambdagent/__init__.py
  2. 18 1
      lambdagent/core.py
  3. 386 0
      lambdagent/effects.py
  4. 295 0
      lambdagent/tests/test_effects.py

+ 13 - 0
lambdagent/__init__.py

@@ -87,6 +87,13 @@ from .types import (
     T_ANY, T_NONE, T_STR, T_INT, T_FLOAT, T_BOOL, T_JSON, T_TUPLE, T_UNION,
     is_subtype, check_compose_types, parse_type_annotation, infer_type_from_value,
 )
+from .effects import (
+    Effect, EffectKind, ComposedEffect,
+    PURE, IO, LLM, STATE,
+    serial, parallel, iterate,
+    effect_leq, max_effect,
+    parse_effect_annotation, infer_effect_for_term,
+)
 # Phase 1: P0 Engineering Improvements
 from .cancellation import CancellationToken, CancelledError, NullCancellationToken
 from .retry import RetryPolicy, CircuitBreaker, CircuitOpenError, with_retry, with_retry_sync
@@ -172,6 +179,12 @@ __all__ = [
     "T_ANY", "T_NONE", "T_STR", "T_INT", "T_FLOAT", "T_BOOL",
     "T_JSON", "T_TUPLE", "T_UNION",
     "is_subtype", "check_compose_types", "parse_type_annotation", "infer_type_from_value",
+    # Paper III: 效果系统
+    "Effect", "EffectKind", "ComposedEffect",
+    "PURE", "IO", "LLM", "STATE",
+    "serial", "parallel", "iterate",
+    "effect_leq", "max_effect",
+    "parse_effect_annotation", "infer_effect_for_term",
     # 辅助设施
     "Dataset",
     "from_config", "build_agent", "describe_config",

+ 18 - 1
lambdagent/core.py

@@ -17,6 +17,7 @@ from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
 
 if TYPE_CHECKING:
     from .types import LamType, AgentType
+    from .effects import Effect, ComposedEffect
 
 
 # ============================================================
@@ -143,6 +144,8 @@ class Term(ABC):
         # Paper III: 类型标注 (默认 Any → Any)
         self._input_type: LamType | None = None
         self._output_type: LamType | None = None
+        # Paper III: 效果标注 (默认 None = 需要推断)
+        self._effect: Effect | ComposedEffect | None = None
 
     # ── Paper III: 类型标注属性 ──
 
@@ -170,11 +173,25 @@ class Term(ABC):
     def output_type(self, t: LamType):
         self._output_type = t
 
+    @property
+    def effect(self) -> Effect | ComposedEffect:
+        """Agent 的效果标注 (Paper III Definition 6-7)"""
+        if self._effect is not None:
+            return self._effect
+        from .effects import infer_effect_for_term
+        return infer_effect_for_term(self)
+
+    @effect.setter
+    def effect(self, e: Effect | ComposedEffect):
+        self._effect = e
+
     @property
     def agent_type(self) -> AgentType:
         """完整的 Agent 函数类型 τ1 →^ε τ2"""
         from .types import AgentType
-        return AgentType(self.input_type, self.output_type)
+        eff = self.effect
+        eff_str = repr(eff)
+        return AgentType(self.input_type, self.output_type, effect=eff_str)
 
     @abstractmethod
     def apply(self, input: Any, ctx: Context) -> Any:

+ 386 - 0
lambdagent/effects.py

@@ -0,0 +1,386 @@
+"""
+lambdagent.effects — Paper III 效果代数
+
+实现论文 III 的效果系统 (§4):
+  - Effect: 效果类型 (pure, llm(m), io, state(s))
+  - 效果组合: 串行 (·), 并行 (∥), 迭代 (εⁿ)
+  - 效果子类型格: pure ≤ ε for all ε (Definition 9)
+  - 效果标注: 每个 Term 有计算效果 (Definition 6-7)
+
+核心方程:
+    ε1 · ε2 = 串行效果组合 (Compose)
+    ε1 ∥ ε2 = 并行效果组合 (Pair/Par)
+    εⁿ      = 迭代效果 (Loop)
+
+效果格偏序:
+    pure ≤ llm(m) ≤ llm(m) · io ≤ ...
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import Enum, auto
+from typing import Any, FrozenSet, List, Optional, Set, Tuple
+
+
+# ============================================================
+# 效果种类 (Paper III Definition 6)
+# ============================================================
+
+class EffectKind(Enum):
+    """基本效果种类"""
+    PURE = "pure"           # 纯函数 — 无副作用
+    LLM = "llm"             # LLM 调用 — 自回归解码
+    IO = "io"               # I/O — 工具调用、外部 API
+    STATE = "state"         # 状态 — 读写 Memory/SharedMemory
+
+
+# ============================================================
+# Effect: 效果类型 (Paper III Definition 7)
+# ============================================================
+
+@dataclass(frozen=True)
+class Effect:
+    """
+    效果类型。
+
+    Paper III Definition 7:
+        ε ::= pure | llm(m) | io | state(s) | ε1 · ε2 | ε1 ∥ ε2 | εⁿ
+
+    每个 Effect 是基本效果的组合。
+    """
+    kind: EffectKind
+    # llm(m): 模型名称 (when kind == LLM)
+    model: Optional[str] = None
+    # state(s): 状态键集合 (when kind == STATE)
+    state_keys: Optional[FrozenSet[str]] = None
+
+    def __repr__(self) -> str:
+        if self.kind == EffectKind.PURE:
+            return "pure"
+        elif self.kind == EffectKind.LLM:
+            if self.model:
+                return f"llm({self.model})"
+            return "llm"
+        elif self.kind == EffectKind.IO:
+            return "io"
+        elif self.kind == EffectKind.STATE:
+            if self.state_keys:
+                keys = ", ".join(sorted(self.state_keys))
+                return f"state({keys})"
+            return "state"
+        return f"Effect({self.kind})"
+
+
+# ============================================================
+# 效果常量
+# ============================================================
+
+PURE = Effect(EffectKind.PURE)
+IO = Effect(EffectKind.IO)
+
+
+def LLM(model: str | None = None) -> Effect:
+    """构造 llm(m) 效果"""
+    return Effect(EffectKind.LLM, model=model)
+
+
+def STATE(*keys: str) -> Effect:
+    """构造 state(s) 效果"""
+    return Effect(EffectKind.STATE, state_keys=frozenset(keys) if keys else None)
+
+
+# ============================================================
+# 组合效果 (Paper III Definition 7 continued)
+# ============================================================
+
+@dataclass(frozen=True)
+class ComposedEffect:
+    """
+    组合效果 — 多个基本效果的组合。
+
+    串行 (·): Compose(f, g) 的效果 = ε_f · ε_g
+    并行 (∥): Pair(f, g) 的效果 = ε_f ∥ ε_g
+    迭代 (εⁿ): Loop(body, n) 的效果 = ε_body^n
+    """
+    effects: Tuple[Effect, ...]
+    mode: str = "serial"  # "serial" (·), "parallel" (∥), "iterate" (εⁿ)
+    iterations: Optional[int] = None  # only for iterate
+
+    @property
+    def is_pure(self) -> bool:
+        """是否所有效果都是 pure"""
+        return all(e.kind == EffectKind.PURE for e in self.effects)
+
+    @property
+    def has_llm(self) -> bool:
+        """是否包含 LLM 调用"""
+        return any(e.kind == EffectKind.LLM for e in self.effects)
+
+    @property
+    def has_io(self) -> bool:
+        """是否包含 I/O"""
+        return any(e.kind == EffectKind.IO for e in self.effects)
+
+    @property
+    def has_state(self) -> bool:
+        """是否包含状态操作"""
+        return any(e.kind == EffectKind.STATE for e in self.effects)
+
+    @property
+    def all_state_keys(self) -> FrozenSet[str]:
+        """收集所有状态键(用于 store-independence 检查)"""
+        keys: Set[str] = set()
+        for e in self.effects:
+            if e.kind == EffectKind.STATE and e.state_keys:
+                keys.update(e.state_keys)
+        return frozenset(keys)
+
+    @property
+    def models_used(self) -> FrozenSet[str]:
+        """收集所有使用的 LLM 模型"""
+        models: Set[str] = set()
+        for e in self.effects:
+            if e.kind == EffectKind.LLM and e.model:
+                models.add(e.model)
+        return frozenset(models)
+
+    def __repr__(self) -> str:
+        if not self.effects:
+            return "pure"
+        if len(self.effects) == 1:
+            base = repr(self.effects[0])
+            if self.mode == "iterate" and self.iterations:
+                return f"{base}^{self.iterations}"
+            return base
+        sep = " · " if self.mode == "serial" else " ∥ "
+        parts = sep.join(repr(e) for e in self.effects)
+        if self.mode == "iterate" and self.iterations:
+            return f"({parts})^{self.iterations}"
+        return parts
+
+
+# ============================================================
+# 效果组合运算 (Paper III Definitions 6-7)
+# ============================================================
+
+def serial(*effects: Effect | ComposedEffect) -> ComposedEffect:
+    """
+    串行效果组合: ε1 · ε2
+
+    对应 Compose(f, g) — 先执行 f 的效果,再执行 g 的效果。
+    """
+    flat: List[Effect] = []
+    for e in effects:
+        if isinstance(e, ComposedEffect):
+            flat.extend(e.effects)
+        else:
+            flat.append(e)
+    # 过滤 pure
+    non_pure = [e for e in flat if e.kind != EffectKind.PURE]
+    if not non_pure:
+        return ComposedEffect(effects=(PURE,), mode="serial")
+    return ComposedEffect(effects=tuple(non_pure), mode="serial")
+
+
+def parallel(*effects: Effect | ComposedEffect) -> ComposedEffect:
+    """
+    并行效果组合: ε1 ∥ ε2
+
+    对应 Pair(f, g) — f 和 g 的效果同时发生。
+    """
+    flat: List[Effect] = []
+    for e in effects:
+        if isinstance(e, ComposedEffect):
+            flat.extend(e.effects)
+        else:
+            flat.append(e)
+    non_pure = [e for e in flat if e.kind != EffectKind.PURE]
+    if not non_pure:
+        return ComposedEffect(effects=(PURE,), mode="parallel")
+    return ComposedEffect(effects=tuple(non_pure), mode="parallel")
+
+
+def iterate(effect: Effect | ComposedEffect, n: int) -> ComposedEffect:
+    """
+    迭代效果: εⁿ
+
+    对应 Loop(body, n) — body 的效果重复 n 次。
+    """
+    if isinstance(effect, ComposedEffect):
+        return ComposedEffect(effects=effect.effects, mode="iterate", iterations=n)
+    return ComposedEffect(effects=(effect,), mode="iterate", iterations=n)
+
+
+# ============================================================
+# 效果子类型格 (Paper III Definition 9)
+# ============================================================
+
+# 偏序: pure ≤ ε for all ε
+_EFFECT_ORDER = {
+    EffectKind.PURE: 0,
+    EffectKind.STATE: 1,
+    EffectKind.IO: 2,
+    EffectKind.LLM: 3,
+}
+
+
+def effect_leq(e1: Effect, e2: Effect) -> bool:
+    """
+    效果子类型: e1 ≤ e2
+
+    Paper III Definition 9:
+        pure ≤ ε for all ε (Proposition 10: monotonicity)
+
+    偏序: pure ≤ state ≤ io ≤ llm
+    """
+    if e1.kind == EffectKind.PURE:
+        return True
+    if e1.kind == e2.kind:
+        return True
+    return _EFFECT_ORDER.get(e1.kind, 0) <= _EFFECT_ORDER.get(e2.kind, 0)
+
+
+def max_effect(*effects: Effect) -> Effect:
+    """取效果格中的最大元素 (join / supremum)"""
+    if not effects:
+        return PURE
+    result = effects[0]
+    for e in effects[1:]:
+        if _EFFECT_ORDER.get(e.kind, 0) > _EFFECT_ORDER.get(result.kind, 0):
+            result = e
+    return result
+
+
+# ============================================================
+# 效果推断辅助
+# ============================================================
+
+def parse_effect_annotation(annotation: Any) -> Effect | ComposedEffect:
+    """
+    从 YAML 配置中的效果标注解析为 Effect。
+
+    支持的格式:
+        "pure"              → PURE
+        "llm(qwen3-max)"   → LLM("qwen3-max")
+        "io"                → IO
+        "state(memory)"     → STATE("memory")
+        "llm(claude) · io"  → serial(LLM("claude"), IO)
+    """
+    if annotation is None:
+        return ComposedEffect(effects=(PURE,))
+
+    if isinstance(annotation, str):
+        annotation = annotation.strip()
+
+        # 组合效果: "ε1 · ε2"
+        if " · " in annotation:
+            parts = [parse_effect_annotation(p.strip()) for p in annotation.split(" · ")]
+            return serial(*parts)
+
+        # 组合效果: "ε1 ∥ ε2"
+        if " ∥ " in annotation or " || " in annotation:
+            sep = " ∥ " if " ∥ " in annotation else " || "
+            parts = [parse_effect_annotation(p.strip()) for p in annotation.split(sep)]
+            return parallel(*parts)
+
+        if annotation == "pure":
+            return PURE
+        if annotation == "io":
+            return IO
+        if annotation.startswith("llm(") and annotation.endswith(")"):
+            model = annotation[4:-1]
+            return LLM(model)
+        if annotation.startswith("llm"):
+            return LLM()
+        if annotation.startswith("state(") and annotation.endswith(")"):
+            keys = annotation[6:-1].split(",")
+            return STATE(*[k.strip() for k in keys])
+        if annotation == "state":
+            return STATE()
+
+    return ComposedEffect(effects=(PURE,))
+
+
+# ============================================================
+# Term 效果推断 (Paper III §4)
+# ============================================================
+
+def infer_effect_for_term(term: Any) -> Effect | ComposedEffect:
+    """
+    为 Term 推断效果。
+
+    Paper III §4 效果推断规则:
+        Lam     → llm(model)
+        Tool    → io
+        Memory  → state · inner_effect
+        Guard   → inner_effect^(1+retry)
+        Compose → ε1 · ε2 · ... · εn
+        Pair    → ε1 ∥ ε2
+        Par     → ε1 ∥ ε2 ∥ ... ∥ εn
+        If      → ε_cond · (ε_then | ε_else)
+        Loop    → ε_body^n
+        Route   → ε_classifier · max(ε_routes)
+    """
+    # 延迟导入避免循环
+    from .primitives import Lam, Compose, If, Loop, Pair, Tool
+    from .extensions import Par, Route, Memory, Guard
+
+    if isinstance(term, Lam):
+        return LLM(term.model)
+
+    elif isinstance(term, Tool):
+        return IO
+
+    elif isinstance(term, Compose):
+        stage_effects = [infer_effect_for_term(s) for s in term.stages]
+        return serial(*stage_effects)
+
+    elif isinstance(term, Pair):
+        return parallel(
+            infer_effect_for_term(term.first),
+            infer_effect_for_term(term.second),
+        )
+
+    elif isinstance(term, Par):
+        agent_effects = [infer_effect_for_term(a) for a in term.agents]
+        return parallel(*agent_effects)
+
+    elif isinstance(term, If):
+        cond_eff = infer_effect_for_term(term.cond) if isinstance(term.cond, type) and hasattr(term.cond, 'apply') else PURE
+        then_eff = infer_effect_for_term(term.then_)
+        else_eff = infer_effect_for_term(term.else_)
+        # If 的效果 = ε_cond · max(ε_then, ε_else)
+        branch_max = max_effect(
+            then_eff if isinstance(then_eff, Effect) else then_eff.effects[0] if then_eff.effects else PURE,
+            else_eff if isinstance(else_eff, Effect) else else_eff.effects[0] if else_eff.effects else PURE,
+        )
+        return serial(cond_eff if isinstance(cond_eff, Effect) else cond_eff, branch_max)
+
+    elif isinstance(term, Loop):
+        body_eff = infer_effect_for_term(term.body)
+        return iterate(body_eff, term.max_steps)
+
+    elif isinstance(term, Memory):
+        inner_eff = infer_effect_for_term(term.agent)
+        return serial(STATE(), inner_eff)
+
+    elif isinstance(term, Guard):
+        inner_eff = infer_effect_for_term(term.agent)
+        return iterate(inner_eff, 1 + term.retry)
+
+    elif isinstance(term, Route):
+        cls_eff = infer_effect_for_term(term.classifier)
+        route_effects = [infer_effect_for_term(r) for r in term.routes.values()]
+        if route_effects:
+            route_max = route_effects[0]
+            for re in route_effects[1:]:
+                if isinstance(re, Effect):
+                    route_max = re
+                elif isinstance(route_max, ComposedEffect) and isinstance(re, ComposedEffect):
+                    if len(re.effects) > len(route_max.effects):
+                        route_max = re
+            return serial(cls_eff, route_max)
+        return cls_eff
+
+    return PURE

+ 295 - 0
lambdagent/tests/test_effects.py

@@ -0,0 +1,295 @@
+"""
+Tests for Paper III Effect Algebra — effect annotations on all 11 constructs.
+
+Tests cover:
+  1. Basic effect construction and properties
+  2. Effect composition: serial (·), parallel (∥), iterate (εⁿ)
+  3. Effect subtype lattice (Definition 9)
+  4. Effect inference for all 11 constructs
+  5. Effect annotation parsing from YAML
+  6. Term.effect property integration
+"""
+
+import pytest
+from lambdagent.effects import (
+    Effect, EffectKind, ComposedEffect,
+    PURE, IO, LLM, STATE,
+    serial, parallel, iterate,
+    effect_leq, max_effect,
+    parse_effect_annotation, infer_effect_for_term,
+)
+from lambdagent.primitives import Lam, Compose, If, Loop, Pair, Tool
+from lambdagent.extensions import Par, Route, Memory, Guard
+
+
+# ============================================================
+# 1. Basic Effect Construction
+# ============================================================
+
+class TestEffectConstruction:
+
+    def test_pure(self):
+        assert PURE.kind == EffectKind.PURE
+        assert repr(PURE) == "pure"
+
+    def test_llm(self):
+        e = LLM("claude-sonnet")
+        assert e.kind == EffectKind.LLM
+        assert e.model == "claude-sonnet"
+        assert repr(e) == "llm(claude-sonnet)"
+
+    def test_io(self):
+        assert IO.kind == EffectKind.IO
+        assert repr(IO) == "io"
+
+    def test_state(self):
+        e = STATE("memory", "counter")
+        assert e.kind == EffectKind.STATE
+        assert "memory" in e.state_keys
+        assert "counter" in e.state_keys
+
+    def test_state_repr(self):
+        e = STATE("mem")
+        assert "state(mem)" == repr(e)
+
+
+# ============================================================
+# 2. Effect Composition
+# ============================================================
+
+class TestEffectComposition:
+
+    def test_serial_composition(self):
+        """ε1 · ε2"""
+        result = serial(LLM("claude"), IO)
+        assert result.mode == "serial"
+        assert len(result.effects) == 2
+        assert "llm(claude) · io" == repr(result)
+
+    def test_serial_pure_elimination(self):
+        """pure · ε = ε"""
+        result = serial(PURE, LLM("claude"))
+        assert len(result.effects) == 1
+        assert result.effects[0].kind == EffectKind.LLM
+
+    def test_serial_all_pure(self):
+        """pure · pure = pure"""
+        result = serial(PURE, PURE)
+        assert result.is_pure
+
+    def test_parallel_composition(self):
+        """ε1 ∥ ε2"""
+        result = parallel(LLM("claude"), LLM("gpt-4"))
+        assert result.mode == "parallel"
+        assert len(result.effects) == 2
+
+    def test_iterate(self):
+        """εⁿ"""
+        result = iterate(LLM("claude"), 5)
+        assert result.mode == "iterate"
+        assert result.iterations == 5
+        assert "llm(claude)^5" == repr(result)
+
+    def test_composed_properties(self):
+        """Test ComposedEffect property accessors"""
+        result = serial(LLM("claude"), IO, STATE("mem"))
+        assert result.has_llm
+        assert result.has_io
+        assert result.has_state
+        assert not result.is_pure
+        assert "claude" in result.models_used
+        assert "mem" in result.all_state_keys
+
+
+# ============================================================
+# 3. Effect Subtype Lattice (Paper III Definition 9)
+# ============================================================
+
+class TestEffectLattice:
+
+    def test_pure_bottom(self):
+        """pure ≤ ε for all ε"""
+        assert effect_leq(PURE, PURE)
+        assert effect_leq(PURE, IO)
+        assert effect_leq(PURE, LLM())
+        assert effect_leq(PURE, STATE())
+
+    def test_reflexivity(self):
+        """ε ≤ ε"""
+        assert effect_leq(IO, IO)
+        assert effect_leq(LLM(), LLM())
+        assert effect_leq(STATE(), STATE())
+
+    def test_ordering(self):
+        """pure ≤ state ≤ io ≤ llm"""
+        assert effect_leq(STATE(), IO)
+        assert effect_leq(IO, LLM())
+
+    def test_max_effect(self):
+        """join in the lattice"""
+        result = max_effect(PURE, IO, LLM("claude"))
+        assert result.kind == EffectKind.LLM
+
+
+# ============================================================
+# 4. Effect Inference for All 11 Constructs
+# ============================================================
+
+class TestEffectInference:
+
+    def test_lam_effect(self):
+        """Lam → llm(model)"""
+        lam = Lam("test", "prompt", model="claude-sonnet")
+        eff = infer_effect_for_term(lam)
+        assert isinstance(eff, Effect)
+        assert eff.kind == EffectKind.LLM
+        assert eff.model == "claude-sonnet"
+
+    def test_tool_effect(self):
+        """Tool → io"""
+        tool = Tool("double", lambda x: int(x) * 2)
+        eff = infer_effect_for_term(tool)
+        assert isinstance(eff, Effect)
+        assert eff.kind == EffectKind.IO
+
+    def test_compose_effect(self):
+        """Compose → ε1 · ε2"""
+        f = Lam("a", "prompt", model="m1")
+        g = Tool("b", lambda x: x)
+        comp = Compose(f, g)
+        eff = infer_effect_for_term(comp)
+        assert isinstance(eff, ComposedEffect)
+        assert eff.mode == "serial"
+        assert eff.has_llm
+        assert eff.has_io
+
+    def test_pair_effect(self):
+        """Pair → ε1 ∥ ε2"""
+        f = Lam("a", "prompt", model="m1")
+        g = Lam("b", "prompt", model="m2")
+        pair = Pair(f, g)
+        eff = infer_effect_for_term(pair)
+        assert isinstance(eff, ComposedEffect)
+        assert eff.mode == "parallel"
+        assert len(eff.models_used) == 2
+
+    def test_par_effect(self):
+        """Par → ε1 ∥ ε2 ∥ ... ∥ εn"""
+        agents = [Tool(f"t{i}", lambda x: x) for i in range(3)]
+        par = Par(*agents)
+        eff = infer_effect_for_term(par)
+        assert isinstance(eff, ComposedEffect)
+        assert eff.mode == "parallel"
+
+    def test_loop_effect(self):
+        """Loop → ε_body^n"""
+        body = Lam("b", "prompt", model="m")
+        loop = Loop(body, lambda r, s: False, max_steps=5)
+        eff = infer_effect_for_term(loop)
+        assert isinstance(eff, ComposedEffect)
+        assert eff.mode == "iterate"
+        assert eff.iterations == 5
+
+    def test_memory_effect(self):
+        """Memory → state · inner_effect"""
+        inner = Tool("t", lambda x: x)
+        mem = Memory(inner)
+        eff = infer_effect_for_term(mem)
+        assert isinstance(eff, ComposedEffect)
+        assert eff.has_state
+        assert eff.has_io
+
+    def test_guard_effect(self):
+        """Guard → ε_agent^(1+retry)"""
+        inner = Lam("a", "prompt", model="m")
+        guard = Guard(inner, lambda x: True, retry=2)
+        eff = infer_effect_for_term(guard)
+        assert isinstance(eff, ComposedEffect)
+        assert eff.mode == "iterate"
+        assert eff.iterations == 3
+
+    def test_route_effect(self):
+        """Route → ε_classifier · max(ε_routes)"""
+        classifier = Lam("cls", "classify", model="m")
+        routes = {
+            "a": Tool("ta", lambda x: x),
+            "b": Lam("lb", "prompt", model="m2"),
+        }
+        route = Route(classifier, routes)
+        eff = infer_effect_for_term(route)
+        assert isinstance(eff, ComposedEffect)
+        assert eff.has_llm
+
+
+# ============================================================
+# 5. Effect Annotation Parsing
+# ============================================================
+
+class TestEffectParsing:
+
+    def test_parse_pure(self):
+        result = parse_effect_annotation("pure")
+        assert result == PURE
+
+    def test_parse_io(self):
+        result = parse_effect_annotation("io")
+        assert result == IO
+
+    def test_parse_llm(self):
+        result = parse_effect_annotation("llm(claude-sonnet)")
+        assert isinstance(result, Effect)
+        assert result.kind == EffectKind.LLM
+        assert result.model == "claude-sonnet"
+
+    def test_parse_state(self):
+        result = parse_effect_annotation("state(memory)")
+        assert isinstance(result, Effect)
+        assert result.kind == EffectKind.STATE
+
+    def test_parse_serial(self):
+        result = parse_effect_annotation("llm(claude) · io")
+        assert isinstance(result, ComposedEffect)
+        assert result.mode == "serial"
+
+    def test_parse_parallel(self):
+        result = parse_effect_annotation("llm(m1) ∥ llm(m2)")
+        assert isinstance(result, ComposedEffect)
+        assert result.mode == "parallel"
+
+    def test_parse_none(self):
+        result = parse_effect_annotation(None)
+        assert isinstance(result, ComposedEffect)
+        assert result.is_pure
+
+
+# ============================================================
+# 6. Term.effect Property Integration
+# ============================================================
+
+class TestTermEffectProperty:
+
+    def test_lam_effect_property(self):
+        """Term.effect should auto-infer for Lam"""
+        lam = Lam("test", "prompt", model="claude")
+        eff = lam.effect
+        assert isinstance(eff, Effect)
+        assert eff.kind == EffectKind.LLM
+
+    def test_tool_effect_property(self):
+        """Term.effect should auto-infer for Tool"""
+        tool = Tool("t", lambda x: x)
+        eff = tool.effect
+        assert isinstance(eff, Effect)
+        assert eff.kind == EffectKind.IO
+
+    def test_effect_setter(self):
+        """Manual effect annotation overrides inference"""
+        tool = Tool("t", lambda x: x)
+        tool.effect = PURE
+        assert tool.effect == PURE
+
+    def test_agent_type_includes_effect(self):
+        """AgentType includes effect in repr"""
+        lam = Lam("test", "prompt", model="claude")
+        at = lam.agent_type
+        assert "llm(claude)" in repr(at)