Ctrl K
ComunidadGuías…

tests/test_dag_executor.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
Tests for the DAG executor.

Uses a mock LLM that returns deterministic answers. Covers:
- linear pipeline
- parallel pipeline
- mixed DAG
- retry with success
- retries exhausted → FAILED
- dependency skip
- resume from a checkpoint
- rate-limiter delay
"""

import asyncio
import sys
import time
from pathlib import Path

# path to the source file (tests run from tests/)
sys.path.insert(0, str(Path(__file__).parent.parent))

from src.pipeline.dag_executor import (
    DAGExecutor,
    RateLimitException,
)
from src.pipeline.models import (
    HarvestContext,
    SubTask,
    TaskPlan,
    TaskState,
)


# ─── Mock-LLM ──────────────────────────────────────────────────────

class MockLLM:
    """Deterministic mock LLM for tests.

    Behaviour configurable per task_id:
    - "success" (default): returns dummy output immediately
    - "fail_then_succeed": fails on the first attempt, succeeds on the second
    - "always_fail": always raises
    - "rate_limit_then_succeed": once RateLimit, then success
    - "delay:N": wait N seconds, then success
    - "json": returns JSON output
    """

    def __init__(self, behavior: dict[str, str] = None):
        self.behavior = behavior or {}
        self.call_log: list[tuple[str, int]] = []  # (task_id, attempt)
        self.call_counts: dict[str, int] = {}

    async def call(self, prompt: str, meta: dict) -> tuple[str, dict]:
        """Mock implementation of the LLM interface."""
        task_id = meta.get("task_id", "unknown")
        self.call_counts[task_id] = self.call_counts.get(task_id, 0) + 1
        attempt = self.call_counts[task_id]
        self.call_log.append((task_id, attempt))

        behavior = self.behavior.get(task_id, "success")

        if behavior == "success":
            return f"Output for {task_id}", {"tokens_used": 100}

        if behavior == "always_fail":
            raise RuntimeError(f"mock error for {task_id}")

        if behavior == "fail_then_succeed":
            if attempt == 1:
                raise RuntimeError(f"let the first attempt fail ({task_id})")
            return f"Output for {task_id} nach Retry", {"tokens_used": 100}

        if behavior == "rate_limit_then_succeed":
            if attempt == 1:
                raise RateLimitException(f"429 for {task_id}")
            return f"Output for {task_id} nach Rate-Limit", {"tokens_used": 100}

        if behavior.startswith("delay:"):
            seconds = float(behavior.split(":")[1])
            await asyncio.sleep(seconds)
            return f"Output for {task_id} nach {seconds}s", {"tokens_used": 100}

        if behavior == "json":
            return '```json\n{"result": "ok", "value": 42}\n```', {"tokens_used": 100}

        return f"Default output for {task_id}", {"tokens_used": 100}


# ─── Test helpers ───────────────────────────────────────────────────

def make_context() -> HarvestContext:
    """Create an empty HarvestContext."""
    return HarvestContext(query="test")


def make_task(task_id: str, deps: list[str] = None, max_retries: int = 0) -> SubTask:
    """Helper for creating a SubTask."""
    return SubTask(
        id=task_id,
        phase="test",
        description=f"Test {task_id}",
        prompt_template="dummy",
        prompt_params={"task_id": task_id},
        depends_on=deps or [],
        max_retries=max_retries,
    )


PROMPTS = {"dummy": "Task: {task_id}"}


# ─── Tests ─────────────────────────────────────────────────────────

async def test_linear_pipeline():
    """3 tasks in sequence: A → B → C."""
    print("Test: linear pipeline ...", end=" ")
    plan = TaskPlan(use_case="test", tasks=[
        make_task("A"),
        make_task("B", deps=["A"]),
        make_task("C", deps=["B"]),
    ])
    mock = MockLLM()
    executor = DAGExecutor(llm_call=mock.call, max_parallel=5, rate_limit=100)
    ctx = make_context()
    results = await executor.execute(plan, PROMPTS, ctx)

    assert len(results) == 3
    assert all(r.state == TaskState.DONE for r in results.values())
    assert ctx.completed_layers == [0, 1, 2]
    # linear order: A before B before C
    a_time = results["A"].finished_at
    b_time = results["B"].started_at
    assert a_time <= b_time, f"A finished at {a_time}, B started at {b_time}"
    print("✓")


async def test_parallel_layer():
    """5 tasks in parallel in one layer."""
    print("Test: parallel layer ...", end=" ")
    plan = TaskPlan(use_case="test", tasks=[
        make_task(f"T{i}") for i in range(5)
    ])
    mock = MockLLM({f"T{i}": "delay:0.1" for i in range(5)})
    executor = DAGExecutor(llm_call=mock.call, max_parallel=5, rate_limit=100)
    ctx = make_context()

    start = time.monotonic()
    await executor.execute(plan, PROMPTS, ctx)
    elapsed = time.monotonic() - start

    # 5 parallel tasks of 0.1 s each should run in ~0.1-0.3 s, not 0.5 s
    assert elapsed < 0.4, f"parallelism does not work: {elapsed:.2f}s"
    assert all(r.state == TaskState.DONE for r in ctx.task_results.values())
    print(f"✓ ({elapsed:.2f}s for 5 tasks of 0.1s)")


async def test_max_parallel_limit():
    """With max_parallel=2, 4 tasks of 0.1 s each should take ~0.2 s, not 0.1 s."""
    print("Test: max_parallel-Limit ...", end=" ")
    plan = TaskPlan(use_case="test", tasks=[
        make_task(f"T{i}") for i in range(4)
    ])
    mock = MockLLM({f"T{i}": "delay:0.1" for i in range(4)})
    executor = DAGExecutor(llm_call=mock.call, max_parallel=2, rate_limit=100)
    ctx = make_context()

    start = time.monotonic()
    await executor.execute(plan, PROMPTS, ctx)
    elapsed = time.monotonic() - start

    # 4 tasks with at most 2 in parallel of 0.1 s each = ~0.2 s
    assert 0.15 < elapsed < 0.35, f"max_parallel=2 does not hold: {elapsed:.2f}s"
    print(f"✓ ({elapsed:.2f}s)")


async def test_mixed_dag():
    """A → (B, C) → D — a DAG with branching."""
    print("Test: mixed DAG (A → B,C → D) ...", end=" ")
    plan = TaskPlan(use_case="test", tasks=[
        make_task("A"),
        make_task("B", deps=["A"]),
        make_task("C", deps=["A"]),
        make_task("D", deps=["B", "C"]),
    ])
    mock = MockLLM()
    executor = DAGExecutor(llm_call=mock.call, max_parallel=5, rate_limit=100)
    ctx = make_context()
    await executor.execute(plan, PROMPTS, ctx)

    assert all(r.state == TaskState.DONE for r in ctx.task_results.values())
    assert ctx.completed_layers == [0, 1, 2]  # 3 layers
    # B and C after A
    assert ctx.task_results["B"].started_at >= ctx.task_results["A"].finished_at
    # D after B and C
    assert ctx.task_results["D"].started_at >= ctx.task_results["B"].finished_at
    assert ctx.task_results["D"].started_at >= ctx.task_results["C"].finished_at
    print("✓")


async def test_retry_success():
    """The task fails on the first attempt, succeeds on the second."""
    print("Test: retry with success ...", end=" ")
    plan = TaskPlan(use_case="test", tasks=[
        make_task("R", max_retries=2),
    ])
    mock = MockLLM({"R": "fail_then_succeed"})
    executor = DAGExecutor(llm_call=mock.call, max_parallel=5, rate_limit=100)
    ctx = make_context()
    await executor.execute(plan, PROMPTS, ctx)

    result = ctx.task_results["R"]
    assert result.state == TaskState.DONE, f"State: {result.state}"
    assert result.retries_used == 1, f"Retries: {result.retries_used}"
    assert mock.call_counts["R"] == 2
    print("✓")


async def test_retry_exhausted():
    """The task always fails, the retries are used up."""
    print("Test: retries exhausted → FAILED ...", end=" ")
    plan = TaskPlan(use_case="test", tasks=[
        make_task("F", max_retries=2),
    ])
    mock = MockLLM({"F": "always_fail"})
    executor = DAGExecutor(llm_call=mock.call, max_parallel=5, rate_limit=100)
    ctx = make_context()
    await executor.execute(plan, PROMPTS, ctx)

    result = ctx.task_results["F"]
    assert result.state == TaskState.FAILED
    assert "mock error" in result.error
    assert mock.call_counts["F"] == 3  # 1 + 2 retries
    print("✓")


async def test_dependency_skip():
    """The task is SKIPPED if a dependency FAILED."""
    print("Test: dependency skip ...", end=" ")
    plan = TaskPlan(use_case="test", tasks=[
        make_task("A"),
        make_task("B", deps=["A"]),  # depends on A
        make_task("C", deps=["B"]),  # depends on B
    ])
    mock = MockLLM({"A": "always_fail"})
    executor = DAGExecutor(llm_call=mock.call, max_parallel=5, rate_limit=100)
    ctx = make_context()
    await executor.execute(plan, PROMPTS, ctx)

    assert ctx.task_results["A"].state == TaskState.FAILED
    assert ctx.task_results["B"].state == TaskState.SKIPPED
    assert ctx.task_results["C"].state == TaskState.SKIPPED
    # B and C must not have been called
    assert "B" not in mock.call_counts
    assert "C" not in mock.call_counts
    print("✓")


async def test_rate_limit_recovery():
    """RateLimitException → back-off → retry → success."""
    print("Test: Rate-Limit Recovery ...", end=" ")
    plan = TaskPlan(use_case="test", tasks=[
        make_task("RL", max_retries=2),
    ])
    mock = MockLLM({"RL": "rate_limit_then_succeed"})
    executor = DAGExecutor(
        llm_call=mock.call,
        max_parallel=5,
        rate_limit=100,
    )
    # shorten the back-off for the test
    executor.rate_limiter.backoff_initial = 0.05
    ctx = make_context()
    await executor.execute(plan, PROMPTS, ctx)

    result = ctx.task_results["RL"]
    assert result.state == TaskState.DONE
    assert mock.call_counts["RL"] == 2
    print("✓")


async def test_resume_from_layer():
    """Resume: the first layer is skipped, earlier results stay."""
    print("Test: resume from a checkpoint ...", end=" ")
    plan = TaskPlan(use_case="test", tasks=[
        make_task("A"),
        make_task("B", deps=["A"]),
    ])
    # first run normally
    mock1 = MockLLM()
    executor1 = DAGExecutor(llm_call=mock1.call, max_parallel=5, rate_limit=100)
    ctx = make_context()
    await executor1.execute(plan, PROMPTS, ctx)
    assert mock1.call_counts == {"A": 1, "B": 1}
    assert ctx.completed_layers == [0, 1]

    # Now resume from layer 1 (i.e. only B anew, keep A)
    # a new executor with a different mock, so that we can measure who is called
    mock2 = MockLLM()
    executor2 = DAGExecutor(llm_call=mock2.call, max_parallel=5, rate_limit=100)
    await executor2.execute(plan, PROMPTS, ctx, resume_from_layer=1)

    # A must not have been called again
    assert "A" not in mock2.call_counts, f"A was called unexpectedly: {mock2.call_counts}"
    # B is run again
    assert mock2.call_counts == {"B": 1}, f"Mock2 calls: {mock2.call_counts}"
    print("✓")


async def test_progress_callback():
    """Progress events are sent correctly."""
    print("Test: Progress-Callback ...", end=" ")
    events = []

    async def cb(event: str, data: dict):
        events.append((event, data))

    plan = TaskPlan(use_case="test", tasks=[
        make_task("A"),
        make_task("B", deps=["A"]),
    ])
    mock = MockLLM()
    executor = DAGExecutor(
        llm_call=mock.call, max_parallel=5, rate_limit=100,
        progress_callback=cb,
    )
    ctx = make_context()
    await executor.execute(plan, PROMPTS, ctx)

    event_types = [e[0] for e in events]
    assert "layer_start" in event_types
    assert "task_start" in event_types
    assert "task_done" in event_types
    assert "layer_done" in event_types
    print(f"✓ ({len(events)} Events)")


async def test_persist_callback():
    """The persist callback is called after every layer."""
    print("Test: Persist-Callback ...", end=" ")
    persist_calls = []

    async def persist(ctx: HarvestContext, layer_idx: int):
        persist_calls.append((layer_idx, len(ctx.task_results)))

    plan = TaskPlan(use_case="test", tasks=[
        make_task("A"),
        make_task("B", deps=["A"]),
        make_task("C", deps=["B"]),
    ])
    mock = MockLLM()
    executor = DAGExecutor(
        llm_call=mock.call, max_parallel=5, rate_limit=100,
        persist_callback=persist,
    )
    ctx = make_context()
    await executor.execute(plan, PROMPTS, ctx)

    assert len(persist_calls) == 3  # 3 layers
    assert [c[0] for c in persist_calls] == [0, 1, 2]
    print("✓")


async def test_json_parsing():
    """JSON output is parsed automatically."""
    print("Test: JSON-Parsing ...", end=" ")
    plan = TaskPlan(use_case="test", tasks=[make_task("J")])
    mock = MockLLM({"J": "json"})
    executor = DAGExecutor(llm_call=mock.call, max_parallel=5, rate_limit=100)
    ctx = make_context()
    await executor.execute(plan, PROMPTS, ctx)

    result = ctx.task_results["J"]
    assert result.state == TaskState.DONE
    assert result.parsed_output == {"result": "ok", "value": 42}
    print("✓")


async def test_invalid_max_parallel():
    """The constructor rejects an invalid max_parallel."""
    print("Test: Invalid max_parallel ...", end=" ")
    mock = MockLLM()
    try:
        DAGExecutor(llm_call=mock.call, max_parallel=0)
        assert False, "should raise an exception"
    except ValueError:
        pass
    try:
        DAGExecutor(llm_call=mock.call, max_parallel=20)
        assert False, "should raise an exception"
    except ValueError:
        pass
    print("✓")


async def test_stop_check_between_layers():
    """A stop check between layers aborts the pipeline cleanly."""
    print("Test: stop between layers ...", end=" ")
    plan = TaskPlan(use_case="test", tasks=[
        make_task("A"),
        make_task("B", deps=["A"]),
        make_task("C", deps=["B"]),
    ])

    # stop flag that becomes active after the first layer
    stop_flag = {"stop": False}

    async def stop_after_first(event: str, data: dict):
        if event == "layer_done" and data.get("layer") == 1:
            stop_flag["stop"] = True

    mock = MockLLM()
    executor = DAGExecutor(
        llm_call=mock.call,
        max_parallel=5,
        rate_limit=100,
        progress_callback=stop_after_first,
        stop_check=lambda: stop_flag["stop"],
    )
    ctx = make_context()
    await executor.execute(plan, PROMPTS, ctx)

    # A must have run
    assert ctx.task_results["A"].state == TaskState.DONE
    # B and C must be SKIPPED
    assert ctx.task_results["B"].state == TaskState.SKIPPED, \
        f"B state: {ctx.task_results['B'].state}"
    assert ctx.task_results["C"].state == TaskState.SKIPPED
    # B must not have been called
    assert "B" not in mock.call_counts
    assert "C" not in mock.call_counts
    print("✓")


async def test_stop_before_first_layer():
    """Stop before the first layer → all tasks SKIPPED."""
    print("Test: stop before the first layer ...", end=" ")
    plan = TaskPlan(use_case="test", tasks=[
        make_task("A"),
        make_task("B", deps=["A"]),
    ])
    mock = MockLLM()
    executor = DAGExecutor(
        llm_call=mock.call,
        max_parallel=5,
        rate_limit=100,
        stop_check=lambda: True,  # abort immediately
    )
    ctx = make_context()
    await executor.execute(plan, PROMPTS, ctx)

    # all tasks must be SKIPPED
    assert ctx.task_results["A"].state == TaskState.SKIPPED
    assert ctx.task_results["B"].state == TaskState.SKIPPED
    # no LLM calls
    assert not mock.call_counts
    print("✓")


# ─── Test-Runner ───────────────────────────────────────────────────

async def main():
    print("=" * 60)
    print("DAG-Executor Tests")
    print("=" * 60)
    tests = [
        test_linear_pipeline,
        test_parallel_layer,
        test_max_parallel_limit,
        test_mixed_dag,
        test_retry_success,
        test_retry_exhausted,
        test_dependency_skip,
        test_rate_limit_recovery,
        test_resume_from_layer,
        test_progress_callback,
        test_persist_callback,
        test_json_parsing,
        test_invalid_max_parallel,
        test_stop_check_between_layers,
        test_stop_before_first_layer,
    ]
    failed = 0
    for test in tests:
        try:
            await test()
        except AssertionError as e:
            print(f"❌ {test.__name__}: {e}")
            failed += 1
        except Exception as e:
            print(f"💥 {test.__name__}: {type(e).__name__}: {e}")
            import traceback; traceback.print_exc()
            failed += 1

    print("=" * 60)
    if failed:
        print(f"❌ {failed}/{len(tests)} tests failed")
        sys.exit(1)
    else:
        print(f"✅ All {len(tests)} tests passed")


if __name__ == "__main__":
    asyncio.run(main())