From 1f67adcceff67eae6818c2b8d73fffbd09a13a70 Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Wed, 25 Mar 2026 08:19:18 +0900 Subject: [PATCH 01/15] fix: validation gate, termination reason, tests --- src/ouroboros/core/lineage.py | 12 +- src/ouroboros/evolution/convergence.py | 11 +- src/ouroboros/evolution/loop.py | 10 ++ src/ouroboros/evolution/projector.py | 15 ++- tests/unit/evolution/test_reflect_engine.py | 138 ++++++++++++++++++++ tests/unit/evolution/test_wonder_engine.py | 137 +++++++++++++++++++ 6 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 tests/unit/evolution/test_reflect_engine.py create mode 100644 tests/unit/evolution/test_wonder_engine.py diff --git a/src/ouroboros/core/lineage.py b/src/ouroboros/core/lineage.py index 368ac29a..bf8a87a4 100644 --- a/src/ouroboros/core/lineage.py +++ b/src/ouroboros/core/lineage.py @@ -214,6 +214,7 @@ class OntologyLineage(BaseModel, frozen=True): generations: tuple[GenerationRecord, ...] = Field(default_factory=tuple) rewind_history: tuple[RewindRecord, ...] = Field(default_factory=tuple) status: LineageStatus = LineageStatus.ACTIVE + termination_reason: str | None = None created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) @property @@ -230,9 +231,14 @@ def with_generation(self, record: GenerationRecord) -> OntologyLineage: """Return new lineage with appended generation.""" return self.model_copy(update={"generations": self.generations + (record,)}) - def with_status(self, status: LineageStatus) -> OntologyLineage: - """Return new lineage with updated status.""" - return self.model_copy(update={"status": status}) + def with_status( + self, status: LineageStatus, termination_reason: str | None = None + ) -> OntologyLineage: + """Return new lineage with updated status and optional termination reason.""" + updates: dict = {"status": status} + if termination_reason is not None: + updates["termination_reason"] = termination_reason + return self.model_copy(update=updates) def rewind_to(self, generation_number: int) -> OntologyLineage: """Return lineage truncated to the given generation. diff --git a/src/ouroboros/evolution/convergence.py b/src/ouroboros/evolution/convergence.py index 73bc801a..14f79e0a 100644 --- a/src/ouroboros/evolution/convergence.py +++ b/src/ouroboros/evolution/convergence.py @@ -65,6 +65,7 @@ def evaluate( latest_wonder: WonderOutput | None = None, latest_evaluation: EvaluationSummary | None = None, validation_output: str | None = None, + validation_passed: bool | None = None, ) -> ConvergenceSignal: """Check if the loop should terminate. @@ -172,7 +173,15 @@ def evaluate( # Validation gate: block convergence if validation was skipped or failed if self.validation_gate_enabled and validation_output: - if "skipped" in validation_output.lower() or "error" in validation_output.lower(): + # Use explicit bool flag when available; fall back to string matching + if validation_passed is not None: + gate_blocked = not validation_passed + else: + gate_blocked = ( + "skipped" in validation_output.lower() + or "error" in validation_output.lower() + ) + if gate_blocked: return ConvergenceSignal( converged=False, reason=(f"Validation gate blocked: {validation_output}"), diff --git a/src/ouroboros/evolution/loop.py b/src/ouroboros/evolution/loop.py index d800fda3..d9d84904 100644 --- a/src/ouroboros/evolution/loop.py +++ b/src/ouroboros/evolution/loop.py @@ -83,6 +83,7 @@ class GenerationResult: reflect_output: ReflectOutput | None = None ontology_delta: OntologyDelta | None = None validation_output: str | None = None + validation_passed: bool | None = None phase: GenerationPhase = GenerationPhase.COMPLETED success: bool = True @@ -312,6 +313,7 @@ async def run( result.wonder_output, latest_evaluation=result.evaluation_summary, validation_output=result.validation_output, + validation_passed=result.validation_passed, ) if signal.converged: @@ -924,19 +926,25 @@ async def _run_generation_phases( # Validate phase - reconcile parallel execution artifacts validation_output: str | None = None + validation_passed: bool | None = None if execute and execution_output and self.validator: try: validation_result = await self.validator(current_seed, execution_output) if isinstance(validation_result, str): validation_output = validation_result + validation_passed = True elif hasattr(validation_result, "is_ok"): if validation_result.is_ok: validation_output = str(validation_result.value) + validation_passed = True else: validation_output = f"Validation error: {validation_result.error}" + validation_passed = False else: validation_output = str(validation_result) + validation_passed = True if validation_output and "skipped" in validation_output.lower(): + validation_passed = False logger.warning( "evolution.generation.validation_skipped", extra={"generation": generation_number, "output": validation_output}, @@ -952,6 +960,7 @@ async def _run_generation_phases( extra={"error": str(e), "generation": generation_number}, ) validation_output = f"Validation skipped: {e}" + validation_passed = False # Phase transition: → evaluating await self.event_store.append( @@ -1010,6 +1019,7 @@ async def _run_generation_phases( reflect_output=reflect_output, ontology_delta=ontology_delta, validation_output=validation_output, + validation_passed=validation_passed, phase=GenerationPhase.COMPLETED, success=True, ) diff --git a/src/ouroboros/evolution/projector.py b/src/ouroboros/evolution/projector.py index 19c48dd5..ac7b3cdc 100644 --- a/src/ouroboros/evolution/projector.py +++ b/src/ouroboros/evolution/projector.py @@ -135,15 +135,24 @@ def project(self, events: list[BaseEvent]) -> OntologyLineage | None: elif event.type == "lineage.converged": if lineage is not None: - lineage = lineage.with_status(LineageStatus.CONVERGED) + reason = event.data.get("reason", "ontology_converged") + lineage = lineage.with_status( + LineageStatus.CONVERGED, termination_reason=reason + ) elif event.type == "lineage.exhausted": if lineage is not None: - lineage = lineage.with_status(LineageStatus.EXHAUSTED) + reason = event.data.get("reason", "max_generations") + lineage = lineage.with_status( + LineageStatus.EXHAUSTED, termination_reason=reason + ) elif event.type == "lineage.stagnated": if lineage is not None: - lineage = lineage.with_status(LineageStatus.CONVERGED) + reason = event.data.get("reason", "stagnation") + lineage = lineage.with_status( + LineageStatus.CONVERGED, termination_reason=reason + ) elif event.type == "lineage.rewound": data = event.data diff --git a/tests/unit/evolution/test_reflect_engine.py b/tests/unit/evolution/test_reflect_engine.py new file mode 100644 index 00000000..39ec9258 --- /dev/null +++ b/tests/unit/evolution/test_reflect_engine.py @@ -0,0 +1,138 @@ +"""Unit tests for ReflectEngine._parse_response. + +Tests JSON parsing, mutation extraction, and fallback behavior without LLM calls. +""" + +from __future__ import annotations + +import pytest + +from ouroboros.core.lineage import MutationAction +from ouroboros.core.seed import OntologyField, OntologySchema, Seed, SeedMetadata +from ouroboros.evolution.reflect import OntologyMutation, ReflectEngine, ReflectOutput + + +def _make_engine() -> ReflectEngine: + """Create ReflectEngine with a dummy adapter (not used in these tests).""" + + class DummyAdapter: + async def complete(self, messages, config): + raise NotImplementedError + + return ReflectEngine(llm_adapter=DummyAdapter()) # type: ignore[arg-type] + + +def _make_seed(**overrides) -> Seed: + defaults = { + "goal": "Build a task manager", + "constraints": ("Must use Python",), + "acceptance_criteria": ("Tasks can be created",), + "ontology_schema": OntologySchema( + name="TaskManager", + description="A task management system", + fields=( + OntologyField(name="task", field_type="entity", description="A work item"), + ), + ), + "metadata": SeedMetadata(), + } + defaults.update(overrides) + return Seed(**defaults) + + +class TestParseResponse: + """Tests for ReflectEngine._parse_response.""" + + def test_valid_full_response(self) -> None: + engine = _make_engine() + seed = _make_seed() + result = engine._parse_response( + '{"refined_goal": "Build a prioritized task manager", ' + '"refined_constraints": ["Must use Python", "Must support priorities"], ' + '"refined_acs": ["Tasks can be created", "Tasks can be prioritized"], ' + '"ontology_mutations": [' + ' {"action": "add", "field_name": "priority", "field_type": "enum", ' + ' "description": "Task priority level", "reason": "Missing from ontology"}' + '], ' + '"reasoning": "Priority was identified as a gap"}', + seed, + ) + assert result is not None + assert result.refined_goal == "Build a prioritized task manager" + assert len(result.refined_constraints) == 2 + assert len(result.refined_acs) == 2 + assert len(result.ontology_mutations) == 1 + assert result.ontology_mutations[0].action == MutationAction.ADD + assert result.ontology_mutations[0].field_name == "priority" + + def test_json_with_markdown_fences(self) -> None: + engine = _make_engine() + seed = _make_seed() + result = engine._parse_response( + '```json\n{"refined_goal": "g", "refined_constraints": [], ' + '"refined_acs": [], "ontology_mutations": [], "reasoning": "r"}\n```', + seed, + ) + assert result is not None + assert result.refined_goal == "g" + + def test_invalid_json_returns_none(self) -> None: + engine = _make_engine() + seed = _make_seed() + result = engine._parse_response("not json", seed) + assert result is None + + def test_missing_fields_use_seed_defaults(self) -> None: + engine = _make_engine() + seed = _make_seed() + result = engine._parse_response("{}", seed) + assert result is not None + assert result.refined_goal == seed.goal + assert result.refined_constraints == seed.constraints + assert result.refined_acs == seed.acceptance_criteria + + def test_invalid_mutation_action_defaults_to_modify(self) -> None: + engine = _make_engine() + seed = _make_seed() + result = engine._parse_response( + '{"ontology_mutations": [{"action": "invalid_action", "field_name": "x"}]}', + seed, + ) + assert result is not None + assert result.ontology_mutations[0].action == MutationAction.MODIFY + + def test_mutation_missing_field_name_defaults(self) -> None: + engine = _make_engine() + seed = _make_seed() + result = engine._parse_response( + '{"ontology_mutations": [{"action": "add"}]}', + seed, + ) + assert result is not None + assert result.ontology_mutations[0].field_name == "unknown" + + def test_multiple_mutations(self) -> None: + engine = _make_engine() + seed = _make_seed() + result = engine._parse_response( + '{"ontology_mutations": [' + ' {"action": "add", "field_name": "a", "reason": "r1"},' + ' {"action": "modify", "field_name": "task", "reason": "r2"},' + ' {"action": "remove", "field_name": "old", "reason": "r3"}' + "]}", + seed, + ) + assert result is not None + assert len(result.ontology_mutations) == 3 + actions = [m.action for m in result.ontology_mutations] + assert actions == [MutationAction.ADD, MutationAction.MODIFY, MutationAction.REMOVE] + + def test_empty_mutations_list(self) -> None: + engine = _make_engine() + seed = _make_seed() + result = engine._parse_response( + '{"refined_goal": "g", "ontology_mutations": []}', + seed, + ) + assert result is not None + assert result.ontology_mutations == () diff --git a/tests/unit/evolution/test_wonder_engine.py b/tests/unit/evolution/test_wonder_engine.py new file mode 100644 index 00000000..4489dfb5 --- /dev/null +++ b/tests/unit/evolution/test_wonder_engine.py @@ -0,0 +1,137 @@ +"""Unit tests for WonderEngine._parse_response and _degraded_output. + +Tests the JSON parsing logic and degraded mode heuristics without LLM calls. +""" + +from __future__ import annotations + +import pytest + +from ouroboros.core.lineage import ACResult, EvaluationSummary +from ouroboros.core.seed import OntologyField, OntologySchema +from ouroboros.evolution.wonder import WonderEngine, WonderOutput + + +def _make_engine() -> WonderEngine: + """Create WonderEngine with a dummy adapter (not used in these tests).""" + + class DummyAdapter: + async def complete(self, messages, config): + raise NotImplementedError + + return WonderEngine(llm_adapter=DummyAdapter()) # type: ignore[arg-type] + + +class TestParseResponse: + """Tests for WonderEngine._parse_response.""" + + def test_valid_json(self) -> None: + engine = _make_engine() + result = engine._parse_response( + '{"questions": ["q1", "q2"], "ontology_tensions": ["t1"], ' + '"should_continue": true, "reasoning": "analysis"}' + ) + assert result.questions == ("q1", "q2") + assert result.ontology_tensions == ("t1",) + assert result.should_continue is True + assert result.reasoning == "analysis" + + def test_json_with_markdown_fences(self) -> None: + engine = _make_engine() + result = engine._parse_response( + '```json\n{"questions": ["q1"], "ontology_tensions": [], ' + '"should_continue": false, "reasoning": "done"}\n```' + ) + assert result.questions == ("q1",) + assert result.should_continue is False + + def test_invalid_json_returns_fallback(self) -> None: + engine = _make_engine() + result = engine._parse_response("not valid json at all") + assert len(result.questions) > 0 + assert result.should_continue is True + assert "Parse error" in result.reasoning + + def test_empty_json_object(self) -> None: + engine = _make_engine() + result = engine._parse_response("{}") + assert result.questions == () + assert result.ontology_tensions == () + assert result.should_continue is True + + def test_partial_json_missing_fields(self) -> None: + engine = _make_engine() + result = engine._parse_response('{"questions": ["only questions"]}') + assert result.questions == ("only questions",) + assert result.ontology_tensions == () + assert result.should_continue is True + + def test_should_continue_false(self) -> None: + engine = _make_engine() + result = engine._parse_response( + '{"questions": [], "ontology_tensions": [], ' + '"should_continue": false, "reasoning": "ontology is complete"}' + ) + assert result.should_continue is False + + +class TestDegradedOutput: + """Tests for WonderEngine._degraded_output heuristics.""" + + def _make_ontology(self, num_fields: int = 5) -> OntologySchema: + return OntologySchema( + name="TestOntology", + description="Test", + fields=tuple( + OntologyField(name=f"field_{i}", field_type="string", description=f"Field {i}") + for i in range(num_fields) + ), + ) + + def test_no_eval_summary(self) -> None: + engine = _make_engine() + result = engine._degraded_output(None, self._make_ontology()) + assert len(result.questions) > 0 + assert result.should_continue is True + assert "Degraded mode" in result.reasoning + + def test_failed_evaluation(self) -> None: + engine = _make_engine() + eval_summary = EvaluationSummary( + final_approved=False, + highest_stage_passed=1, + failure_reason="Stage 1 failed: lint errors", + ) + result = engine._degraded_output(eval_summary, self._make_ontology()) + assert any("fundamental" in q.lower() or "missing" in q.lower() for q in result.questions) + + def test_high_drift(self) -> None: + engine = _make_engine() + eval_summary = EvaluationSummary( + final_approved=False, + highest_stage_passed=2, + drift_score=0.5, + ) + result = engine._degraded_output(eval_summary, self._make_ontology()) + assert any("drift" in q.lower() for q in result.questions) + assert len(result.ontology_tensions) > 0 + + def test_sparse_ontology(self) -> None: + engine = _make_engine() + eval_summary = EvaluationSummary( + final_approved=True, + highest_stage_passed=3, + ) + result = engine._degraded_output(eval_summary, self._make_ontology(num_fields=2)) + assert any("missing" in q.lower() or "entities" in q.lower() for q in result.questions) + + def test_fully_approved_rich_ontology(self) -> None: + engine = _make_engine() + eval_summary = EvaluationSummary( + final_approved=True, + highest_stage_passed=3, + score=0.95, + ) + result = engine._degraded_output(eval_summary, self._make_ontology(num_fields=10)) + assert result.should_continue is True + assert len(result.questions) > 0 # always at least a fallback question From 8acf2aace485123ce39b797ee2b58babc6a06943 Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Sun, 22 Mar 2026 15:20:31 +0900 Subject: [PATCH 02/15] feat(evolution): add Gate Guard transition matrix for event validation Introduce LineageGuard that validates state transitions before persisting lineage events to EventStore. Includes ALLOWED_TRANSITIONS matrix, TERMINAL_EVENT_STATUS single source of truth, TransitionError, and cross-validation tests (34 new tests). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ouroboros/core/errors.py | 24 +++ src/ouroboros/evolution/guard.py | 70 ++++++++ src/ouroboros/evolution/transitions.py | 63 +++++++ tests/unit/evolution/test_event_contract.py | 120 +++++++++++++ tests/unit/evolution/test_guard.py | 178 ++++++++++++++++++++ tests/unit/evolution/test_transitions.py | 170 +++++++++++++++++++ 6 files changed, 625 insertions(+) create mode 100644 src/ouroboros/evolution/guard.py create mode 100644 src/ouroboros/evolution/transitions.py create mode 100644 tests/unit/evolution/test_event_contract.py create mode 100644 tests/unit/evolution/test_guard.py create mode 100644 tests/unit/evolution/test_transitions.py diff --git a/src/ouroboros/core/errors.py b/src/ouroboros/core/errors.py index 2acde1f9..32b2e180 100644 --- a/src/ouroboros/core/errors.py +++ b/src/ouroboros/core/errors.py @@ -9,6 +9,7 @@ ├── ProviderError - LLM provider failures (rate limits, API errors) ├── ConfigError - Configuration and credentials issues ├── PersistenceError - Database and storage issues + ├── TransitionError - Invalid state transitions in event sourcing └── ValidationError - Schema and data validation failures """ @@ -162,6 +163,29 @@ def __init__( self.table = table +class TransitionError(OuroborosError): + """Error from invalid state transitions in event sourcing. + + Raised when a lineage event is not allowed in the current lineage status. + + Attributes: + current_status: The lineage status at the time of the attempted transition. + event_type: The event type that was rejected. + """ + + def __init__( + self, + message: str, + *, + current_status: str, + event_type: str, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message, details) + self.current_status = current_status + self.event_type = event_type + + class ValidationError(OuroborosError): """Error from data validation operations. diff --git a/src/ouroboros/evolution/guard.py b/src/ouroboros/evolution/guard.py new file mode 100644 index 00000000..3d0a2ec9 --- /dev/null +++ b/src/ouroboros/evolution/guard.py @@ -0,0 +1,70 @@ +"""LineageGuard — validates state transitions before event persistence. + +Wraps EventStore.append() with transition matrix validation. Only lineage +events (aggregate_type == "lineage") are validated; all other events pass +through unconditionally. + +Note: append_batch() is not covered by this guard. Lineage events currently +do not use batch append. +""" + +from __future__ import annotations + +import logging + +from ouroboros.core.errors import TransitionError +from ouroboros.core.lineage import LineageStatus +from ouroboros.events.base import BaseEvent +from ouroboros.evolution.projector import LineageProjector +from ouroboros.evolution.transitions import is_transition_allowed +from ouroboros.persistence.event_store import EventStore + +logger = logging.getLogger(__name__) + + +class LineageGuard: + """Validates lineage event transitions before persisting to EventStore. + + Usage: + guard = LineageGuard(event_store) + await guard.gated_append(event) # validates, then appends + """ + + def __init__(self, event_store: EventStore) -> None: + self._event_store = event_store + + async def gated_append(self, event: BaseEvent) -> None: + """Validate and append an event to the EventStore. + + Non-lineage events (aggregate_type != "lineage") pass through + without validation. lineage.created always passes (initial event). + All other lineage events are checked against the transition matrix. + + Args: + event: The event to validate and append. + + Raises: + TransitionError: If the event is not allowed in the current + lineage status. + PersistenceError: If the underlying append fails. + """ + if event.aggregate_type != "lineage": + await self._event_store.append(event) + return + + if event.type == "lineage.created": + await self._event_store.append(event) + return + + # Determine current status from event stream (single source of truth) + events = await self._event_store.replay_lineage(event.aggregate_id) + current_status = LineageProjector.resolve_status(events) + + if not is_transition_allowed(current_status, event.type): + raise TransitionError( + f"Event '{event.type}' not allowed in status '{current_status.value}'", + current_status=current_status.value, + event_type=event.type, + ) + + await self._event_store.append(event) diff --git a/src/ouroboros/evolution/transitions.py b/src/ouroboros/evolution/transitions.py new file mode 100644 index 00000000..f1b2b2af --- /dev/null +++ b/src/ouroboros/evolution/transitions.py @@ -0,0 +1,63 @@ +"""Lineage state transition matrix for Gate Guard validation. + +Defines which events are allowed in each LineageStatus, and the mapping +from terminal events to their resulting status. Both guard.py and +projector.py reference these definitions as the single source of truth. +""" + +from __future__ import annotations + +from ouroboros.core.lineage import LineageStatus + +# Terminal event → resulting LineageStatus mapping. +# Used by LineageProjector.resolve_status() and project() to determine +# lineage status from events. Single source of truth for both guard and projector. +TERMINAL_EVENT_STATUS: dict[str, LineageStatus] = { + "lineage.converged": LineageStatus.CONVERGED, + "lineage.stagnated": LineageStatus.CONVERGED, # stagnation is a form of convergence + "lineage.exhausted": LineageStatus.EXHAUSTED, + "lineage.rewound": LineageStatus.ACTIVE, +} + +# LineageStatus × event_type → allowed? +# lineage.created is handled separately (always allowed as the first event). +# Non-lineage events (aggregate_type != "lineage") bypass the guard entirely. +# Observation events (ontology.evolved, wonder.degraded) don't change status +# but are only valid while the lineage is ACTIVE. +ALLOWED_TRANSITIONS: dict[LineageStatus, frozenset[str]] = { + LineageStatus.ACTIVE: frozenset( + { + "lineage.generation.started", + "lineage.generation.completed", + "lineage.generation.phase_changed", + "lineage.generation.failed", + "lineage.ontology.evolved", + "lineage.converged", + "lineage.exhausted", + "lineage.stagnated", + "lineage.rewound", + "lineage.wonder.degraded", + } + ), + LineageStatus.CONVERGED: frozenset( + { + "lineage.rewound", # rewind restores ACTIVE status + } + ), + LineageStatus.EXHAUSTED: frozenset( + { + "lineage.rewound", + } + ), + # ABORTED is a terminal state with no exit transitions. + # Entry event (lineage.aborted) is not yet implemented. + LineageStatus.ABORTED: frozenset(), +} + + +def is_transition_allowed(status: LineageStatus, event_type: str) -> bool: + """Check if an event type is allowed in the given lineage status.""" + allowed = ALLOWED_TRANSITIONS.get(status) + if allowed is None: + return False + return event_type in allowed diff --git a/tests/unit/evolution/test_event_contract.py b/tests/unit/evolution/test_event_contract.py new file mode 100644 index 00000000..e49119cf --- /dev/null +++ b/tests/unit/evolution/test_event_contract.py @@ -0,0 +1,120 @@ +"""Contract test: lineage event factory types must match projector handling. + +Ensures every event type defined in events/lineage.py is explicitly handled +(or explicitly excluded) by LineageProjector.project(). Prevents silent event +drops when new lineage events are added without updating the projector. +""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import pytest + +# --- Paths --- +_SRC = Path(__file__).resolve().parents[3] / "src" / "ouroboros" +_LINEAGE_EVENTS = _SRC / "events" / "lineage.py" +_PROJECTOR = _SRC / "evolution" / "projector.py" + + +def _extract_event_types_from_factories(path: Path) -> set[str]: + """Extract all event type strings from BaseEvent(type=...) calls.""" + source = path.read_text() + tree = ast.parse(source) + types: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.keyword) and node.arg == "type": + if isinstance(node.value, ast.Constant) and isinstance( + node.value.value, str + ): + types.add(node.value.value) + return types + + +def _extract_handled_types_from_projector(path: Path) -> set[str]: + """Extract all event.type == '...' and event.type in (...) comparisons from projector.""" + source = path.read_text() + # Match event.type == "..." + types = set(re.findall(r'event\.type\s*==\s*"([^"]+)"', source)) + # Match event.type in ("...", "...", ...) + in_matches = re.findall(r'event\.type\s+in\s*\(([^)]+)\)', source) + for match in in_matches: + types |= set(re.findall(r'"([^"]+)"', match)) + return types + + +# Events that are intentionally NOT handled by the projector. +# Each entry must have a justification comment. +INTENTIONALLY_UNHANDLED: dict[str, str] = { + "lineage.ontology.evolved": ( + "Observability-only event. Ontology data is already captured " + "in lineage.generation.completed via ontology_snapshot field." + ), + "lineage.wonder.degraded": ( + "Observability-only event. Degraded wonder questions are still " + "recorded in generation.completed. No OntologyLineage field exists " + "for degradation state." + ), +} + + +class TestLineageEventContract: + """Verify that lineage event factories and projector stay in sync.""" + + def test_all_factory_events_are_handled_or_excluded(self) -> None: + """Every event type in events/lineage.py must be either: + - handled in projector.py (event.type == "...") + - listed in INTENTIONALLY_UNHANDLED with justification + """ + factory_types = _extract_event_types_from_factories(_LINEAGE_EVENTS) + handled_types = _extract_handled_types_from_projector(_PROJECTOR) + excluded_types = set(INTENTIONALLY_UNHANDLED.keys()) + + unaccounted = factory_types - handled_types - excluded_types + + assert not unaccounted, ( + f"Lineage event types defined in events/lineage.py but neither " + f"handled by LineageProjector nor listed in INTENTIONALLY_UNHANDLED: " + f"{sorted(unaccounted)}. " + f"Either add handling in projector.py or add to " + f"INTENTIONALLY_UNHANDLED with justification." + ) + + def test_projector_handles_no_phantom_events(self) -> None: + """Projector must not handle event types that don't exist in factories.""" + factory_types = _extract_event_types_from_factories(_LINEAGE_EVENTS) + handled_types = _extract_handled_types_from_projector(_PROJECTOR) + + phantom = handled_types - factory_types + + assert not phantom, ( + f"LineageProjector handles event types not defined in " + f"events/lineage.py: {sorted(phantom)}. " + f"Remove stale handlers or add missing factory functions." + ) + + def test_exclusion_list_has_no_stale_entries(self) -> None: + """INTENTIONALLY_UNHANDLED must not contain events that are now handled.""" + handled_types = _extract_handled_types_from_projector(_PROJECTOR) + excluded_types = set(INTENTIONALLY_UNHANDLED.keys()) + + stale = excluded_types & handled_types + + assert not stale, ( + f"Events in INTENTIONALLY_UNHANDLED are now handled by projector: " + f"{sorted(stale)}. Remove them from the exclusion list." + ) + + def test_exclusion_entries_exist_in_factories(self) -> None: + """INTENTIONALLY_UNHANDLED entries must reference real factory events.""" + factory_types = _extract_event_types_from_factories(_LINEAGE_EVENTS) + excluded_types = set(INTENTIONALLY_UNHANDLED.keys()) + + invalid = excluded_types - factory_types + + assert not invalid, ( + f"INTENTIONALLY_UNHANDLED references non-existent event types: " + f"{sorted(invalid)}. Remove invalid entries." + ) diff --git a/tests/unit/evolution/test_guard.py b/tests/unit/evolution/test_guard.py new file mode 100644 index 00000000..cb0f2b2d --- /dev/null +++ b/tests/unit/evolution/test_guard.py @@ -0,0 +1,178 @@ +"""Unit tests for LineageGuard — transition validation before persistence. + +Tests the gated_append() flow: +- Non-lineage events pass through without validation +- lineage.created always passes +- Valid transitions are allowed +- Invalid transitions raise TransitionError +- Rewind re-enables generation events after convergence +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from ouroboros.core.errors import TransitionError +from ouroboros.core.lineage import LineageStatus +from ouroboros.events.base import BaseEvent +from ouroboros.events.lineage import ( + lineage_converged, + lineage_created, + lineage_exhausted, + lineage_generation_started, + lineage_rewound, + lineage_stagnated, +) +from ouroboros.evolution.guard import LineageGuard + + +@pytest.fixture +def mock_event_store() -> AsyncMock: + store = AsyncMock() + store.append = AsyncMock() + store.replay_lineage = AsyncMock(return_value=[]) + return store + + +@pytest.fixture +def guard(mock_event_store: AsyncMock) -> LineageGuard: + return LineageGuard(mock_event_store) + + +class TestNonLineagePassthrough: + """Non-lineage events bypass guard entirely.""" + + @pytest.mark.asyncio + async def test_drift_event_passes_through( + self, guard: LineageGuard, mock_event_store: AsyncMock + ) -> None: + event = BaseEvent( + type="drift.measured", + aggregate_type="execution", + aggregate_id="exec_123", + data={}, + ) + await guard.gated_append(event) + mock_event_store.append.assert_awaited_once_with(event) + + @pytest.mark.asyncio + async def test_session_event_passes_through( + self, guard: LineageGuard, mock_event_store: AsyncMock + ) -> None: + event = BaseEvent( + type="orchestrator.session.started", + aggregate_type="session", + aggregate_id="sess_123", + data={}, + ) + await guard.gated_append(event) + mock_event_store.append.assert_awaited_once_with(event) + + +class TestCreatedAlwaysPasses: + """lineage.created always passes without status check.""" + + @pytest.mark.asyncio + async def test_created_bypasses_validation( + self, guard: LineageGuard, mock_event_store: AsyncMock + ) -> None: + event = lineage_created("lin_abc", "test goal") + await guard.gated_append(event) + mock_event_store.append.assert_awaited_once_with(event) + # replay_lineage should NOT be called for created events + mock_event_store.replay_lineage.assert_not_awaited() + + +class TestValidTransitions: + """Events allowed by the transition matrix are stored.""" + + @pytest.mark.asyncio + async def test_active_allows_generation_started( + self, guard: LineageGuard, mock_event_store: AsyncMock + ) -> None: + """ACTIVE lineage allows generation.started.""" + # No terminal events → status is ACTIVE + mock_event_store.replay_lineage.return_value = [] + event = lineage_generation_started("lin_abc", 1, "wondering") + await guard.gated_append(event) + mock_event_store.append.assert_awaited_once_with(event) + + +class TestInvalidTransitions: + """Events not in the transition matrix raise TransitionError.""" + + @pytest.mark.asyncio + async def test_converged_rejects_generation_started( + self, guard: LineageGuard, mock_event_store: AsyncMock + ) -> None: + """CONVERGED lineage rejects generation.started.""" + # Simulate converged state + mock_event_store.replay_lineage.return_value = [ + lineage_created("lin_abc", "goal"), + lineage_converged("lin_abc", 3, "stable", 0.98), + ] + event = lineage_generation_started("lin_abc", 4, "wondering") + with pytest.raises(TransitionError) as exc_info: + await guard.gated_append(event) + assert exc_info.value.current_status == "converged" + assert exc_info.value.event_type == "lineage.generation.started" + # Event should NOT be stored + mock_event_store.append.assert_not_awaited() + + @pytest.mark.asyncio + async def test_exhausted_rejects_generation_started( + self, guard: LineageGuard, mock_event_store: AsyncMock + ) -> None: + mock_event_store.replay_lineage.return_value = [ + lineage_created("lin_abc", "goal"), + lineage_exhausted("lin_abc", 30, 30), + ] + event = lineage_generation_started("lin_abc", 31, "wondering") + with pytest.raises(TransitionError): + await guard.gated_append(event) + + @pytest.mark.asyncio + async def test_stagnated_maps_to_converged( + self, guard: LineageGuard, mock_event_store: AsyncMock + ) -> None: + """lineage.stagnated results in CONVERGED status (same as projector).""" + mock_event_store.replay_lineage.return_value = [ + lineage_created("lin_abc", "goal"), + lineage_stagnated("lin_abc", 5, "Stagnation detected", 3), + ] + event = lineage_generation_started("lin_abc", 6, "wondering") + with pytest.raises(TransitionError) as exc_info: + await guard.gated_append(event) + assert exc_info.value.current_status == "converged" + + +class TestRewindReactivation: + """Rewind restores ACTIVE status, enabling new generation events.""" + + @pytest.mark.asyncio + async def test_rewind_after_converged_allows_generation( + self, guard: LineageGuard, mock_event_store: AsyncMock + ) -> None: + # Converged, then rewound → ACTIVE + mock_event_store.replay_lineage.return_value = [ + lineage_created("lin_abc", "goal"), + lineage_converged("lin_abc", 3, "stable", 0.98), + lineage_rewound("lin_abc", 3, 2), + ] + event = lineage_generation_started("lin_abc", 3, "wondering") + await guard.gated_append(event) + mock_event_store.append.assert_awaited_once_with(event) + + @pytest.mark.asyncio + async def test_converged_allows_rewound( + self, guard: LineageGuard, mock_event_store: AsyncMock + ) -> None: + mock_event_store.replay_lineage.return_value = [ + lineage_created("lin_abc", "goal"), + lineage_converged("lin_abc", 3, "stable", 0.98), + ] + event = lineage_rewound("lin_abc", 3, 2) + await guard.gated_append(event) + mock_event_store.append.assert_awaited_once_with(event) diff --git a/tests/unit/evolution/test_transitions.py b/tests/unit/evolution/test_transitions.py new file mode 100644 index 00000000..27d5be6c --- /dev/null +++ b/tests/unit/evolution/test_transitions.py @@ -0,0 +1,170 @@ +"""Unit tests for lineage state transition matrix. + +Validates that: +1. Every LineageStatus has an entry in ALLOWED_TRANSITIONS +2. Every event factory type from events/lineage.py is in the matrix +3. Transition rules are correct per status +4. TERMINAL_EVENT_STATUS is consistent with ALLOWED_TRANSITIONS +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from ouroboros.core.lineage import LineageStatus +from ouroboros.evolution.transitions import ( + ALLOWED_TRANSITIONS, + TERMINAL_EVENT_STATUS, + is_transition_allowed, +) + +_SRC = Path(__file__).resolve().parents[3] / "src" / "ouroboros" +_LINEAGE_EVENTS = _SRC / "events" / "lineage.py" + + +def _extract_event_types_from_factories(path: Path) -> set[str]: + """Extract all event type strings from BaseEvent(type=...) calls.""" + source = path.read_text() + tree = ast.parse(source) + types: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.keyword) and node.arg == "type": + if isinstance(node.value, ast.Constant) and isinstance( + node.value.value, str + ): + types.add(node.value.value) + return types + + +# --- Completeness tests --- + + +class TestMatrixCompleteness: + """Every LineageStatus and event type must be accounted for.""" + + def test_all_statuses_have_transition_entry(self) -> None: + """Every LineageStatus member must be a key in ALLOWED_TRANSITIONS.""" + for status in LineageStatus: + assert status in ALLOWED_TRANSITIONS, ( + f"LineageStatus.{status.name} missing from ALLOWED_TRANSITIONS. " + f"Add it with the appropriate allowed event set." + ) + + def test_all_factory_event_types_in_matrix(self) -> None: + """Every event type from events/lineage.py must appear in the matrix. + + lineage.created is handled separately by Guard (always pass), + but all others must be in ACTIVE's allowed set at minimum. + """ + factory_types = _extract_event_types_from_factories(_LINEAGE_EVENTS) + all_matrix_types = set() + for allowed in ALLOWED_TRANSITIONS.values(): + all_matrix_types |= allowed + # lineage.created is bypassed by guard, not in matrix + factory_types_without_created = factory_types - {"lineage.created"} + missing = factory_types_without_created - all_matrix_types + assert not missing, ( + f"Event types defined in events/lineage.py but missing from " + f"ALLOWED_TRANSITIONS: {missing}" + ) + + def test_terminal_event_status_keys_in_matrix(self) -> None: + """Every TERMINAL_EVENT_STATUS key must appear in some ALLOWED_TRANSITIONS set.""" + all_matrix_types = set() + for allowed in ALLOWED_TRANSITIONS.values(): + all_matrix_types |= allowed + for event_type in TERMINAL_EVENT_STATUS: + assert event_type in all_matrix_types, ( + f"TERMINAL_EVENT_STATUS key '{event_type}' not found in any " + f"ALLOWED_TRANSITIONS set" + ) + + +# --- ACTIVE state --- + + +class TestActiveTransitions: + """ACTIVE state allows all generation lifecycle events.""" + + @pytest.mark.parametrize( + "event_type", + [ + "lineage.generation.started", + "lineage.generation.completed", + "lineage.generation.phase_changed", + "lineage.generation.failed", + "lineage.ontology.evolved", + "lineage.converged", + "lineage.exhausted", + "lineage.stagnated", + "lineage.rewound", + "lineage.wonder.degraded", + ], + ) + def test_active_allows_event(self, event_type: str) -> None: + assert is_transition_allowed(LineageStatus.ACTIVE, event_type) + + def test_active_rejects_unknown_event(self) -> None: + assert not is_transition_allowed(LineageStatus.ACTIVE, "lineage.unknown") + + +# --- CONVERGED state --- + + +class TestConvergedTransitions: + def test_converged_allows_rewound(self) -> None: + assert is_transition_allowed(LineageStatus.CONVERGED, "lineage.rewound") + + @pytest.mark.parametrize( + "event_type", + [ + "lineage.generation.started", + "lineage.generation.completed", + "lineage.converged", + "lineage.exhausted", + "lineage.stagnated", + ], + ) + def test_converged_rejects_non_rewind(self, event_type: str) -> None: + assert not is_transition_allowed(LineageStatus.CONVERGED, event_type) + + +# --- EXHAUSTED state --- + + +class TestExhaustedTransitions: + def test_exhausted_allows_rewound(self) -> None: + assert is_transition_allowed(LineageStatus.EXHAUSTED, "lineage.rewound") + + def test_exhausted_rejects_generation_started(self) -> None: + assert not is_transition_allowed( + LineageStatus.EXHAUSTED, "lineage.generation.started" + ) + + +# --- ABORTED state --- + + +class TestAbortedTransitions: + def test_aborted_rejects_everything(self) -> None: + """ABORTED is a terminal state with no allowed transitions.""" + for event_type in ALLOWED_TRANSITIONS[LineageStatus.ACTIVE]: + assert not is_transition_allowed(LineageStatus.ABORTED, event_type), ( + f"ABORTED should reject '{event_type}'" + ) + + def test_aborted_rejects_rewound(self) -> None: + assert not is_transition_allowed(LineageStatus.ABORTED, "lineage.rewound") + + +# --- Unknown status --- + + +class TestUnknownStatus: + def test_unknown_status_returns_false(self) -> None: + """A status not in ALLOWED_TRANSITIONS defaults to rejection.""" + # Simulate by checking the function logic directly + assert not is_transition_allowed(LineageStatus.ACTIVE, "totally.unknown.event") From 888902770ee3f507e26ef0980067f9fbf54fa830 Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Sun, 22 Mar 2026 15:20:47 +0900 Subject: [PATCH 03/15] feat(evolution): introduce TerminationReason enum and integrate Gate Guard Replace string pattern matching ("Stagnation" in signal.reason) with TerminationReason enum (CONVERGED, STAGNATED, OSCILLATED, EXHAUSTED, REPETITIVE). Extract _emit_termination() to eliminate run/evolve_step duplication. Add LineageGuard integration across loop.py. Projector includes legacy reason mapping for backward compatibility. Fixes: Repetitive feedback was incorrectly routed to lineage_converged instead of lineage_stagnated (existing bug discovered during review). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ouroboros/core/lineage.py | 21 ++- src/ouroboros/events/lineage.py | 40 ++++-- src/ouroboros/evolution/convergence.py | 21 ++- src/ouroboros/evolution/loop.py | 185 +++++++++++++------------ src/ouroboros/evolution/projector.py | 73 +++++++--- tests/unit/test_convergence.py | 121 +++++++++++++++- 6 files changed, 338 insertions(+), 123 deletions(-) diff --git a/src/ouroboros/core/lineage.py b/src/ouroboros/core/lineage.py index bf8a87a4..bac6c0cf 100644 --- a/src/ouroboros/core/lineage.py +++ b/src/ouroboros/core/lineage.py @@ -29,6 +29,21 @@ class LineageStatus(StrEnum): ABORTED = "aborted" +class TerminationReason(StrEnum): + """Why the evolutionary loop terminated. + + Categorizes the termination cause (not the final status). Multiple + reasons can map to the same LineageStatus — e.g., STAGNATED, OSCILLATED, + and REPETITIVE all result in LineageStatus.CONVERGED. + """ + + CONVERGED = "converged" # ontology stable: similarity >= threshold + STAGNATED = "stagnated" # ontology unchanged for N consecutive generations + OSCILLATED = "oscillated" # ontology cycling between similar states (A→B→A→B) + EXHAUSTED = "exhausted" # max_generations reached + REPETITIVE = "repetitive" # wonder questions repeating across generations + + class GenerationPhase(StrEnum): """Lifecycle phase of a single generation (for error recovery).""" @@ -214,7 +229,7 @@ class OntologyLineage(BaseModel, frozen=True): generations: tuple[GenerationRecord, ...] = Field(default_factory=tuple) rewind_history: tuple[RewindRecord, ...] = Field(default_factory=tuple) status: LineageStatus = LineageStatus.ACTIVE - termination_reason: str | None = None + termination_reason: TerminationReason | str | None = None created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) @property @@ -232,7 +247,9 @@ def with_generation(self, record: GenerationRecord) -> OntologyLineage: return self.model_copy(update={"generations": self.generations + (record,)}) def with_status( - self, status: LineageStatus, termination_reason: str | None = None + self, + status: LineageStatus, + termination_reason: TerminationReason | str | None = None, ) -> OntologyLineage: """Return new lineage with updated status and optional termination reason.""" updates: dict = {"status": status} diff --git a/src/ouroboros/events/lineage.py b/src/ouroboros/events/lineage.py index 5fd541b3..4dc22f5f 100644 --- a/src/ouroboros/events/lineage.py +++ b/src/ouroboros/events/lineage.py @@ -129,17 +129,21 @@ def lineage_converged( generation_number: int, reason: str, similarity: float, + termination_reason: str | None = None, ) -> BaseEvent: """Create event when convergence is detected.""" + data: dict = { + "generation_number": generation_number, + "reason": reason, + "similarity": similarity, + } + if termination_reason is not None: + data["termination_reason"] = str(termination_reason) return BaseEvent( type="lineage.converged", aggregate_type="lineage", aggregate_id=lineage_id, - data={ - "generation_number": generation_number, - "reason": reason, - "similarity": similarity, - }, + data=data, ) @@ -147,16 +151,20 @@ def lineage_exhausted( lineage_id: str, generation_number: int, max_generations: int, + termination_reason: str | None = None, ) -> BaseEvent: """Create event when max generations is reached.""" + data: dict = { + "generation_number": generation_number, + "max_generations": max_generations, + } + if termination_reason is not None: + data["termination_reason"] = str(termination_reason) return BaseEvent( type="lineage.exhausted", aggregate_type="lineage", aggregate_id=lineage_id, - data={ - "generation_number": generation_number, - "max_generations": max_generations, - }, + data=data, ) @@ -199,15 +207,19 @@ def lineage_stagnated( generation_number: int, reason: str, window: int, + termination_reason: str | None = None, ) -> BaseEvent: """Create event when stagnation is detected (repeated feedback/unchanged ontology).""" + data: dict = { + "generation_number": generation_number, + "reason": reason, + "stagnation_window": window, + } + if termination_reason is not None: + data["termination_reason"] = str(termination_reason) return BaseEvent( type="lineage.stagnated", aggregate_type="lineage", aggregate_id=lineage_id, - data={ - "generation_number": generation_number, - "reason": reason, - "stagnation_window": window, - }, + data=data, ) diff --git a/src/ouroboros/evolution/convergence.py b/src/ouroboros/evolution/convergence.py index 14f79e0a..e4272be8 100644 --- a/src/ouroboros/evolution/convergence.py +++ b/src/ouroboros/evolution/convergence.py @@ -18,6 +18,7 @@ GenerationRecord, OntologyDelta, OntologyLineage, + TerminationReason, ) from ouroboros.evolution.regression import RegressionDetector from ouroboros.evolution.wonder import WonderOutput @@ -25,13 +26,24 @@ @dataclass(frozen=True, slots=True) class ConvergenceSignal: - """Result of convergence evaluation.""" + """Result of convergence evaluation. + + When converged=True, termination_reason must be set to indicate why. + When converged=False, termination_reason is None (loop continues). + """ converged: bool reason: str ontology_similarity: float generation: int failed_acs: tuple[int, ...] = () + termination_reason: TerminationReason | None = None + + def __post_init__(self) -> None: + if self.converged and self.termination_reason is None: + raise ValueError( + "converged=True requires termination_reason to be set" + ) @dataclass @@ -87,6 +99,7 @@ def evaluate( reason=f"Max generations reached ({self.max_generations})", ontology_similarity=self._latest_similarity(lineage), generation=current_gen, + termination_reason=TerminationReason.EXHAUSTED, ) # Need at least min_generations completed before checking other signals @@ -197,6 +210,7 @@ def evaluate( ), ontology_similarity=latest_sim, generation=current_gen, + termination_reason=TerminationReason.CONVERGED, ) # Signal 2: Stagnation (unchanged for N consecutive gens) @@ -211,6 +225,7 @@ def evaluate( ), ontology_similarity=latest_sim, generation=current_gen, + termination_reason=TerminationReason.STAGNATED, ) # Signal 2.5: Oscillation detection (A→B→A→B cycling) @@ -219,9 +234,10 @@ def evaluate( if oscillating: return ConvergenceSignal( converged=True, - reason=("Oscillation detected: ontology is cycling between similar states"), + reason="Oscillation detected: ontology is cycling between similar states", ontology_similarity=latest_sim, generation=current_gen, + termination_reason=TerminationReason.OSCILLATED, ) # Signal 3: Repetitive wonder questions @@ -233,6 +249,7 @@ def evaluate( reason="Repetitive feedback: wonder questions are repeating across generations", ontology_similarity=latest_sim, generation=current_gen, + termination_reason=TerminationReason.REPETITIVE, ) # Not converged diff --git a/src/ouroboros/evolution/loop.py b/src/ouroboros/evolution/loop.py index d9d84904..22040c42 100644 --- a/src/ouroboros/evolution/loop.py +++ b/src/ouroboros/evolution/loop.py @@ -30,6 +30,7 @@ LineageStatus, OntologyDelta, OntologyLineage, + TerminationReason, ) from ouroboros.core.seed import Seed from ouroboros.core.types import Result @@ -46,6 +47,7 @@ lineage_wonder_degraded, ) from ouroboros.evolution.convergence import ConvergenceCriteria, ConvergenceSignal +from ouroboros.evolution.guard import LineageGuard from ouroboros.evolution.projector import LineageProjector from ouroboros.evolution.reflect import ReflectEngine, ReflectOutput from ouroboros.evolution.wonder import WonderEngine, WonderOutput @@ -150,6 +152,7 @@ def __init__( validator: Any | None = None, ) -> None: self.event_store = event_store + self._guard = LineageGuard(event_store) self.config = config or EvolutionaryLoopConfig() self.wonder_engine = wonder_engine self.reflect_engine = reflect_engine @@ -207,7 +210,7 @@ async def run( ) # Emit lineage created event - await self.event_store.append(lineage_created(lineage.lineage_id, lineage.goal)) + await self._guard.gated_append(lineage_created(lineage.lineage_id, lineage.goal)) generation_results: list[GenerationResult] = [] current_seed = initial_seed @@ -244,7 +247,7 @@ async def run( "timeout": self.config.generation_timeout_seconds, }, ) - await self.event_store.append( + await self._guard.gated_append( lineage_generation_failed( lineage.lineage_id, generation_number, @@ -282,7 +285,7 @@ async def run( lineage = lineage.with_generation(record) # Emit generation completed event (with seed_json for cross-session reconstruction) - await self.event_store.append( + await self._guard.gated_append( lineage_generation_completed( lineage.lineage_id, generation_number, @@ -299,7 +302,7 @@ async def run( # Emit ontology evolved event if delta exists if result.ontology_delta and result.ontology_delta.similarity < 1.0: - await self.event_store.append( + await self._guard.gated_append( lineage_ontology_evolved( lineage.lineage_id, generation_number, @@ -324,40 +327,13 @@ async def run( "generation": generation_number, "reason": signal.reason, "similarity": signal.ontology_similarity, + "termination_reason": str(signal.termination_reason), }, ) - # Emit appropriate termination event - if generation_number >= self.config.max_generations: - await self.event_store.append( - lineage_exhausted( - lineage.lineage_id, - generation_number, - self.config.max_generations, - ) - ) - lineage = lineage.with_status(LineageStatus.EXHAUSTED) - elif "Stagnation" in signal.reason or "Oscillation" in signal.reason: - await self.event_store.append( - lineage_stagnated( - lineage.lineage_id, - generation_number, - signal.reason, - self.config.stagnation_window, - ) - ) - lineage = lineage.with_status(LineageStatus.CONVERGED) - else: - await self.event_store.append( - lineage_converged( - lineage.lineage_id, - generation_number, - signal.reason, - signal.ontology_similarity, - ) - ) - lineage = lineage.with_status(LineageStatus.CONVERGED) - + lineage = await self._emit_termination( + signal, lineage, generation_number + ) break # Prepare for next generation @@ -419,7 +395,7 @@ async def evolve_step( lineage_id=lineage_id, goal=initial_seed.goal, ) - await self.event_store.append(lineage_created(lineage.lineage_id, lineage.goal)) + await self._guard.gated_append(lineage_created(lineage.lineage_id, lineage.goal)) generation_number = 1 current_seed = initial_seed @@ -524,7 +500,7 @@ async def evolve_step( if gen_result.is_err: # Emit generation.failed event so the event store reflects the failure. # Without this, only generation.started is recorded, leaving an orphan. - await self.event_store.append( + await self._guard.gated_append( lineage_generation_failed( lineage.lineage_id, generation_number, @@ -570,7 +546,7 @@ async def evolve_step( ) lineage = lineage.with_generation(record) - await self.event_store.append( + await self._guard.gated_append( lineage_generation_completed( lineage.lineage_id, generation_number, @@ -587,7 +563,7 @@ async def evolve_step( # Emit ontology evolved event if delta exists if result.ontology_delta and result.ontology_delta.similarity < 1.0: - await self.event_store.append( + await self._guard.gated_append( lineage_ontology_evolved( lineage.lineage_id, generation_number, @@ -605,38 +581,9 @@ async def evolve_step( action = StepAction.CONTINUE if signal.converged: - if generation_number >= self.config.max_generations: - await self.event_store.append( - lineage_exhausted( - lineage.lineage_id, - generation_number, - self.config.max_generations, - ) - ) - lineage = lineage.with_status(LineageStatus.EXHAUSTED) - action = StepAction.EXHAUSTED - elif "Stagnation" in signal.reason or "Oscillation" in signal.reason: - await self.event_store.append( - lineage_stagnated( - lineage.lineage_id, - generation_number, - signal.reason, - self.config.stagnation_window, - ) - ) - lineage = lineage.with_status(LineageStatus.CONVERGED) - action = StepAction.STAGNATED - else: - await self.event_store.append( - lineage_converged( - lineage.lineage_id, - generation_number, - signal.reason, - signal.ontology_similarity, - ) - ) - lineage = lineage.with_status(LineageStatus.CONVERGED) - action = StepAction.CONVERGED + lineage, action = await self._emit_termination_step( + signal, lineage, generation_number + ) return Result.ok( StepResult( @@ -648,6 +595,74 @@ async def evolve_step( ) ) + async def _emit_termination( + self, + signal: ConvergenceSignal, + lineage: OntologyLineage, + generation_number: int, + ) -> OntologyLineage: + """Emit termination event and update lineage status. Used by run().""" + tr = signal.termination_reason + + if tr == TerminationReason.EXHAUSTED: + await self._guard.gated_append( + lineage_exhausted( + lineage.lineage_id, + generation_number, + self.config.max_generations, + termination_reason=str(tr), + ) + ) + return lineage.with_status(LineageStatus.EXHAUSTED, termination_reason=tr) + + if tr in ( + TerminationReason.STAGNATED, + TerminationReason.OSCILLATED, + TerminationReason.REPETITIVE, + ): + await self._guard.gated_append( + lineage_stagnated( + lineage.lineage_id, + generation_number, + signal.reason, + self.config.stagnation_window, + termination_reason=str(tr), + ) + ) + return lineage.with_status(LineageStatus.CONVERGED, termination_reason=tr) + + # Default: CONVERGED (ontology stable) + await self._guard.gated_append( + lineage_converged( + lineage.lineage_id, + generation_number, + signal.reason, + signal.ontology_similarity, + termination_reason=str(tr), + ) + ) + return lineage.with_status(LineageStatus.CONVERGED, termination_reason=tr) + + async def _emit_termination_step( + self, + signal: ConvergenceSignal, + lineage: OntologyLineage, + generation_number: int, + ) -> tuple[OntologyLineage, StepAction]: + """Emit termination event and return (lineage, action). Used by evolve_step().""" + tr = signal.termination_reason + lineage = await self._emit_termination(signal, lineage, generation_number) + + if tr == TerminationReason.EXHAUSTED: + return lineage, StepAction.EXHAUSTED + if tr in ( + TerminationReason.STAGNATED, + TerminationReason.OSCILLATED, + TerminationReason.REPETITIVE, + ): + return lineage, StepAction.STAGNATED + return lineage, StepAction.CONVERGED + async def _run_generation( self, lineage: OntologyLineage, @@ -680,7 +695,7 @@ async def _run_generation( }, ) try: - await self.event_store.append( + await self._guard.gated_append( lineage_generation_failed( lineage.lineage_id, generation_number, @@ -714,7 +729,7 @@ async def _run_generation_phases( prev_gen = lineage.generations[-1] # Emit generation started - await self.event_store.append( + await self._guard.gated_append( lineage_generation_started( lineage.lineage_id, generation_number, @@ -758,7 +773,7 @@ async def _run_generation_phases( ) else: # Wonder degraded - emit event but continue - await self.event_store.append( + await self._guard.gated_append( lineage_wonder_degraded( lineage.lineage_id, generation_number, @@ -767,7 +782,7 @@ async def _run_generation_phases( ) # Phase transition: wondering → reflecting - await self.event_store.append( + await self._guard.gated_append( lineage_generation_phase_changed( lineage.lineage_id, generation_number, @@ -800,7 +815,7 @@ async def _run_generation_phases( }, ) else: - await self.event_store.append( + await self._guard.gated_append( lineage_generation_failed( lineage.lineage_id, generation_number, @@ -827,7 +842,7 @@ async def _run_generation_phases( ) # Phase transition: reflecting → seeding - await self.event_store.append( + await self._guard.gated_append( lineage_generation_phase_changed( lineage.lineage_id, generation_number, @@ -842,7 +857,7 @@ async def _run_generation_phases( reflect_output, ) if seed_result.is_err: - await self.event_store.append( + await self._guard.gated_append( lineage_generation_failed( lineage.lineage_id, generation_number, @@ -865,7 +880,7 @@ async def _run_generation_phases( else: # Gen 1: just emit started event - await self.event_store.append( + await self._guard.gated_append( lineage_generation_started( lineage.lineage_id, generation_number, @@ -875,7 +890,7 @@ async def _run_generation_phases( ) # Phase transition: → executing - await self.event_store.append( + await self._guard.gated_append( lineage_generation_phase_changed( lineage.lineage_id, generation_number, @@ -902,7 +917,7 @@ async def _run_generation_phases( }, ) elif hasattr(exec_result, "is_ok"): - await self.event_store.append( + await self._guard.gated_append( lineage_generation_failed( lineage.lineage_id, generation_number, @@ -914,7 +929,7 @@ async def _run_generation_phases( else: execution_output = str(exec_result) except Exception as e: - await self.event_store.append( + await self._guard.gated_append( lineage_generation_failed( lineage.lineage_id, generation_number, @@ -963,7 +978,7 @@ async def _run_generation_phases( validation_passed = False # Phase transition: → evaluating - await self.event_store.append( + await self._guard.gated_append( lineage_generation_phase_changed( lineage.lineage_id, generation_number, @@ -1002,7 +1017,7 @@ async def _run_generation_phases( iteration=generation_number, metrics=drift_metrics, ) - await self.event_store.append(drift_event) + await self._guard.gated_append(drift_event) except Exception as e: logger.warning( "evolution.drift.measurement_failed", @@ -1047,7 +1062,7 @@ async def rewind_to( from ouroboros.events.lineage import lineage_rewound - await self.event_store.append( + await self._guard.gated_append( lineage_rewound( lineage.lineage_id, from_gen, diff --git a/src/ouroboros/evolution/projector.py b/src/ouroboros/evolution/projector.py index ac7b3cdc..4439cd77 100644 --- a/src/ouroboros/evolution/projector.py +++ b/src/ouroboros/evolution/projector.py @@ -13,9 +13,27 @@ LineageStatus, OntologyLineage, RewindRecord, + TerminationReason, ) from ouroboros.core.seed import OntologySchema from ouroboros.events.base import BaseEvent +from ouroboros.evolution.transitions import TERMINAL_EVENT_STATUS + +# Legacy reason strings → TerminationReason mapping for events +# stored before TerminationReason was introduced. +_LEGACY_REASON_MAP: dict[str, TerminationReason] = { + "ontology_converged": TerminationReason.CONVERGED, + "max_generations": TerminationReason.EXHAUSTED, + "stagnation": TerminationReason.STAGNATED, +} + +# Default TerminationReason by event type (fallback when neither +# termination_reason nor reason is in event data). +_DEFAULT_TERMINATION: dict[str, TerminationReason] = { + "lineage.converged": TerminationReason.CONVERGED, + "lineage.exhausted": TerminationReason.EXHAUSTED, + "lineage.stagnated": TerminationReason.STAGNATED, +} # Sentinel for generations that haven't completed (started/failed). # These don't have a real ontology yet, but GenerationRecord requires one. @@ -31,6 +49,24 @@ class LineageProjector: lineage = projector.project(events) """ + @staticmethod + def resolve_status(events: list[BaseEvent]) -> LineageStatus: + """Determine current LineageStatus from event stream. + + Scans events in reverse for early return. Uses TERMINAL_EVENT_STATUS + as the single source of truth (shared with guard.py). + + Args: + events: Ordered list of lineage events from EventStore.replay(). + + Returns: + Current LineageStatus (defaults to ACTIVE if no terminal events found). + """ + for event in reversed(events): + if event.type in TERMINAL_EVENT_STATUS: + return TERMINAL_EVENT_STATUS[event.type] + return LineageStatus.ACTIVE + def project(self, events: list[BaseEvent]) -> OntologyLineage | None: """Fold events into OntologyLineage state. @@ -133,26 +169,25 @@ def project(self, events: list[BaseEvent]) -> OntologyLineage | None: failure_error=error_msg, ) - elif event.type == "lineage.converged": + elif event.type in ("lineage.converged", "lineage.exhausted", "lineage.stagnated"): if lineage is not None: - reason = event.data.get("reason", "ontology_converged") - lineage = lineage.with_status( - LineageStatus.CONVERGED, termination_reason=reason - ) - - elif event.type == "lineage.exhausted": - if lineage is not None: - reason = event.data.get("reason", "max_generations") - lineage = lineage.with_status( - LineageStatus.EXHAUSTED, termination_reason=reason - ) - - elif event.type == "lineage.stagnated": - if lineage is not None: - reason = event.data.get("reason", "stagnation") - lineage = lineage.with_status( - LineageStatus.CONVERGED, termination_reason=reason - ) + target_status = TERMINAL_EVENT_STATUS[event.type] + # Resolve termination_reason with fallback chain: + # 1. New field: event.data["termination_reason"] (enum value string) + # 2. Legacy field: event.data["reason"] → _LEGACY_REASON_MAP + # 3. Default by event type + raw_tr = event.data.get("termination_reason") + if raw_tr: + try: + tr: TerminationReason | str = TerminationReason(raw_tr) + except ValueError: + tr = _DEFAULT_TERMINATION[event.type] + else: + raw_reason = event.data.get("reason", "") + tr = _LEGACY_REASON_MAP.get( + raw_reason, _DEFAULT_TERMINATION[event.type] + ) + lineage = lineage.with_status(target_status, termination_reason=tr) elif event.type == "lineage.rewound": data = event.data diff --git a/tests/unit/test_convergence.py b/tests/unit/test_convergence.py index a9276f8b..17d15ba2 100644 --- a/tests/unit/test_convergence.py +++ b/tests/unit/test_convergence.py @@ -10,9 +10,10 @@ GenerationRecord, OntologyDelta, OntologyLineage, + TerminationReason, ) from ouroboros.core.seed import OntologyField, OntologySchema -from ouroboros.evolution.convergence import ConvergenceCriteria +from ouroboros.evolution.convergence import ConvergenceCriteria, ConvergenceSignal from ouroboros.evolution.wonder import WonderOutput # -- Helpers -- @@ -634,3 +635,121 @@ def test_disabled_allows_skipped_validation(self) -> None: validation_output="Validation skipped: no project directory", ) assert signal.converged + + +# --- TerminationReason enum tests --- + + +class TestTerminationReasonEnum: + """Verify TerminationReason is correctly set for all converged=True paths.""" + + def test_converged_true_requires_termination_reason(self) -> None: + """__post_init__ enforces that converged=True has termination_reason.""" + with pytest.raises(ValueError, match="requires termination_reason"): + ConvergenceSignal( + converged=True, + reason="test", + ontology_similarity=0.99, + generation=1, + ) + + def test_converged_false_allows_none(self) -> None: + """converged=False can have termination_reason=None.""" + signal = ConvergenceSignal( + converged=False, + reason="test", + ontology_similarity=0.5, + generation=1, + ) + assert signal.termination_reason is None + + def test_max_generations_returns_exhausted(self) -> None: + schema_a = _schema(("name",)) + lineage = OntologyLineage( + lineage_id="test", + goal="test", + generations=tuple( + GenerationRecord( + generation_number=i, + seed_id=f"s{i}", + ontology_snapshot=schema_a, + ) + for i in range(1, 4) + ), + ) + criteria = ConvergenceCriteria(max_generations=3, min_generations=2) + signal = criteria.evaluate(lineage) + assert signal.converged + assert signal.termination_reason == TerminationReason.EXHAUSTED + + def test_ontology_stable_returns_converged(self) -> None: + # Gen 1: different schema, Gen 2-3: same schema → evolution happened, then stable + schema_a = _schema(("name",)) + schema_b = _schema(("name", "age")) + lineage = OntologyLineage( + lineage_id="test", + goal="test", + generations=( + GenerationRecord(generation_number=1, seed_id="s1", ontology_snapshot=schema_a), + GenerationRecord(generation_number=2, seed_id="s2", ontology_snapshot=schema_b), + GenerationRecord(generation_number=3, seed_id="s3", ontology_snapshot=schema_b), + ), + ) + criteria = ConvergenceCriteria(convergence_threshold=0.95, min_generations=2) + signal = criteria.evaluate(lineage) + assert signal.converged + assert signal.termination_reason == TerminationReason.CONVERGED + + def test_stagnation_returns_stagnated(self) -> None: + # Gen 1: different, Gen 2-5: same → evolution gate passes, stagnation detected + schema_a = _schema(("x",)) + schema_b = _schema(("name", "age")) + lineage = OntologyLineage( + lineage_id="test", + goal="test", + generations=( + GenerationRecord(generation_number=1, seed_id="s1", ontology_snapshot=schema_a), + *( + GenerationRecord( + generation_number=i, + seed_id=f"s{i}", + ontology_snapshot=schema_b, + ) + for i in range(2, 6) + ), + ), + ) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + stagnation_window=3, + min_generations=2, + ) + signal = criteria.evaluate(lineage) + assert signal.converged + assert signal.termination_reason in ( + TerminationReason.CONVERGED, + TerminationReason.STAGNATED, + ) + + def test_oscillation_returns_oscillated(self) -> None: + schema_a = _schema(("name",)) + schema_b = _schema(("title",)) + lineage = OntologyLineage( + lineage_id="test", + goal="test", + generations=( + GenerationRecord(generation_number=1, seed_id="s1", ontology_snapshot=schema_a), + GenerationRecord(generation_number=2, seed_id="s2", ontology_snapshot=schema_b), + GenerationRecord(generation_number=3, seed_id="s3", ontology_snapshot=schema_a), + GenerationRecord(generation_number=4, seed_id="s4", ontology_snapshot=schema_b), + ), + ) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + enable_oscillation_detection=True, + ) + signal = criteria.evaluate(lineage) + assert signal.converged + assert signal.termination_reason == TerminationReason.OSCILLATED + assert "Oscillation" in signal.reason From b3cf09d8c61df9fa829109ba1c320c0fc0c0bfc4 Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Sun, 22 Mar 2026 15:38:53 +0900 Subject: [PATCH 04/15] fix(safety): add StrEnum exhaustiveness guards and cross-validation tests - Make _emit_termination() explicitly handle CONVERGED branch with defensive else + warning for unknown TerminationReason members - Add warning log for unhandled MutationAction in seed_generator - Add TestTerminationReasonExhaustiveness: verifies all enum members belong to a dispatch group with no overlap - Add TestMutationActionExhaustiveness: verifies all actions handled - Add TestTerminationReasonProjectorCoverage: verifies legacy/default mappings cover all terminal event types Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ouroboros/bigbang/seed_generator.py | 6 +++ src/ouroboros/evolution/loop.py | 23 ++++++++- tests/unit/evolution/test_transitions.py | 45 +++++++++++++++++- tests/unit/test_enum_safety.py | 59 ++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_enum_safety.py diff --git a/src/ouroboros/bigbang/seed_generator.py b/src/ouroboros/bigbang/seed_generator.py index 5a5af330..e4ca307e 100644 --- a/src/ouroboros/bigbang/seed_generator.py +++ b/src/ouroboros/bigbang/seed_generator.py @@ -280,6 +280,12 @@ def _apply_mutations( ) elif action == "remove" and mutation.field_name in fields_by_name: del fields_by_name[mutation.field_name] + else: + log.warning( + "seed_generator.unhandled_mutation", + action=action, + field_name=mutation.field_name, + ) return OntologySchema( name=schema.name, diff --git a/src/ouroboros/evolution/loop.py b/src/ouroboros/evolution/loop.py index 22040c42..24288885 100644 --- a/src/ouroboros/evolution/loop.py +++ b/src/ouroboros/evolution/loop.py @@ -631,7 +631,27 @@ async def _emit_termination( ) return lineage.with_status(LineageStatus.CONVERGED, termination_reason=tr) - # Default: CONVERGED (ontology stable) + if tr == TerminationReason.CONVERGED: + await self._guard.gated_append( + lineage_converged( + lineage.lineage_id, + generation_number, + signal.reason, + signal.ontology_similarity, + termination_reason=str(tr), + ) + ) + return lineage.with_status(LineageStatus.CONVERGED, termination_reason=tr) + + # Defensive: unknown TerminationReason falls back to CONVERGED with warning + logger.warning( + "evolution.termination.unknown_reason", + extra={ + "termination_reason": str(tr), + "lineage_id": lineage.lineage_id, + "generation": generation_number, + }, + ) await self._guard.gated_append( lineage_converged( lineage.lineage_id, @@ -661,6 +681,7 @@ async def _emit_termination_step( TerminationReason.REPETITIVE, ): return lineage, StepAction.STAGNATED + # CONVERGED or unknown return lineage, StepAction.CONVERGED async def _run_generation( diff --git a/tests/unit/evolution/test_transitions.py b/tests/unit/evolution/test_transitions.py index 27d5be6c..84a5aec1 100644 --- a/tests/unit/evolution/test_transitions.py +++ b/tests/unit/evolution/test_transitions.py @@ -14,7 +14,7 @@ import pytest -from ouroboros.core.lineage import LineageStatus +from ouroboros.core.lineage import LineageStatus, TerminationReason from ouroboros.evolution.transitions import ( ALLOWED_TRANSITIONS, TERMINAL_EVENT_STATUS, @@ -166,5 +166,46 @@ def test_aborted_rejects_rewound(self) -> None: class TestUnknownStatus: def test_unknown_status_returns_false(self) -> None: """A status not in ALLOWED_TRANSITIONS defaults to rejection.""" - # Simulate by checking the function logic directly assert not is_transition_allowed(LineageStatus.ACTIVE, "totally.unknown.event") + + +# --- TerminationReason exhaustiveness --- + + +class TestTerminationReasonExhaustiveness: + """Every TerminationReason member must be explicitly handled in _emit_termination.""" + + # The known groupings in loop.py _emit_termination(): + _EXHAUSTED_GROUP = {TerminationReason.EXHAUSTED} + _STAGNATED_GROUP = { + TerminationReason.STAGNATED, + TerminationReason.OSCILLATED, + TerminationReason.REPETITIVE, + } + _CONVERGED_GROUP = {TerminationReason.CONVERGED} + + def test_all_members_are_in_a_group(self) -> None: + """Every TerminationReason member must belong to exactly one dispatch group.""" + all_grouped = self._EXHAUSTED_GROUP | self._STAGNATED_GROUP | self._CONVERGED_GROUP + for member in TerminationReason: + assert member in all_grouped, ( + f"TerminationReason.{member.name} is not in any dispatch group in " + f"_emit_termination(). Add it to the appropriate group." + ) + + def test_no_group_overlap(self) -> None: + """Dispatch groups must not overlap.""" + groups = [self._EXHAUSTED_GROUP, self._STAGNATED_GROUP, self._CONVERGED_GROUP] + for i, a in enumerate(groups): + for b in groups[i + 1 :]: + overlap = a & b + assert not overlap, f"Dispatch groups overlap: {overlap}" + + def test_groups_cover_all_members(self) -> None: + """Union of all groups equals the full enum.""" + all_grouped = self._EXHAUSTED_GROUP | self._STAGNATED_GROUP | self._CONVERGED_GROUP + all_members = set(TerminationReason) + assert all_grouped == all_members, ( + f"Groups missing: {all_members - all_grouped}, " + f"Extra in groups: {all_grouped - all_members}" + ) diff --git a/tests/unit/test_enum_safety.py b/tests/unit/test_enum_safety.py new file mode 100644 index 00000000..f3b84110 --- /dev/null +++ b/tests/unit/test_enum_safety.py @@ -0,0 +1,59 @@ +"""StrEnum exhaustiveness safety tests. + +Ensures that when new members are added to StrEnum types, all dispatch +sites that handle them are updated. These tests fail-fast when a new +enum member is added but not handled. +""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import pytest + +from ouroboros.core.lineage import MutationAction, TerminationReason + +_SRC = Path(__file__).resolve().parents[2] / "src" / "ouroboros" + + +class TestMutationActionExhaustiveness: + """Every MutationAction member must be handled in seed_generator._apply_mutations.""" + + _SEED_GENERATOR = _SRC / "bigbang" / "seed_generator.py" + + def test_all_actions_in_apply_mutations(self) -> None: + """Every MutationAction value appears in _apply_mutations if/elif chain.""" + source = self._SEED_GENERATOR.read_text() + # Extract string comparisons: action == "add", action == "modify", etc. + handled = set(re.findall(r'action\s*==\s*"(\w+)"', source)) + for member in MutationAction: + assert member.value in handled, ( + f"MutationAction.{member.name} ('{member.value}') is not handled " + f"in seed_generator.py _apply_mutations(). " + f"Handled actions: {sorted(handled)}" + ) + + +class TestTerminationReasonProjectorCoverage: + """Projector legacy/default mappings must cover all terminal event types.""" + + def test_legacy_map_covers_known_defaults(self) -> None: + """_LEGACY_REASON_MAP has entries for all historical default reason strings.""" + from ouroboros.evolution.projector import _LEGACY_REASON_MAP + + # These are the known legacy default strings from before enum introduction + expected_keys = {"ontology_converged", "max_generations", "stagnation"} + assert set(_LEGACY_REASON_MAP.keys()) == expected_keys + + def test_default_termination_covers_terminal_events(self) -> None: + """_DEFAULT_TERMINATION has entries for all terminal event types.""" + from ouroboros.evolution.projector import _DEFAULT_TERMINATION + from ouroboros.evolution.transitions import TERMINAL_EVENT_STATUS + + # Every terminal event type (except rewound, which doesn't set termination_reason) + terminal_events_with_reason = { + et for et in TERMINAL_EVENT_STATUS if et != "lineage.rewound" + } + assert set(_DEFAULT_TERMINATION.keys()) == terminal_events_with_reason From c32e8a65e15de4cd94a6680140ddfb2d787caca8 Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Sun, 22 Mar 2026 16:22:05 +0900 Subject: [PATCH 05/15] feat(convergence): add ontology completeness gate and stagnation safety net Add ontology_completeness_gate that blocks convergence when ontology has too few fields or trivially shallow descriptions. Gate is disabled by default (opt-in via ontology_completeness_gate_enabled). Add stagnation safety check inside the similarity >= threshold block to prevent infinite loops when gates repeatedly block convergence but ontology remains unchanged. Also fix: forward 4 missing ConvergenceCriteria settings from EvolutionaryLoopConfig (ac_gate_mode, ac_min_pass_ratio, regression_gate_enabled, validation_gate_enabled). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ouroboros/evolution/convergence.py | 64 ++++++++++++ src/ouroboros/evolution/loop.py | 12 +++ tests/unit/test_convergence.py | 132 ++++++++++++++++++++++++- 3 files changed, 207 insertions(+), 1 deletion(-) diff --git a/src/ouroboros/evolution/convergence.py b/src/ouroboros/evolution/convergence.py index e4272be8..7441645a 100644 --- a/src/ouroboros/evolution/convergence.py +++ b/src/ouroboros/evolution/convergence.py @@ -70,6 +70,8 @@ class ConvergenceCriteria: ac_min_pass_ratio: float = 1.0 # for "ratio" mode regression_gate_enabled: bool = True validation_gate_enabled: bool = True + ontology_completeness_gate_enabled: bool = False + ontology_min_fields: int = 3 def evaluate( self, @@ -114,6 +116,23 @@ def evaluate( # Signal 1: Ontology stability (latest two generations) latest_sim = self._latest_similarity(lineage) if latest_sim >= self.convergence_threshold: + # Safety: if ontology has been stable for stagnation_window consecutive + # generations but gates keep blocking, force stagnation termination. + # Without this, gates (e.g., completeness) can block indefinitely + # since the stagnation check below only runs when similarity < threshold. + if self._check_stagnation(lineage): + return ConvergenceSignal( + converged=True, + reason=( + f"Stagnation detected: ontology unchanged for " + f"{self.stagnation_window} consecutive generations " + f"(convergence gates could not be satisfied)" + ), + ontology_similarity=latest_sim, + generation=current_gen, + termination_reason=TerminationReason.STAGNATED, + ) + # Eval gate: block convergence if evaluation is unsatisfactory if self.eval_gate_enabled and latest_evaluation is not None: eval_blocks = not latest_evaluation.final_approved or ( @@ -184,6 +203,17 @@ def evaluate( generation=current_gen, ) + # Ontology completeness gate: block convergence if ontology is structurally thin + if self.ontology_completeness_gate_enabled: + completeness_block = self._check_ontology_completeness(lineage) + if completeness_block is not None: + return ConvergenceSignal( + converged=False, + reason=completeness_block, + ontology_similarity=latest_sim, + generation=current_gen, + ) + # Validation gate: block convergence if validation was skipped or failed if self.validation_gate_enabled and validation_output: # Use explicit bool flag when available; fall back to string matching @@ -329,6 +359,40 @@ def _check_ac_gate( return None + def _check_ontology_completeness(self, lineage: OntologyLineage) -> str | None: + """Check if ontology meets minimum structural completeness. + + Returns blocking reason if incomplete, None if OK. + Checks: (1) minimum field count, (2) description quality. + """ + if not lineage.generations: + return None + + ontology = lineage.generations[-1].ontology_snapshot + + # Check 1: Minimum field count + if self.ontology_min_fields > 0 and len(ontology.fields) < self.ontology_min_fields: + return ( + f"Ontology completeness gate: {len(ontology.fields)} fields " + f"(minimum {self.ontology_min_fields} required)" + ) + + # Check 2: Trivially short or name-echoing descriptions + if ontology.fields: + trivial_count = sum( + 1 + for f in ontology.fields + if len(f.description.strip()) < 10 + or f.description.strip().lower() == f.name.strip().lower() + ) + if trivial_count > len(ontology.fields) // 2: + return ( + f"Ontology completeness gate: {trivial_count}/{len(ontology.fields)} " + f"fields have trivial descriptions" + ) + + return None + def _check_stagnation(self, lineage: OntologyLineage) -> bool: """Check if ontology has been unchanged for stagnation_window gens.""" gens = self._completed_generations(lineage) diff --git a/src/ouroboros/evolution/loop.py b/src/ouroboros/evolution/loop.py index 24288885..58ccca98 100644 --- a/src/ouroboros/evolution/loop.py +++ b/src/ouroboros/evolution/loop.py @@ -71,6 +71,12 @@ class EvolutionaryLoopConfig: enable_oscillation_detection: bool = True eval_gate_enabled: bool = True eval_min_score: float = 0.7 + ac_gate_mode: str = "all" + ac_min_pass_ratio: float = 1.0 + regression_gate_enabled: bool = True + validation_gate_enabled: bool = True + ontology_completeness_gate_enabled: bool = False + ontology_min_fields: int = 3 @dataclass(frozen=True, slots=True) @@ -172,6 +178,12 @@ def __init__( enable_oscillation_detection=self.config.enable_oscillation_detection, eval_gate_enabled=self.config.eval_gate_enabled, eval_min_score=self.config.eval_min_score, + ac_gate_mode=self.config.ac_gate_mode, + ac_min_pass_ratio=self.config.ac_min_pass_ratio, + regression_gate_enabled=self.config.regression_gate_enabled, + validation_gate_enabled=self.config.validation_gate_enabled, + ontology_completeness_gate_enabled=self.config.ontology_completeness_gate_enabled, + ontology_min_fields=self.config.ontology_min_fields, ) def set_project_dir(self, project_dir: str | None) -> Token[str | None]: diff --git a/tests/unit/test_convergence.py b/tests/unit/test_convergence.py index 17d15ba2..8ca83ade 100644 --- a/tests/unit/test_convergence.py +++ b/tests/unit/test_convergence.py @@ -511,11 +511,15 @@ class TestEvolutionGateDetection: """ def test_blocks_when_ontology_never_evolved(self) -> None: - """Identical ontology across all generations -> convergence withheld.""" + """Identical ontology across 2 generations -> convergence withheld. + + Uses stagnation_window=4 to avoid stagnation safety firing first. + """ lineage = _lineage_with_schemas(SCHEMA_A, SCHEMA_A, SCHEMA_A) criteria = ConvergenceCriteria( convergence_threshold=0.95, min_generations=2, + stagnation_window=4, eval_gate_enabled=False, ) signal = criteria.evaluate(lineage) @@ -753,3 +757,129 @@ def test_oscillation_returns_oscillated(self) -> None: assert signal.converged assert signal.termination_reason == TerminationReason.OSCILLATED assert "Oscillation" in signal.reason + + +# --- Ontology Completeness Gate --- + + +class TestOntologyCompletenessGate: + """Tests for ontology_completeness_gate in convergence criteria.""" + + @staticmethod + def _stable_lineage( + fields: tuple[str, ...], + descriptions: tuple[str, ...] | None = None, + ) -> OntologyLineage: + """Create a lineage where the last 2 gens have identical ontology.""" + if descriptions is None: + descriptions = tuple(f"Description of {f}" for f in fields) + schema_init = _schema(("initial_different_field",)) + schema = OntologySchema( + name="Test", + description="Test schema", + fields=tuple( + OntologyField(name=n, field_type="string", description=d, required=True) + for n, d in zip(fields, descriptions) + ), + ) + return OntologyLineage( + lineage_id="test", + goal="test", + generations=( + GenerationRecord(generation_number=1, seed_id="s1", ontology_snapshot=schema_init), + GenerationRecord(generation_number=2, seed_id="s2", ontology_snapshot=schema), + GenerationRecord(generation_number=3, seed_id="s3", ontology_snapshot=schema), + ), + ) + + def test_blocks_when_too_few_fields(self) -> None: + """Completeness gate blocks when field count < min_fields.""" + lineage = self._stable_lineage(("name", "age")) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + ontology_completeness_gate_enabled=True, + ontology_min_fields=3, + ) + signal = criteria.evaluate(lineage) + assert not signal.converged + assert "completeness gate" in signal.reason.lower() + assert "2 fields" in signal.reason + + def test_blocks_when_trivial_descriptions(self) -> None: + """Completeness gate blocks when majority of descriptions are trivial.""" + lineage = self._stable_lineage( + ("name", "age", "email"), + descriptions=("name", "age", "Detailed email address for contact"), + ) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + ontology_completeness_gate_enabled=True, + ontology_min_fields=3, + ) + signal = criteria.evaluate(lineage) + assert not signal.converged + assert "trivial descriptions" in signal.reason.lower() + + def test_passes_when_sufficient_fields_and_descriptions(self) -> None: + """Completeness gate passes with enough fields and good descriptions.""" + lineage = self._stable_lineage( + ("name", "age", "email"), + descriptions=( + "Full legal name of the person", + "Age in years since birth", + "Primary email address for contact", + ), + ) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + ontology_completeness_gate_enabled=True, + ontology_min_fields=3, + ) + signal = criteria.evaluate(lineage) + assert signal.converged + assert signal.termination_reason == TerminationReason.CONVERGED + + def test_disabled_allows_convergence(self) -> None: + """Disabled completeness gate allows convergence regardless.""" + lineage = self._stable_lineage(("x",)) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + ontology_completeness_gate_enabled=False, + ) + signal = criteria.evaluate(lineage) + assert signal.converged + + def test_stagnation_safety_overrides_gate_blocking(self) -> None: + """When stagnation_window is reached, stagnation terminates even if gate blocks.""" + schema_init = _schema(("different",)) + schema = OntologySchema( + name="T", + description="T", + fields=(OntologyField(name="x", field_type="string", description="x", required=True),), + ) + # 4 gens: gen1 different, gen2-4 identical → stagnation_window=3 reached + lineage = OntologyLineage( + lineage_id="test", + goal="test", + generations=( + GenerationRecord(generation_number=1, seed_id="s1", ontology_snapshot=schema_init), + GenerationRecord(generation_number=2, seed_id="s2", ontology_snapshot=schema), + GenerationRecord(generation_number=3, seed_id="s3", ontology_snapshot=schema), + GenerationRecord(generation_number=4, seed_id="s4", ontology_snapshot=schema), + ), + ) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + stagnation_window=3, + ontology_completeness_gate_enabled=True, + ontology_min_fields=5, # would block: only 1 field + ) + signal = criteria.evaluate(lineage) + # Stagnation safety should override the completeness gate + assert signal.converged + assert signal.termination_reason == TerminationReason.STAGNATED From 9218ee04e3c185d7f3364ae899b3949265c55aeb Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Sun, 22 Mar 2026 16:30:07 +0900 Subject: [PATCH 06/15] feat(convergence): add wonder gate to block convergence on novel questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wonder gate blocks convergence when Wonder discovers significant novel questions (novelty ratio >= threshold), indicating unexplored ontological space remains. Gate is disabled by default (opt-in via wonder_gate_enabled). Completes 6-2: Wonder output now used as both negative signal (repetitive feedback → REPETITIVE termination) and positive signal (novel questions → convergence holdback). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ouroboros/evolution/convergence.py | 43 ++++++++++++ src/ouroboros/evolution/loop.py | 4 ++ tests/unit/test_convergence.py | 94 ++++++++++++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/src/ouroboros/evolution/convergence.py b/src/ouroboros/evolution/convergence.py index 7441645a..85522c42 100644 --- a/src/ouroboros/evolution/convergence.py +++ b/src/ouroboros/evolution/convergence.py @@ -72,6 +72,8 @@ class ConvergenceCriteria: validation_gate_enabled: bool = True ontology_completeness_gate_enabled: bool = False ontology_min_fields: int = 3 + wonder_gate_enabled: bool = False + wonder_novelty_threshold: float = 0.5 def evaluate( self, @@ -214,6 +216,17 @@ def evaluate( generation=current_gen, ) + # Wonder gate: block convergence if Wonder found significant novel questions + if self.wonder_gate_enabled and latest_wonder is not None: + wonder_block = self._check_wonder_gate(lineage, latest_wonder) + if wonder_block is not None: + return ConvergenceSignal( + converged=False, + reason=wonder_block, + ontology_similarity=latest_sim, + generation=current_gen, + ) + # Validation gate: block convergence if validation was skipped or failed if self.validation_gate_enabled and validation_output: # Use explicit bool flag when available; fall back to string matching @@ -359,6 +372,36 @@ def _check_ac_gate( return None + def _check_wonder_gate( + self, lineage: OntologyLineage, latest_wonder: WonderOutput + ) -> str | None: + """Block convergence if Wonder found significant novel questions. + + Compares latest wonder questions against all previous generations. + If novelty ratio >= threshold, ontology may still benefit from evolution. + + Returns blocking reason if novel questions exceed threshold, None if OK. + """ + if not latest_wonder.questions: + return None + + prev_questions: set[str] = set() + for gen in lineage.generations: + prev_questions.update(gen.wonder_questions) + + novel = [q for q in latest_wonder.questions if q not in prev_questions] + if not latest_wonder.questions: + return None + novelty_ratio = len(novel) / len(latest_wonder.questions) + + if novelty_ratio >= self.wonder_novelty_threshold: + return ( + f"Wonder gate: {len(novel)}/{len(latest_wonder.questions)} novel questions " + f"(novelty {novelty_ratio:.0%} >= {self.wonder_novelty_threshold:.0%} threshold)" + ) + + return None + def _check_ontology_completeness(self, lineage: OntologyLineage) -> str | None: """Check if ontology meets minimum structural completeness. diff --git a/src/ouroboros/evolution/loop.py b/src/ouroboros/evolution/loop.py index 58ccca98..85e14038 100644 --- a/src/ouroboros/evolution/loop.py +++ b/src/ouroboros/evolution/loop.py @@ -77,6 +77,8 @@ class EvolutionaryLoopConfig: validation_gate_enabled: bool = True ontology_completeness_gate_enabled: bool = False ontology_min_fields: int = 3 + wonder_gate_enabled: bool = False + wonder_novelty_threshold: float = 0.5 @dataclass(frozen=True, slots=True) @@ -184,6 +186,8 @@ def __init__( validation_gate_enabled=self.config.validation_gate_enabled, ontology_completeness_gate_enabled=self.config.ontology_completeness_gate_enabled, ontology_min_fields=self.config.ontology_min_fields, + wonder_gate_enabled=self.config.wonder_gate_enabled, + wonder_novelty_threshold=self.config.wonder_novelty_threshold, ) def set_project_dir(self, project_dir: str | None) -> Token[str | None]: diff --git a/tests/unit/test_convergence.py b/tests/unit/test_convergence.py index 8ca83ade..4e8989c0 100644 --- a/tests/unit/test_convergence.py +++ b/tests/unit/test_convergence.py @@ -883,3 +883,97 @@ def test_stagnation_safety_overrides_gate_blocking(self) -> None: # Stagnation safety should override the completeness gate assert signal.converged assert signal.termination_reason == TerminationReason.STAGNATED + + +# --- Wonder Gate --- + + +class TestWonderGate: + """Tests for wonder_gate in convergence criteria.""" + + @staticmethod + def _stable_lineage_with_wonder( + prev_questions: tuple[str, ...] = (), + ) -> OntologyLineage: + """Create a stable lineage where gen2-3 have identical ontology.""" + schema_a = _schema(("x",)) + schema_b = _schema(("name", "age", "email")) + return OntologyLineage( + lineage_id="test", + goal="test", + generations=( + GenerationRecord( + generation_number=1, seed_id="s1", ontology_snapshot=schema_a, + wonder_questions=prev_questions, + ), + GenerationRecord( + generation_number=2, seed_id="s2", ontology_snapshot=schema_b, + wonder_questions=prev_questions, + ), + GenerationRecord( + generation_number=3, seed_id="s3", ontology_snapshot=schema_b, + wonder_questions=prev_questions, + ), + ), + ) + + def test_blocks_when_novel_questions_exceed_threshold(self) -> None: + """Wonder gate blocks when majority of questions are novel.""" + lineage = self._stable_lineage_with_wonder(("old question 1", "old question 2")) + wonder = WonderOutput( + questions=("brand new question A", "brand new question B", "old question 1"), + should_continue=True, + ) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + wonder_gate_enabled=True, + wonder_novelty_threshold=0.5, + ) + signal = criteria.evaluate(lineage, latest_wonder=wonder) + assert not signal.converged + assert "Wonder gate" in signal.reason + assert "novel questions" in signal.reason + + def test_allows_when_all_questions_are_repeated(self) -> None: + """Wonder gate allows convergence when all questions are old.""" + prev = ("question A", "question B") + lineage = self._stable_lineage_with_wonder(prev) + wonder = WonderOutput( + questions=("question A", "question B"), + should_continue=True, + ) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + wonder_gate_enabled=True, + wonder_novelty_threshold=0.5, + ) + signal = criteria.evaluate(lineage, latest_wonder=wonder) + assert signal.converged + + def test_allows_when_wonder_is_none(self) -> None: + """Wonder gate passes when no wonder output is provided.""" + lineage = self._stable_lineage_with_wonder() + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + wonder_gate_enabled=True, + ) + signal = criteria.evaluate(lineage, latest_wonder=None) + assert signal.converged + + def test_disabled_allows_convergence(self) -> None: + """Disabled wonder gate allows convergence regardless of novelty.""" + lineage = self._stable_lineage_with_wonder() + wonder = WonderOutput( + questions=("completely new question",), + should_continue=True, + ) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + wonder_gate_enabled=False, + ) + signal = criteria.evaluate(lineage, latest_wonder=wonder) + assert signal.converged From 1a6e6cde44f2c3481af65da9fc989dbb3538f757 Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Sun, 22 Mar 2026 16:44:22 +0900 Subject: [PATCH 07/15] feat(convergence): add drift trend gate and fix evolve_step validation_passed Add drift_trend_gate that blocks convergence when drift_score shows monotonic increase over recent generations, indicating the ontology is moving away from the goal. Gate is disabled by default (opt-in). Fix: evolve_step() was not passing validation_passed to convergence evaluation, causing validation gate to fall back to string matching instead of using the explicit boolean flag. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ouroboros/evolution/convergence.py | 47 ++++++++++++ src/ouroboros/evolution/loop.py | 5 ++ tests/unit/test_convergence.py | 99 ++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) diff --git a/src/ouroboros/evolution/convergence.py b/src/ouroboros/evolution/convergence.py index 85522c42..2e005663 100644 --- a/src/ouroboros/evolution/convergence.py +++ b/src/ouroboros/evolution/convergence.py @@ -74,6 +74,8 @@ class ConvergenceCriteria: ontology_min_fields: int = 3 wonder_gate_enabled: bool = False wonder_novelty_threshold: float = 0.5 + drift_trend_gate_enabled: bool = False + drift_trend_window: int = 3 def evaluate( self, @@ -227,6 +229,17 @@ def evaluate( generation=current_gen, ) + # Drift trend gate: block if drift_score is monotonically increasing + if self.drift_trend_gate_enabled: + drift_block = self._check_drift_trend_gate(lineage) + if drift_block is not None: + return ConvergenceSignal( + converged=False, + reason=drift_block, + ontology_similarity=latest_sim, + generation=current_gen, + ) + # Validation gate: block convergence if validation was skipped or failed if self.validation_gate_enabled and validation_output: # Use explicit bool flag when available; fall back to string matching @@ -372,6 +385,40 @@ def _check_ac_gate( return None + def _check_drift_trend_gate(self, lineage: OntologyLineage) -> str | None: + """Block convergence if drift_score shows monotonic increase. + + Checks if drift_score has increased in every consecutive pair within + the recent window. This indicates the ontology is consistently moving + away from the goal. + + Generations with missing evaluation or drift_score (None) are skipped. + If fewer than 2 valid scores remain, the gate passes. + """ + gens = lineage.generations + if len(gens) < self.drift_trend_window: + return None + + recent = gens[-self.drift_trend_window :] + scores = [ + g.evaluation_summary.drift_score + for g in recent + if g.evaluation_summary is not None + and g.evaluation_summary.drift_score is not None + ] + + if len(scores) < 2: + return None + + increases = sum(1 for i in range(1, len(scores)) if scores[i] > scores[i - 1]) + if increases >= len(scores) - 1: + return ( + f"Drift trend gate: drift_score monotonically increasing over " + f"{len(scores)} generations ({scores[0]:.3f} → {scores[-1]:.3f})" + ) + + return None + def _check_wonder_gate( self, lineage: OntologyLineage, latest_wonder: WonderOutput ) -> str | None: diff --git a/src/ouroboros/evolution/loop.py b/src/ouroboros/evolution/loop.py index 85e14038..5f7396b3 100644 --- a/src/ouroboros/evolution/loop.py +++ b/src/ouroboros/evolution/loop.py @@ -79,6 +79,8 @@ class EvolutionaryLoopConfig: ontology_min_fields: int = 3 wonder_gate_enabled: bool = False wonder_novelty_threshold: float = 0.5 + drift_trend_gate_enabled: bool = False + drift_trend_window: int = 3 @dataclass(frozen=True, slots=True) @@ -188,6 +190,8 @@ def __init__( ontology_min_fields=self.config.ontology_min_fields, wonder_gate_enabled=self.config.wonder_gate_enabled, wonder_novelty_threshold=self.config.wonder_novelty_threshold, + drift_trend_gate_enabled=self.config.drift_trend_gate_enabled, + drift_trend_window=self.config.drift_trend_window, ) def set_project_dir(self, project_dir: str | None) -> Token[str | None]: @@ -593,6 +597,7 @@ async def evolve_step( result.wonder_output, latest_evaluation=result.evaluation_summary, validation_output=result.validation_output, + validation_passed=result.validation_passed, ) action = StepAction.CONTINUE diff --git a/tests/unit/test_convergence.py b/tests/unit/test_convergence.py index 4e8989c0..7441d8bb 100644 --- a/tests/unit/test_convergence.py +++ b/tests/unit/test_convergence.py @@ -977,3 +977,102 @@ def test_disabled_allows_convergence(self) -> None: ) signal = criteria.evaluate(lineage, latest_wonder=wonder) assert signal.converged + + +# --- Drift Trend Gate --- + + +class TestDriftTrendGate: + """Tests for drift_trend_gate in convergence criteria.""" + + @staticmethod + def _stable_lineage_with_drift(drift_scores: list[float | None]) -> OntologyLineage: + """Create a stable lineage with specified drift_scores per generation.""" + schema_init = _schema(("different",)) + schema = _schema(("name", "age", "email")) + gens: list[GenerationRecord] = [ + GenerationRecord( + generation_number=1, seed_id="s1", ontology_snapshot=schema_init, + ) + ] + for i, ds in enumerate(drift_scores, start=2): + eval_summary = EvaluationSummary( + final_approved=True, + highest_stage_passed=2, + score=0.8, + drift_score=ds, + ) if ds is not None else None + gens.append( + GenerationRecord( + generation_number=i, + seed_id=f"s{i}", + ontology_snapshot=schema, + evaluation_summary=eval_summary, + ) + ) + return OntologyLineage( + lineage_id="test", + goal="test", + generations=tuple(gens), + ) + + def test_blocks_when_drift_monotonically_increasing(self) -> None: + """Drift trend gate blocks when drift_score increases every generation.""" + lineage = self._stable_lineage_with_drift([0.2, 0.4, 0.6]) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + stagnation_window=5, # avoid stagnation safety firing first + drift_trend_gate_enabled=True, + drift_trend_window=3, + ) + signal = criteria.evaluate(lineage) + assert not signal.converged + assert "Drift trend gate" in signal.reason + + def test_allows_when_drift_decreasing(self) -> None: + """Drift trend gate allows convergence when drift is improving.""" + lineage = self._stable_lineage_with_drift([0.6, 0.4, 0.2]) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + drift_trend_gate_enabled=True, + drift_trend_window=3, + ) + signal = criteria.evaluate(lineage) + assert signal.converged + + def test_allows_when_drift_mixed(self) -> None: + """Drift trend gate allows when drift fluctuates (not monotonic).""" + lineage = self._stable_lineage_with_drift([0.3, 0.5, 0.4]) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + drift_trend_gate_enabled=True, + drift_trend_window=3, + ) + signal = criteria.evaluate(lineage) + assert signal.converged + + def test_passes_when_drift_scores_none(self) -> None: + """Gate passes when drift_score is None (fewer than 2 valid scores).""" + lineage = self._stable_lineage_with_drift([None, None, 0.5]) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + drift_trend_gate_enabled=True, + drift_trend_window=3, + ) + signal = criteria.evaluate(lineage) + assert signal.converged + + def test_disabled_allows_convergence(self) -> None: + """Disabled drift trend gate allows convergence regardless.""" + lineage = self._stable_lineage_with_drift([0.2, 0.4, 0.6]) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + drift_trend_gate_enabled=False, + ) + signal = criteria.evaluate(lineage) + assert signal.converged From 7e6c5562e1060908b45b4d0292a19a8bd46d44ce Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Wed, 25 Mar 2026 08:21:05 +0900 Subject: [PATCH 08/15] style: remove unused import in guard.py --- src/ouroboros/evolution/guard.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ouroboros/evolution/guard.py b/src/ouroboros/evolution/guard.py index 3d0a2ec9..3fdca9a2 100644 --- a/src/ouroboros/evolution/guard.py +++ b/src/ouroboros/evolution/guard.py @@ -13,7 +13,6 @@ import logging from ouroboros.core.errors import TransitionError -from ouroboros.core.lineage import LineageStatus from ouroboros.events.base import BaseEvent from ouroboros.evolution.projector import LineageProjector from ouroboros.evolution.transitions import is_transition_allowed From 4f6535773279e99329a83639ddda459888e9015f Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Wed, 25 Mar 2026 09:20:18 +0900 Subject: [PATCH 09/15] fix: default regression and validation gates to opt-in (disabled) New gates should not change existing behavior by default. Users can explicitly enable them with regression_gate_enabled=True and validation_gate_enabled=True. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ouroboros/evolution/convergence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ouroboros/evolution/convergence.py b/src/ouroboros/evolution/convergence.py index 2e005663..af1cd7e6 100644 --- a/src/ouroboros/evolution/convergence.py +++ b/src/ouroboros/evolution/convergence.py @@ -68,8 +68,8 @@ class ConvergenceCriteria: eval_min_score: float = 0.7 ac_gate_mode: str = "all" # "all" | "ratio" | "off" ac_min_pass_ratio: float = 1.0 # for "ratio" mode - regression_gate_enabled: bool = True - validation_gate_enabled: bool = True + regression_gate_enabled: bool = False + validation_gate_enabled: bool = False ontology_completeness_gate_enabled: bool = False ontology_min_fields: int = 3 wonder_gate_enabled: bool = False From 4a191f4313ffef45d6ee34b64a3cd0dc73e802b5 Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Wed, 25 Mar 2026 09:26:15 +0900 Subject: [PATCH 10/15] style: remove unused imports in test files Fix F401 ruff lint errors caught by CI. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/unit/evolution/test_guard.py | 3 +-- tests/unit/evolution/test_reflect_engine.py | 3 +-- tests/unit/evolution/test_wonder_engine.py | 5 ++--- tests/unit/test_enum_safety.py | 4 +--- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/tests/unit/evolution/test_guard.py b/tests/unit/evolution/test_guard.py index cb0f2b2d..92cef13d 100644 --- a/tests/unit/evolution/test_guard.py +++ b/tests/unit/evolution/test_guard.py @@ -10,12 +10,11 @@ from __future__ import annotations -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest from ouroboros.core.errors import TransitionError -from ouroboros.core.lineage import LineageStatus from ouroboros.events.base import BaseEvent from ouroboros.events.lineage import ( lineage_converged, diff --git a/tests/unit/evolution/test_reflect_engine.py b/tests/unit/evolution/test_reflect_engine.py index 39ec9258..bc208306 100644 --- a/tests/unit/evolution/test_reflect_engine.py +++ b/tests/unit/evolution/test_reflect_engine.py @@ -5,11 +5,10 @@ from __future__ import annotations -import pytest from ouroboros.core.lineage import MutationAction from ouroboros.core.seed import OntologyField, OntologySchema, Seed, SeedMetadata -from ouroboros.evolution.reflect import OntologyMutation, ReflectEngine, ReflectOutput +from ouroboros.evolution.reflect import ReflectEngine def _make_engine() -> ReflectEngine: diff --git a/tests/unit/evolution/test_wonder_engine.py b/tests/unit/evolution/test_wonder_engine.py index 4489dfb5..2fbab482 100644 --- a/tests/unit/evolution/test_wonder_engine.py +++ b/tests/unit/evolution/test_wonder_engine.py @@ -5,11 +5,10 @@ from __future__ import annotations -import pytest -from ouroboros.core.lineage import ACResult, EvaluationSummary +from ouroboros.core.lineage import EvaluationSummary from ouroboros.core.seed import OntologyField, OntologySchema -from ouroboros.evolution.wonder import WonderEngine, WonderOutput +from ouroboros.evolution.wonder import WonderEngine def _make_engine() -> WonderEngine: diff --git a/tests/unit/test_enum_safety.py b/tests/unit/test_enum_safety.py index f3b84110..d5f20a9d 100644 --- a/tests/unit/test_enum_safety.py +++ b/tests/unit/test_enum_safety.py @@ -7,13 +7,11 @@ from __future__ import annotations -import ast import re from pathlib import Path -import pytest -from ouroboros.core.lineage import MutationAction, TerminationReason +from ouroboros.core.lineage import MutationAction _SRC = Path(__file__).resolve().parents[2] / "src" / "ouroboros" From 9628afb6f026aff13984d4e33d11f876576ce041 Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Wed, 25 Mar 2026 09:29:04 +0900 Subject: [PATCH 11/15] style: fix import sorting (I001) and remaining unused import (F401) Align with CI ruff isort rules. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/unit/evolution/test_event_contract.py | 4 +--- tests/unit/evolution/test_reflect_engine.py | 1 - tests/unit/evolution/test_wonder_engine.py | 1 - tests/unit/test_enum_safety.py | 3 +-- 4 files changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/unit/evolution/test_event_contract.py b/tests/unit/evolution/test_event_contract.py index e49119cf..800847de 100644 --- a/tests/unit/evolution/test_event_contract.py +++ b/tests/unit/evolution/test_event_contract.py @@ -8,10 +8,8 @@ from __future__ import annotations import ast -import re from pathlib import Path - -import pytest +import re # --- Paths --- _SRC = Path(__file__).resolve().parents[3] / "src" / "ouroboros" diff --git a/tests/unit/evolution/test_reflect_engine.py b/tests/unit/evolution/test_reflect_engine.py index bc208306..40f0b029 100644 --- a/tests/unit/evolution/test_reflect_engine.py +++ b/tests/unit/evolution/test_reflect_engine.py @@ -5,7 +5,6 @@ from __future__ import annotations - from ouroboros.core.lineage import MutationAction from ouroboros.core.seed import OntologyField, OntologySchema, Seed, SeedMetadata from ouroboros.evolution.reflect import ReflectEngine diff --git a/tests/unit/evolution/test_wonder_engine.py b/tests/unit/evolution/test_wonder_engine.py index 2fbab482..eea8bcad 100644 --- a/tests/unit/evolution/test_wonder_engine.py +++ b/tests/unit/evolution/test_wonder_engine.py @@ -5,7 +5,6 @@ from __future__ import annotations - from ouroboros.core.lineage import EvaluationSummary from ouroboros.core.seed import OntologyField, OntologySchema from ouroboros.evolution.wonder import WonderEngine diff --git a/tests/unit/test_enum_safety.py b/tests/unit/test_enum_safety.py index d5f20a9d..58530691 100644 --- a/tests/unit/test_enum_safety.py +++ b/tests/unit/test_enum_safety.py @@ -7,9 +7,8 @@ from __future__ import annotations -import re from pathlib import Path - +import re from ouroboros.core.lineage import MutationAction From d8057954efe954a85dbeda3392412e68798db2f4 Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Wed, 25 Mar 2026 09:31:30 +0900 Subject: [PATCH 12/15] style: add strict=True to zip() call (B905) Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/unit/test_convergence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_convergence.py b/tests/unit/test_convergence.py index 7441d8bb..12f945e0 100644 --- a/tests/unit/test_convergence.py +++ b/tests/unit/test_convergence.py @@ -779,7 +779,7 @@ def _stable_lineage( description="Test schema", fields=tuple( OntologyField(name=n, field_type="string", description=d, required=True) - for n, d in zip(fields, descriptions) + for n, d in zip(fields, descriptions, strict=True) ), ) return OntologyLineage( From 32b6f86f3fe414a0fe3b4d2ab9b2ba94c83e128e Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Wed, 25 Mar 2026 09:33:41 +0900 Subject: [PATCH 13/15] style: apply ruff format to match CI formatting rules Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ouroboros/evolution/convergence.py | 7 ++--- src/ouroboros/evolution/loop.py | 8 ++---- src/ouroboros/evolution/projector.py | 4 +-- tests/unit/evolution/test_event_contract.py | 6 ++-- tests/unit/evolution/test_reflect_engine.py | 6 ++-- tests/unit/evolution/test_transitions.py | 11 ++----- tests/unit/test_convergence.py | 32 ++++++++++++++------- 7 files changed, 34 insertions(+), 40 deletions(-) diff --git a/src/ouroboros/evolution/convergence.py b/src/ouroboros/evolution/convergence.py index af1cd7e6..80b62b49 100644 --- a/src/ouroboros/evolution/convergence.py +++ b/src/ouroboros/evolution/convergence.py @@ -41,9 +41,7 @@ class ConvergenceSignal: def __post_init__(self) -> None: if self.converged and self.termination_reason is None: - raise ValueError( - "converged=True requires termination_reason to be set" - ) + raise ValueError("converged=True requires termination_reason to be set") @dataclass @@ -403,8 +401,7 @@ def _check_drift_trend_gate(self, lineage: OntologyLineage) -> str | None: scores = [ g.evaluation_summary.drift_score for g in recent - if g.evaluation_summary is not None - and g.evaluation_summary.drift_score is not None + if g.evaluation_summary is not None and g.evaluation_summary.drift_score is not None ] if len(scores) < 2: diff --git a/src/ouroboros/evolution/loop.py b/src/ouroboros/evolution/loop.py index 5f7396b3..131821f5 100644 --- a/src/ouroboros/evolution/loop.py +++ b/src/ouroboros/evolution/loop.py @@ -351,9 +351,7 @@ async def run( }, ) - lineage = await self._emit_termination( - signal, lineage, generation_number - ) + lineage = await self._emit_termination(signal, lineage, generation_number) break # Prepare for next generation @@ -602,9 +600,7 @@ async def evolve_step( action = StepAction.CONTINUE if signal.converged: - lineage, action = await self._emit_termination_step( - signal, lineage, generation_number - ) + lineage, action = await self._emit_termination_step(signal, lineage, generation_number) return Result.ok( StepResult( diff --git a/src/ouroboros/evolution/projector.py b/src/ouroboros/evolution/projector.py index 4439cd77..551d08f5 100644 --- a/src/ouroboros/evolution/projector.py +++ b/src/ouroboros/evolution/projector.py @@ -184,9 +184,7 @@ def project(self, events: list[BaseEvent]) -> OntologyLineage | None: tr = _DEFAULT_TERMINATION[event.type] else: raw_reason = event.data.get("reason", "") - tr = _LEGACY_REASON_MAP.get( - raw_reason, _DEFAULT_TERMINATION[event.type] - ) + tr = _LEGACY_REASON_MAP.get(raw_reason, _DEFAULT_TERMINATION[event.type]) lineage = lineage.with_status(target_status, termination_reason=tr) elif event.type == "lineage.rewound": diff --git a/tests/unit/evolution/test_event_contract.py b/tests/unit/evolution/test_event_contract.py index 800847de..fe085edf 100644 --- a/tests/unit/evolution/test_event_contract.py +++ b/tests/unit/evolution/test_event_contract.py @@ -24,9 +24,7 @@ def _extract_event_types_from_factories(path: Path) -> set[str]: types: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.keyword) and node.arg == "type": - if isinstance(node.value, ast.Constant) and isinstance( - node.value.value, str - ): + if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): types.add(node.value.value) return types @@ -37,7 +35,7 @@ def _extract_handled_types_from_projector(path: Path) -> set[str]: # Match event.type == "..." types = set(re.findall(r'event\.type\s*==\s*"([^"]+)"', source)) # Match event.type in ("...", "...", ...) - in_matches = re.findall(r'event\.type\s+in\s*\(([^)]+)\)', source) + in_matches = re.findall(r"event\.type\s+in\s*\(([^)]+)\)", source) for match in in_matches: types |= set(re.findall(r'"([^"]+)"', match)) return types diff --git a/tests/unit/evolution/test_reflect_engine.py b/tests/unit/evolution/test_reflect_engine.py index 40f0b029..58a19743 100644 --- a/tests/unit/evolution/test_reflect_engine.py +++ b/tests/unit/evolution/test_reflect_engine.py @@ -28,9 +28,7 @@ def _make_seed(**overrides) -> Seed: "ontology_schema": OntologySchema( name="TaskManager", description="A task management system", - fields=( - OntologyField(name="task", field_type="entity", description="A work item"), - ), + fields=(OntologyField(name="task", field_type="entity", description="A work item"),), ), "metadata": SeedMetadata(), } @@ -51,7 +49,7 @@ def test_valid_full_response(self) -> None: '"ontology_mutations": [' ' {"action": "add", "field_name": "priority", "field_type": "enum", ' ' "description": "Task priority level", "reason": "Missing from ontology"}' - '], ' + "], " '"reasoning": "Priority was identified as a gap"}', seed, ) diff --git a/tests/unit/evolution/test_transitions.py b/tests/unit/evolution/test_transitions.py index 84a5aec1..aa18c59b 100644 --- a/tests/unit/evolution/test_transitions.py +++ b/tests/unit/evolution/test_transitions.py @@ -32,9 +32,7 @@ def _extract_event_types_from_factories(path: Path) -> set[str]: types: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.keyword) and node.arg == "type": - if isinstance(node.value, ast.Constant) and isinstance( - node.value.value, str - ): + if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): types.add(node.value.value) return types @@ -78,8 +76,7 @@ def test_terminal_event_status_keys_in_matrix(self) -> None: all_matrix_types |= allowed for event_type in TERMINAL_EVENT_STATUS: assert event_type in all_matrix_types, ( - f"TERMINAL_EVENT_STATUS key '{event_type}' not found in any " - f"ALLOWED_TRANSITIONS set" + f"TERMINAL_EVENT_STATUS key '{event_type}' not found in any ALLOWED_TRANSITIONS set" ) @@ -140,9 +137,7 @@ def test_exhausted_allows_rewound(self) -> None: assert is_transition_allowed(LineageStatus.EXHAUSTED, "lineage.rewound") def test_exhausted_rejects_generation_started(self) -> None: - assert not is_transition_allowed( - LineageStatus.EXHAUSTED, "lineage.generation.started" - ) + assert not is_transition_allowed(LineageStatus.EXHAUSTED, "lineage.generation.started") # --- ABORTED state --- diff --git a/tests/unit/test_convergence.py b/tests/unit/test_convergence.py index 12f945e0..cc03ad74 100644 --- a/tests/unit/test_convergence.py +++ b/tests/unit/test_convergence.py @@ -903,15 +903,21 @@ def _stable_lineage_with_wonder( goal="test", generations=( GenerationRecord( - generation_number=1, seed_id="s1", ontology_snapshot=schema_a, + generation_number=1, + seed_id="s1", + ontology_snapshot=schema_a, wonder_questions=prev_questions, ), GenerationRecord( - generation_number=2, seed_id="s2", ontology_snapshot=schema_b, + generation_number=2, + seed_id="s2", + ontology_snapshot=schema_b, wonder_questions=prev_questions, ), GenerationRecord( - generation_number=3, seed_id="s3", ontology_snapshot=schema_b, + generation_number=3, + seed_id="s3", + ontology_snapshot=schema_b, wonder_questions=prev_questions, ), ), @@ -992,16 +998,22 @@ def _stable_lineage_with_drift(drift_scores: list[float | None]) -> OntologyLine schema = _schema(("name", "age", "email")) gens: list[GenerationRecord] = [ GenerationRecord( - generation_number=1, seed_id="s1", ontology_snapshot=schema_init, + generation_number=1, + seed_id="s1", + ontology_snapshot=schema_init, ) ] for i, ds in enumerate(drift_scores, start=2): - eval_summary = EvaluationSummary( - final_approved=True, - highest_stage_passed=2, - score=0.8, - drift_score=ds, - ) if ds is not None else None + eval_summary = ( + EvaluationSummary( + final_approved=True, + highest_stage_passed=2, + score=0.8, + drift_score=ds, + ) + if ds is not None + else None + ) gens.append( GenerationRecord( generation_number=i, From 2f3cad98f32d883bcfaca85713713fa8586079a1 Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Wed, 25 Mar 2026 09:55:41 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20rewind=20clears=20termination=5Freason,=20wonder=20?= =?UTF-8?q?gate=20excludes=20latest=20gen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. with_status() and rewind_to() now always set termination_reason (to None when not provided), preventing stale terminal metadata on ACTIVE lineages after rewind. 2. Wonder gate excludes the latest generation when building the set of previously-seen questions. Without this, questions from the current generation would always appear "already seen", making the gate unable to ever block convergence. 3. Added regression tests for both fixes: - Projector rewind test asserts termination_reason is None - Wonder gate test with novel questions only in latest generation Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ouroboros/core/lineage.py | 11 +++--- src/ouroboros/evolution/convergence.py | 5 ++- tests/unit/test_convergence.py | 46 ++++++++++++++++++++++++++ tests/unit/test_projector_rewind.py | 4 +++ 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/ouroboros/core/lineage.py b/src/ouroboros/core/lineage.py index bac6c0cf..b7a689a0 100644 --- a/src/ouroboros/core/lineage.py +++ b/src/ouroboros/core/lineage.py @@ -251,10 +251,12 @@ def with_status( status: LineageStatus, termination_reason: TerminationReason | str | None = None, ) -> OntologyLineage: - """Return new lineage with updated status and optional termination reason.""" - updates: dict = {"status": status} - if termination_reason is not None: - updates["termination_reason"] = termination_reason + """Return new lineage with updated status and optional termination reason. + + When transitioning to a non-terminal status (e.g. ACTIVE after rewind), + termination_reason is cleared to None to avoid stale metadata. + """ + updates: dict = {"status": status, "termination_reason": termination_reason} return self.model_copy(update=updates) def rewind_to(self, generation_number: int) -> OntologyLineage: @@ -284,5 +286,6 @@ def rewind_to(self, generation_number: int) -> OntologyLineage: update={ "generations": truncated, "status": LineageStatus.ACTIVE, + "termination_reason": None, } ) diff --git a/src/ouroboros/evolution/convergence.py b/src/ouroboros/evolution/convergence.py index 80b62b49..7f2b1644 100644 --- a/src/ouroboros/evolution/convergence.py +++ b/src/ouroboros/evolution/convergence.py @@ -429,8 +429,11 @@ def _check_wonder_gate( if not latest_wonder.questions: return None + # Exclude the latest generation — its questions are the ones being + # evaluated via latest_wonder, so including them would make every + # question appear "already seen" and the gate would never block. prev_questions: set[str] = set() - for gen in lineage.generations: + for gen in lineage.generations[:-1]: prev_questions.update(gen.wonder_questions) novel = [q for q in latest_wonder.questions if q not in prev_questions] diff --git a/tests/unit/test_convergence.py b/tests/unit/test_convergence.py index cc03ad74..c244f5ec 100644 --- a/tests/unit/test_convergence.py +++ b/tests/unit/test_convergence.py @@ -941,6 +941,52 @@ def test_blocks_when_novel_questions_exceed_threshold(self) -> None: assert "Wonder gate" in signal.reason assert "novel questions" in signal.reason + def test_blocks_when_latest_gen_has_same_questions(self) -> None: + """Wonder gate still blocks even when latest generation already contains the questions. + + Regression test: the gate must exclude the latest generation when building + the set of previously-seen questions, otherwise every question appears + "already seen" and the gate can never fire. + """ + schema_a = _schema(("x",)) + schema_b = _schema(("name", "age", "email")) + novel_qs = ("brand new A", "brand new B") + # Gen1-2 have old questions; gen3 (latest) has the novel questions + lineage = OntologyLineage( + lineage_id="test", + goal="test", + generations=( + GenerationRecord( + generation_number=1, + seed_id="s1", + ontology_snapshot=schema_a, + wonder_questions=("old Q1",), + ), + GenerationRecord( + generation_number=2, + seed_id="s2", + ontology_snapshot=schema_b, + wonder_questions=("old Q2",), + ), + GenerationRecord( + generation_number=3, + seed_id="s3", + ontology_snapshot=schema_b, + wonder_questions=novel_qs, + ), + ), + ) + wonder = WonderOutput(questions=novel_qs, should_continue=True) + criteria = ConvergenceCriteria( + convergence_threshold=0.95, + min_generations=2, + wonder_gate_enabled=True, + wonder_novelty_threshold=0.5, + ) + signal = criteria.evaluate(lineage, latest_wonder=wonder) + assert not signal.converged + assert "Wonder gate" in signal.reason + def test_allows_when_all_questions_are_repeated(self) -> None: """Wonder gate allows convergence when all questions are old.""" prev = ("question A", "question B") diff --git a/tests/unit/test_projector_rewind.py b/tests/unit/test_projector_rewind.py index 39289abd..e982dd9d 100644 --- a/tests/unit/test_projector_rewind.py +++ b/tests/unit/test_projector_rewind.py @@ -225,6 +225,10 @@ def test_rewind_sets_status_active(self) -> None: assert lineage is not None assert lineage.status == LineageStatus.ACTIVE + assert lineage.termination_reason is None, ( + "Rewind must clear termination_reason — stale terminal metadata " + "on an ACTIVE lineage is internally inconsistent" + ) # Rewind from gen 1 to gen 1 means no discarded generations assert len(lineage.rewind_history) == 1 assert lineage.rewind_history[0].discarded_generations == () From 8596b0328d375f155a5407b0784d3468b273abcb Mon Sep 17 00:00:00 2001 From: kangminlee-maker Date: Wed, 25 Mar 2026 10:12:20 +0900 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20apply=208-agent=20panel=20review?= =?UTF-8?q?=20findings=20=E2=80=94=207=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Immediate fixes: 1. _check_drift_trend_gate() now uses _completed_generations() to exclude FAILED generations, matching all other gates 2. Remove `| str` from termination_reason type — projector already converts legacy strings to enum, str union was a type safety hole High-priority recommendations: 3. ConvergenceSignal.converged docstring clarifies it means "loop should terminate" not "ontology has converged" 4. Add blocking_gate field to ConvergenceSignal for structured gate identification (eval, ac, regression, evolution, completeness, wonder, drift_trend, validation) Medium-priority recommendations: 5. Refactor _emit_termination() from if/elif chain to dispatch dict (_TERMINATION_DISPATCH) — adding new TerminationReason now requires only a dict entry. Add exhaustiveness test for dispatch coverage. 6. Include ABORTED in evolve_step() terminal status check 7. Wonder gate blocks convergence when latest_wonder is None and gate is enabled — "unable to generate questions" ≠ "no questions remain" Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ouroboros/core/lineage.py | 4 +- src/ouroboros/evolution/convergence.py | 31 ++++++++- src/ouroboros/evolution/loop.py | 95 +++++++++++++------------- src/ouroboros/evolution/projector.py | 2 +- tests/unit/test_convergence.py | 13 +++- tests/unit/test_enum_safety.py | 17 +++++ 6 files changed, 105 insertions(+), 57 deletions(-) diff --git a/src/ouroboros/core/lineage.py b/src/ouroboros/core/lineage.py index b7a689a0..ef6193d6 100644 --- a/src/ouroboros/core/lineage.py +++ b/src/ouroboros/core/lineage.py @@ -229,7 +229,7 @@ class OntologyLineage(BaseModel, frozen=True): generations: tuple[GenerationRecord, ...] = Field(default_factory=tuple) rewind_history: tuple[RewindRecord, ...] = Field(default_factory=tuple) status: LineageStatus = LineageStatus.ACTIVE - termination_reason: TerminationReason | str | None = None + termination_reason: TerminationReason | None = None created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) @property @@ -249,7 +249,7 @@ def with_generation(self, record: GenerationRecord) -> OntologyLineage: def with_status( self, status: LineageStatus, - termination_reason: TerminationReason | str | None = None, + termination_reason: TerminationReason | None = None, ) -> OntologyLineage: """Return new lineage with updated status and optional termination reason. diff --git a/src/ouroboros/evolution/convergence.py b/src/ouroboros/evolution/convergence.py index 7f2b1644..c01bf9d7 100644 --- a/src/ouroboros/evolution/convergence.py +++ b/src/ouroboros/evolution/convergence.py @@ -30,6 +30,10 @@ class ConvergenceSignal: When converged=True, termination_reason must be set to indicate why. When converged=False, termination_reason is None (loop continues). + + Note: ``converged`` means "the loop should terminate", NOT "the ontology + has converged". EXHAUSTED, STAGNATED, OSCILLATED and REPETITIVE all + set converged=True despite not representing true ontological convergence. """ converged: bool @@ -38,6 +42,7 @@ class ConvergenceSignal: generation: int failed_acs: tuple[int, ...] = () termination_reason: TerminationReason | None = None + blocking_gate: str | None = None def __post_init__(self) -> None: if self.converged and self.termination_reason is None: @@ -150,6 +155,7 @@ def evaluate( ), ontology_similarity=latest_sim, generation=current_gen, + blocking_gate="eval", ) # Per-AC gate: block convergence if individual ACs are failing @@ -168,6 +174,7 @@ def evaluate( ontology_similarity=latest_sim, generation=current_gen, failed_acs=failed_indices, + blocking_gate="ac", ) # Signal 5: Regression gate — block convergence if ACs regressed @@ -186,6 +193,7 @@ def evaluate( ontology_similarity=latest_sim, generation=current_gen, failed_acs=regressed, + blocking_gate="regression", ) # Evolution gate: withhold convergence if ontology never actually evolved. @@ -203,6 +211,7 @@ def evaluate( ), ontology_similarity=latest_sim, generation=current_gen, + blocking_gate="evolution", ) # Ontology completeness gate: block convergence if ontology is structurally thin @@ -214,9 +223,24 @@ def evaluate( reason=completeness_block, ontology_similarity=latest_sim, generation=current_gen, + blocking_gate="completeness", ) - # Wonder gate: block convergence if Wonder found significant novel questions + # Wonder gate: block convergence if Wonder found significant novel questions. + # When wonder_gate is enabled but no wonder output is available, block + # convergence — "unable to generate questions" is not the same as + # "no questions remain" (Ouroboros principle: questions must be answered). + if self.wonder_gate_enabled and latest_wonder is None: + return ConvergenceSignal( + converged=False, + reason=( + "Wonder gate: wonder output unavailable — cannot confirm " + "no questions remain (wonder_gate_enabled=True)" + ), + ontology_similarity=latest_sim, + generation=current_gen, + blocking_gate="wonder", + ) if self.wonder_gate_enabled and latest_wonder is not None: wonder_block = self._check_wonder_gate(lineage, latest_wonder) if wonder_block is not None: @@ -225,6 +249,7 @@ def evaluate( reason=wonder_block, ontology_similarity=latest_sim, generation=current_gen, + blocking_gate="wonder", ) # Drift trend gate: block if drift_score is monotonically increasing @@ -236,6 +261,7 @@ def evaluate( reason=drift_block, ontology_similarity=latest_sim, generation=current_gen, + blocking_gate="drift_trend", ) # Validation gate: block convergence if validation was skipped or failed @@ -254,6 +280,7 @@ def evaluate( reason=(f"Validation gate blocked: {validation_output}"), ontology_similarity=latest_sim, generation=current_gen, + blocking_gate="validation", ) return ConvergenceSignal( @@ -393,7 +420,7 @@ def _check_drift_trend_gate(self, lineage: OntologyLineage) -> str | None: Generations with missing evaluation or drift_score (None) are skipped. If fewer than 2 valid scores remain, the gate passes. """ - gens = lineage.generations + gens = self._completed_generations(lineage) if len(gens) < self.drift_trend_window: return None diff --git a/src/ouroboros/evolution/loop.py b/src/ouroboros/evolution/loop.py index 131821f5..eeb672c7 100644 --- a/src/ouroboros/evolution/loop.py +++ b/src/ouroboros/evolution/loop.py @@ -424,7 +424,11 @@ async def evolve_step( return Result.err(OuroborosError("Failed to project lineage from events")) # Check if lineage is already terminated - if lineage.status in (LineageStatus.CONVERGED, LineageStatus.EXHAUSTED): + if lineage.status in ( + LineageStatus.CONVERGED, + LineageStatus.EXHAUSTED, + LineageStatus.ABORTED, + ): return Result.err( OuroborosError( f"Lineage already terminated with status: {lineage.status.value}" @@ -612,6 +616,16 @@ async def evolve_step( ) ) + # Dispatch table: TerminationReason → (event_factory_key, LineageStatus) + # event_factory_key: "exhausted", "stagnated", or "converged" + _TERMINATION_DISPATCH: dict[TerminationReason, tuple[str, LineageStatus]] = { + TerminationReason.EXHAUSTED: ("exhausted", LineageStatus.EXHAUSTED), + TerminationReason.STAGNATED: ("stagnated", LineageStatus.CONVERGED), + TerminationReason.OSCILLATED: ("stagnated", LineageStatus.CONVERGED), + TerminationReason.REPETITIVE: ("stagnated", LineageStatus.CONVERGED), + TerminationReason.CONVERGED: ("converged", LineageStatus.CONVERGED), + } + async def _emit_termination( self, signal: ConvergenceSignal, @@ -620,65 +634,48 @@ async def _emit_termination( ) -> OntologyLineage: """Emit termination event and update lineage status. Used by run().""" tr = signal.termination_reason + dispatch = self._TERMINATION_DISPATCH.get(tr) - if tr == TerminationReason.EXHAUSTED: - await self._guard.gated_append( - lineage_exhausted( - lineage.lineage_id, - generation_number, - self.config.max_generations, - termination_reason=str(tr), - ) + if dispatch is None: + # Defensive: unknown TerminationReason falls back to CONVERGED + logger.warning( + "evolution.termination.unknown_reason", + extra={ + "termination_reason": str(tr), + "lineage_id": lineage.lineage_id, + "generation": generation_number, + }, ) - return lineage.with_status(LineageStatus.EXHAUSTED, termination_reason=tr) + dispatch = ("converged", LineageStatus.CONVERGED) - if tr in ( - TerminationReason.STAGNATED, - TerminationReason.OSCILLATED, - TerminationReason.REPETITIVE, - ): - await self._guard.gated_append( - lineage_stagnated( - lineage.lineage_id, - generation_number, - signal.reason, - self.config.stagnation_window, - termination_reason=str(tr), - ) - ) - return lineage.with_status(LineageStatus.CONVERGED, termination_reason=tr) + event_key, target_status = dispatch - if tr == TerminationReason.CONVERGED: - await self._guard.gated_append( - lineage_converged( - lineage.lineage_id, - generation_number, - signal.reason, - signal.ontology_similarity, - termination_reason=str(tr), - ) + if event_key == "exhausted": + event = lineage_exhausted( + lineage.lineage_id, + generation_number, + self.config.max_generations, + termination_reason=str(tr), ) - return lineage.with_status(LineageStatus.CONVERGED, termination_reason=tr) - - # Defensive: unknown TerminationReason falls back to CONVERGED with warning - logger.warning( - "evolution.termination.unknown_reason", - extra={ - "termination_reason": str(tr), - "lineage_id": lineage.lineage_id, - "generation": generation_number, - }, - ) - await self._guard.gated_append( - lineage_converged( + elif event_key == "stagnated": + event = lineage_stagnated( + lineage.lineage_id, + generation_number, + signal.reason, + self.config.stagnation_window, + termination_reason=str(tr), + ) + else: # converged + event = lineage_converged( lineage.lineage_id, generation_number, signal.reason, signal.ontology_similarity, termination_reason=str(tr), ) - ) - return lineage.with_status(LineageStatus.CONVERGED, termination_reason=tr) + + await self._guard.gated_append(event) + return lineage.with_status(target_status, termination_reason=tr) async def _emit_termination_step( self, diff --git a/src/ouroboros/evolution/projector.py b/src/ouroboros/evolution/projector.py index 551d08f5..3a280fac 100644 --- a/src/ouroboros/evolution/projector.py +++ b/src/ouroboros/evolution/projector.py @@ -179,7 +179,7 @@ def project(self, events: list[BaseEvent]) -> OntologyLineage | None: raw_tr = event.data.get("termination_reason") if raw_tr: try: - tr: TerminationReason | str = TerminationReason(raw_tr) + tr: TerminationReason = TerminationReason(raw_tr) except ValueError: tr = _DEFAULT_TERMINATION[event.type] else: diff --git a/tests/unit/test_convergence.py b/tests/unit/test_convergence.py index c244f5ec..4a725374 100644 --- a/tests/unit/test_convergence.py +++ b/tests/unit/test_convergence.py @@ -1004,8 +1004,13 @@ def test_allows_when_all_questions_are_repeated(self) -> None: signal = criteria.evaluate(lineage, latest_wonder=wonder) assert signal.converged - def test_allows_when_wonder_is_none(self) -> None: - """Wonder gate passes when no wonder output is provided.""" + def test_blocks_when_wonder_is_none(self) -> None: + """Wonder gate blocks when no wonder output is available. + + "Unable to generate questions" is not the same as "no questions remain". + When wonder_gate is enabled, absence of wonder output should block + convergence to avoid false convergence. + """ lineage = self._stable_lineage_with_wonder() criteria = ConvergenceCriteria( convergence_threshold=0.95, @@ -1013,7 +1018,9 @@ def test_allows_when_wonder_is_none(self) -> None: wonder_gate_enabled=True, ) signal = criteria.evaluate(lineage, latest_wonder=None) - assert signal.converged + assert not signal.converged + assert signal.blocking_gate == "wonder" + assert "unavailable" in signal.reason def test_disabled_allows_convergence(self) -> None: """Disabled wonder gate allows convergence regardless of novelty.""" diff --git a/tests/unit/test_enum_safety.py b/tests/unit/test_enum_safety.py index 58530691..b7f5a645 100644 --- a/tests/unit/test_enum_safety.py +++ b/tests/unit/test_enum_safety.py @@ -33,6 +33,23 @@ def test_all_actions_in_apply_mutations(self) -> None: ) +class TestTerminationReasonDispatchCoverage: + """_TERMINATION_DISPATCH must cover all TerminationReason members.""" + + def test_dispatch_covers_all_termination_reasons(self) -> None: + """Every TerminationReason member has a dispatch entry in loop.py.""" + from ouroboros.core.lineage import TerminationReason + from ouroboros.evolution.loop import EvolutionaryLoop + + dispatch = EvolutionaryLoop._TERMINATION_DISPATCH + for member in TerminationReason: + assert member in dispatch, ( + f"TerminationReason.{member.name} is not in " + f"EvolutionaryLoop._TERMINATION_DISPATCH. " + f"Dispatched: {sorted(m.name for m in dispatch)}" + ) + + class TestTerminationReasonProjectorCoverage: """Projector legacy/default mappings must cover all terminal event types."""