Files
2026-07-13 13:12:33 +08:00

212 lines
7.7 KiB
Python

"""Equivalence harness — policy-pipeline self-comparison.
Both control and candidate are :func:`opensquilla.tools.dispatch.build_tool_handler`
(the policy pipeline). This is a determinism/infrastructure smoke-test: two
independent factory calls must produce byte-for-byte identical results for every
corpus case. Any shared mutable state, contextvar leak, or non-deterministic
ordering will surface here.
The legacy dispatch module was removed. The permanent behavioural contract
lives in:
- test_dispatch_corpus_snapshots.py (golden envelopes)
- test_dispatch_properties.py (idempotence, ordering invariants)
- test_dispatch_legacy_coverage.py (line coverage gate, now targets dispatch.py)
"""
from __future__ import annotations
import copy
import json
from typing import Any
import pytest
import structlog.testing
from laboratory import Experiment
from test_tools.dispatch_corpus import ALL_CASES, CorpusCase # noqa: E402
from opensquilla.tool_boundary import ToolResult
from opensquilla.tools.dispatch import build_tool_handler as _build_candidate
# Both sides use the policy-pipeline factory.
from opensquilla.tools.dispatch import build_tool_handler as _build_control
from opensquilla.tools.types import ToolContext, current_tool_context
# -----------------------------------------------------------------------
# Envelope comparator
# -----------------------------------------------------------------------
def _parse_content(content: Any) -> dict[str, Any] | str:
if isinstance(content, str):
try:
return json.loads(content)
except (TypeError, ValueError):
return content
return content
def _envelope_eq(a: ToolResult, b: ToolResult) -> bool:
"""Compare two ToolResult envelopes for dispatch-contract equivalence."""
from opensquilla.execution_status import normalize_execution_status
if type(a) is not type(b):
return False
if a.tool_use_id != b.tool_use_id:
return False
if a.tool_name != b.tool_name:
return False
if a.is_error != b.is_error:
return False
a_es = (
normalize_execution_status(a.execution_status)
if a.execution_status is not None
else None
)
b_es = (
normalize_execution_status(b.execution_status)
if b.execution_status is not None
else None
)
if a_es != b_es:
return False
if _parse_content(a.content) != _parse_content(b.content):
return False
if a.artifacts != b.artifacts:
return False
return True
# -----------------------------------------------------------------------
# Log record normaliser
# -----------------------------------------------------------------------
def _normalise_log_records(records: list[dict[str, Any]]) -> set[tuple[str, str, str]]:
result: set[tuple[str, str, str]] = set()
for rec in records:
result.add((rec.get("_logger", ""), rec.get("log_level", ""), rec.get("event", "")))
return result
# -----------------------------------------------------------------------
# Side-effect capture helper
# -----------------------------------------------------------------------
class _DispatchRun:
def __init__(self) -> None:
self.result: ToolResult | None = None
self.artifact_snapshot: list[dict[str, Any]] = []
self.log_records: set[tuple[str, str, str]] = set()
async def _run_one_side(
build_fn: Any,
case: CorpusCase,
ctx: ToolContext | None,
registry: Any,
) -> _DispatchRun:
run = _DispatchRun()
handler = build_fn(
registry,
ctx,
known_skill_names=set(case.known_skill_names) if case.known_skill_names else None,
)
token = current_tool_context.set(None)
try:
with structlog.testing.capture_logs() as logs:
run.result = await handler(case.tool_call)
run.log_records = _normalise_log_records(logs)
run.artifact_snapshot = list(ctx.published_artifacts) if ctx is not None else []
finally:
current_tool_context.reset(token)
return run
# -----------------------------------------------------------------------
# Experiment subclass
# -----------------------------------------------------------------------
class _MismatchRecorder:
def __init__(self) -> None:
self.mismatches: list[str] = []
class _DispatchExperiment(Experiment):
def __init__(self, name: str, recorder: _MismatchRecorder) -> None:
super().__init__(name=name, raise_on_mismatch=False)
self._recorder = recorder
def compare(self, control: Any, candidate: Any) -> bool: # type: ignore[override]
ctrl_run: _DispatchRun = control.value
cand_run: _DispatchRun = candidate.value
if ctrl_run.result is None or cand_run.result is None:
self._recorder.mismatches.append("One side returned None result")
return False
if not _envelope_eq(ctrl_run.result, cand_run.result):
self._recorder.mismatches.append(
f"Envelope mismatch: is_error={ctrl_run.result.is_error} vs "
f"{cand_run.result.is_error}, "
f"content={_parse_content(ctrl_run.result.content)!r} vs "
f"{_parse_content(cand_run.result.content)!r}"
)
return False
if ctrl_run.artifact_snapshot != cand_run.artifact_snapshot:
self._recorder.mismatches.append(
f"Artifact mismatch: {ctrl_run.artifact_snapshot!r} vs "
f"{cand_run.artifact_snapshot!r}"
)
return False
if ctrl_run.log_records != cand_run.log_records:
self._recorder.mismatches.append(
f"Log record mismatch: {ctrl_run.log_records!r} vs "
f"{cand_run.log_records!r}"
)
return False
return True
def publish(self, result: Any) -> None: # type: ignore[override]
pass
# -----------------------------------------------------------------------
# Async harness runner
# -----------------------------------------------------------------------
async def _run_equivalence(case: CorpusCase) -> _MismatchRecorder:
recorder = _MismatchRecorder()
ctrl_ctx = copy.deepcopy(case.ctx_factory())
cand_ctx = copy.deepcopy(case.ctx_factory())
ctrl_registry = case.registry_factory()
cand_registry = case.registry_factory()
if case.setup is not None:
case.setup()
try:
ctrl_run = await _run_one_side(_build_control, case, ctrl_ctx, ctrl_registry)
cand_run = await _run_one_side(_build_candidate, case, cand_ctx, cand_registry)
finally:
if case.teardown is not None:
case.teardown()
experiment = _DispatchExperiment(name=case.name, recorder=recorder)
experiment.control(lambda: ctrl_run)
experiment.candidate(lambda: cand_run)
experiment.conduct(randomize=False)
ctx_val = current_tool_context.get()
if ctx_val is not None and case.contextvar_must_be_none_after:
recorder.mismatches.append(
f"Contextvar leak after dispatch: current_tool_context.get() = {ctx_val!r}"
)
return recorder
# -----------------------------------------------------------------------
# Parametrised test
# -----------------------------------------------------------------------
@pytest.mark.parametrize("case", ALL_CASES, ids=[c.name for c in ALL_CASES])
@pytest.mark.asyncio
async def test_dispatch_equivalence_corpus(case: CorpusCase) -> None:
"""Self-comparison: two independent factory calls must produce identical results."""
recorder = await _run_equivalence(case)
assert not recorder.mismatches, (
f"Equivalence failures for case '{case.name}':\n"
+ "\n".join(f" - {m}" for m in recorder.mismatches)
)