diff --git a/benchmarks/invariant_reconciliation_bench.py b/benchmarks/invariant_reconciliation_bench.py new file mode 100644 index 000000000..daea895c6 --- /dev/null +++ b/benchmarks/invariant_reconciliation_bench.py @@ -0,0 +1,174 @@ +"""ASV benchmarks for Invariant Reconciliation Actor throughput. + +Measures the performance of: +- Reconciliation with varying numbers of invariants per scope +- Conflict detection and resolution overhead +- Decision recording throughput +- Full pipeline (collect → reconcile → record) +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +# Ensure the local source tree is importable +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.actor.reconciliation import ( # noqa: E402 + InvariantReconciliationActor, + ScopeInvariants, + reconcile_invariants, +) +from cleveragents.application.services.decision_service import DecisionService # noqa: E402 +from cleveragents.application.services.invariant_service import InvariantService # noqa: E402 +from cleveragents.domain.models.core.invariant import ( # noqa: E402 + Invariant, + InvariantScope, +) + + +def _make_invariant(text: str, scope: InvariantScope, source: str) -> Invariant: + """Create an invariant without going through the service.""" + return Invariant(text=text, scope=scope, source_name=source) + + +def _build_scope_invariants( + n_global: int = 0, + n_project: int = 0, + n_action: int = 0, + n_plan: int = 0, +) -> ScopeInvariants: + """Build a ScopeInvariants with the given counts (unique texts).""" + return ScopeInvariants( + global_invariants=[ + _make_invariant(f"global-rule-{i}", InvariantScope.GLOBAL, "system") + for i in range(n_global) + ], + project_invariants=[ + _make_invariant(f"project-rule-{i}", InvariantScope.PROJECT, "local/api") + for i in range(n_project) + ], + action_invariants=[ + _make_invariant(f"action-rule-{i}", InvariantScope.ACTION, "local/deploy") + for i in range(n_action) + ], + plan_invariants=[ + _make_invariant(f"plan-rule-{i}", InvariantScope.PLAN, "PLAN01") + for i in range(n_plan) + ], + ) + + +def _build_conflicting_scope_invariants(n_conflicts: int) -> ScopeInvariants: + """Build scope invariants where each text appears at global and plan.""" + return ScopeInvariants( + global_invariants=[ + _make_invariant(f"shared-rule-{i}", InvariantScope.GLOBAL, "system") + for i in range(n_conflicts) + ], + project_invariants=[], + action_invariants=[], + plan_invariants=[ + _make_invariant(f"shared-rule-{i}", InvariantScope.PLAN, "PLAN01") + for i in range(n_conflicts) + ], + ) + + +class ReconciliationAlgorithmSuite: + """Benchmark the pure reconciliation algorithm (no I/O).""" + + params = [1, 10, 50, 100] + param_names = ["invariants_per_scope"] + + def setup(self, n: int) -> None: + self._scope_invs = _build_scope_invariants( + n_global=n, n_project=n, n_action=n, n_plan=n + ) + + def time_reconcile_no_conflicts(self, n: int) -> None: + """Reconcile N invariants per scope with no conflicts.""" + reconcile_invariants(self._scope_invs) + + +class ConflictResolutionSuite: + """Benchmark conflict detection and resolution.""" + + params = [1, 10, 50, 100] + param_names = ["conflict_count"] + + def setup(self, n: int) -> None: + self._scope_invs = _build_conflicting_scope_invariants(n) + + def time_reconcile_with_conflicts(self, n: int) -> None: + """Reconcile N conflicting invariant pairs.""" + reconcile_invariants(self._scope_invs) + + +class FullPipelineSuite: + """Benchmark the full actor pipeline (collect + reconcile + record).""" + + params = [1, 10, 50] + param_names = ["invariants_per_scope"] + + def setup(self, n: int) -> None: + self._n = n + + def time_full_pipeline(self, n: int) -> None: + """Run full actor pipeline with N invariants per scope.""" + inv_svc = InvariantService() + dec_svc = DecisionService() + + for i in range(n): + inv_svc.add_invariant(f"global-rule-{i}", InvariantScope.GLOBAL, "system") + inv_svc.add_invariant( + f"project-rule-{i}", InvariantScope.PROJECT, "local/api" + ) + inv_svc.add_invariant( + f"action-rule-{i}", InvariantScope.ACTION, "local/deploy" + ) + inv_svc.add_invariant( + f"plan-rule-{i}", InvariantScope.PLAN, "01JQAAAAAAAAAAAAAAAAAAAA01" + ) + + actor = InvariantReconciliationActor( + invariant_service=inv_svc, + decision_service=dec_svc, + ) + actor.run( + plan_id="01JQAAAAAAAAAAAAAAAAAAAA01", + project_name="local/api", + action_name="local/deploy", + ) + + +class DecisionRecordingSuite: + """Benchmark decision recording throughput.""" + + params = [1, 10, 50, 100] + param_names = ["invariant_count"] + + def setup(self, n: int) -> None: + self._n = n + + def time_record_decisions(self, n: int) -> None: + """Record N invariant_enforced decisions.""" + inv_svc = InvariantService() + dec_svc = DecisionService() + + for i in range(n): + inv_svc.add_invariant(f"global-rule-{i}", InvariantScope.GLOBAL, "system") + + actor = InvariantReconciliationActor( + invariant_service=inv_svc, + decision_service=dec_svc, + ) + actor.run(plan_id="01JQAAAAAAAAAAAAAAAAAAAA01") diff --git a/features/invariant_reconciliation_actor.feature b/features/invariant_reconciliation_actor.feature new file mode 100644 index 000000000..60174954e --- /dev/null +++ b/features/invariant_reconciliation_actor.feature @@ -0,0 +1,236 @@ +Feature: Invariant Reconciliation Actor + As a plan strategizer + I want invariants from all scopes to be reconciled automatically + So that the Strategize phase operates under a clear, conflict-free invariant set + + Background: + Given a fresh InvariantService for reconciliation + And a fresh DecisionService for reconciliation + + # === Single-scope invariants (no conflicts) === + + @single_scope + Scenario: Single global invariant with no conflicts + Given a global invariant "Never delete production data" from source "system" + When I reconcile plan-only for "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 1 invariant + And the reconciled set should contain "Never delete production data" + And 0 conflicts should be detected + And 1 invariant_enforced decision should be recorded + + @single_scope + Scenario: Single plan invariant with no conflicts + Given a plan invariant "Mock all network calls" from source "01JQAAAAAAAAAAAAAAAAAAAA01" + When I reconcile plan-only for "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 1 invariant + And the reconciled set should contain "Mock all network calls" + And 0 conflicts should be detected + + @single_scope + Scenario: Multiple global invariants with no conflicts + Given a global invariant "Never delete production data" from source "system" + And a global invariant "All APIs must maintain backward compatibility" from source "system" + When I reconcile plan-only for "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 2 invariants + And 0 conflicts should be detected + And 2 invariant_enforced decisions should be recorded + + # === Multi-scope compatible invariants === + + @multi_scope + Scenario: Multi-scope invariants that are all compatible + Given a global invariant "Never delete production data" from source "system" + And a project invariant "Use ORM for all queries" from source "local/api-service" for project "local/api-service" + And a plan invariant "Mock all network calls" from source "01JQAAAAAAAAAAAAAAAAAAAA01" + When I reconcile with-project "local/api-service" for plan "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 3 invariants + And the reconciled set should contain "Never delete production data" + And the reconciled set should contain "Use ORM for all queries" + And the reconciled set should contain "Mock all network calls" + And 0 conflicts should be detected + And 3 invariant_enforced decisions should be recorded + + @multi_scope + Scenario: Four-scope invariants all compatible + Given a global invariant "Never delete production data" from source "system" + And a project invariant "Use ORM for all queries" from source "local/api-service" for project "local/api-service" + And an action invariant "Separate commits per file" from source "local/code-coverage" for action "local/code-coverage" + And a plan invariant "Mock all network calls" from source "01JQAAAAAAAAAAAAAAAAAAAA01" + When I reconcile with-project-action "local/api-service" and "local/code-coverage" for plan "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 4 invariants + And 0 conflicts should be detected + And 4 invariant_enforced decisions should be recorded + + # === Conflict detection and specificity resolution === + + @conflict + Scenario: Plan invariant overrides project invariant on same text + Given a project invariant "Use REST for all APIs" from source "local/api-service" for project "local/api-service" + And a plan invariant "Use REST for all APIs" from source "01JQAAAAAAAAAAAAAAAAAAAA01" + When I reconcile with-project "local/api-service" for plan "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 1 invariant + And the winning invariant for "use rest for all apis" should be from "plan" scope + And 1 conflict should be detected + + @conflict + Scenario: Plan overrides global on same text + Given a global invariant "All tests must pass" from source "system" + And a plan invariant "All tests must pass" from source "01JQAAAAAAAAAAAAAAAAAAAA01" + When I reconcile plan-only for "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 1 invariant + And the winning invariant for "all tests must pass" should be from "plan" scope + And 1 conflict should be detected + + @conflict + Scenario: Action invariant overrides project invariant + Given a project invariant "Coverage above 80%" from source "local/api-service" for project "local/api-service" + And an action invariant "Coverage above 80%" from source "local/code-coverage" for action "local/code-coverage" + When I reconcile with-project-action "local/api-service" and "local/code-coverage" for plan "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 1 invariant + And the winning invariant for "coverage above 80%" should be from "action" scope + And 1 conflict should be detected + + @conflict + Scenario: Project invariant overrides global invariant + Given a global invariant "Database timeout 10s" from source "system" + And a project invariant "Database timeout 10s" from source "local/api-service" for project "local/api-service" + When I reconcile with-project "local/api-service" for plan "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 1 invariant + And the winning invariant for "database timeout 10s" should be from "project" scope + And 1 conflict should be detected + + # === non_overridable global invariant === + + @non_overridable + Scenario: Non-overridable global invariant blocks plan override + Given a non_overridable global invariant "Payment processing must be idempotent" from source "system" + And a plan invariant "Payment processing must be idempotent" from source "01JQAAAAAAAAAAAAAAAAAAAA01" + When I reconcile plan-only for "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 1 invariant + And the winning invariant for "payment processing must be idempotent" should be from "global" scope + And 1 conflict should be detected + And the conflict reason should mention "non_overridable" + + @non_overridable + Scenario: Non-overridable global invariant blocks project override + Given a non_overridable global invariant "Audit logging is mandatory" from source "system" + And a project invariant "Audit logging is mandatory" from source "local/api-service" for project "local/api-service" + When I reconcile with-project "local/api-service" for plan "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 1 invariant + And the winning invariant for "audit logging is mandatory" should be from "global" scope + And 1 conflict should be detected + And the conflict reason should mention "non_overridable" + + @non_overridable + Scenario: Non-overridable global invariant blocks action override + Given a non_overridable global invariant "Security scan required" from source "system" + And an action invariant "Security scan required" from source "local/deploy" for action "local/deploy" + When I reconcile with-action "local/deploy" for plan "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 1 invariant + And the winning invariant for "security scan required" should be from "global" scope + And 1 conflict should be detected + + @non_overridable + Scenario: Regular global invariant can be overridden by plan + Given a global invariant "Use verbose logging" from source "system" + And a plan invariant "Use verbose logging" from source "01JQAAAAAAAAAAAAAAAAAAAA01" + When I reconcile plan-only for "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the winning invariant for "use verbose logging" should be from "plan" scope + + # === invariant_enforced decision recording === + + @decisions + Scenario: Each reconciled invariant gets an invariant_enforced decision + Given a global invariant "Never delete production data" from source "system" + And a project invariant "Use ORM for all queries" from source "local/api-service" for project "local/api-service" + And a plan invariant "Mock all network calls" from source "01JQAAAAAAAAAAAAAAAAAAAA01" + When I reconcile with-project "local/api-service" for plan "01JQAAAAAAAAAAAAAAAAAAAA01" + Then 3 invariant_enforced decisions should be recorded + And all recorded decisions should be of type "invariant_enforced" + And each decision should reference the plan "01JQAAAAAAAAAAAAAAAAAAAA01" + + @decisions + Scenario: Decisions include rationale about scope + Given a global invariant "Never delete production data" from source "system" + When I reconcile plan-only for "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the decision rationale should mention "global" + + @decisions + Scenario: Decision question includes invariant text + Given a plan invariant "Mock all network calls" from source "01JQAAAAAAAAAAAAAAAAAAAA01" + When I reconcile plan-only for "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the decision question should contain "Mock all network calls" + + # === Reconciled InvariantSet accessibility === + + @invariant_set + Scenario: Reconciled InvariantSet is returned and accessible + Given a global invariant "Never delete production data" from source "system" + And a plan invariant "Mock all network calls" from source "01JQAAAAAAAAAAAAAAAAAAAA01" + When I reconcile plan-only for "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the result should contain a reconciled InvariantSet + And the InvariantSet should have 2 invariants + And the InvariantSet invariants should be Invariant model instances + + @invariant_set + Scenario: Empty scope produces empty reconciled set + When I reconcile plan-only for "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 0 invariants + And 0 conflicts should be detected + And 0 invariant_enforced decisions should be recorded + + # === Validation and edge cases === + + @validation + Scenario: Empty plan_id raises ValueError + Then running reconciliation with empty plan_id should raise ValueError + + @validation + Scenario: Inactive invariants are excluded from reconciliation + Given a global invariant "Never delete production data" from source "system" + And an inactive global invariant "Deprecated rule" from source "system" + When I reconcile plan-only for "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 1 invariant + And the reconciled set should not contain "Deprecated rule" + + @validation + Scenario: Case-insensitive conflict detection + Given a global invariant "Use HTTPS only" from source "system" + And a plan invariant "use https only" from source "01JQAAAAAAAAAAAAAAAAAAAA01" + When I reconcile plan-only for "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the reconciled set should contain 1 invariant + And 1 conflict should be detected + + @validation + Scenario: Parent decision ID is passed to recorded decisions + Given a global invariant "Never delete production data" from source "system" + When I reconcile with-parent "01JQBBBBBBBBBBBBBBBBBBBB01" for plan "01JQAAAAAAAAAAAAAAAAAAAA01" + Then the recorded decisions should have parent "01JQBBBBBBBBBBBBBBBBBBBB01" + + # === Collect invariants separately === + + @validation + Scenario: Creating actor with None invariant_service raises ValueError + Then creating an actor with None invariant_service should raise ValueError + + @validation + Scenario: Creating actor with None decision_service raises ValueError + Then creating an actor with None decision_service should raise ValueError + + @validation + Scenario: Reconciliation with no plan_id scope omits plan invariants + Given a global invariant "Global rule" from source "system" + When I reconcile without-plan-id using project "local/api-service" + Then the collected plan invariants should be empty + + @collection + Scenario: Collect invariants gathers from all scopes + Given a global invariant "Global rule" from source "system" + And a project invariant "Project rule" from source "local/api-service" for project "local/api-service" + And an action invariant "Action rule" from source "local/deploy" for action "local/deploy" + And a plan invariant "Plan rule" from source "01JQAAAAAAAAAAAAAAAAAAAA01" + When I collect invariants for plan "01JQAAAAAAAAAAAAAAAAAAAA01" with project "local/api-service" and action "local/deploy" + Then the collected scope invariants should have 1 global invariant + And the collected scope invariants should have 1 project invariant + And the collected scope invariants should have 1 action invariant + And the collected scope invariants should have 1 plan invariant diff --git a/features/steps/invariant_reconciliation_actor_steps.py b/features/steps/invariant_reconciliation_actor_steps.py new file mode 100644 index 000000000..284d3ed2c --- /dev/null +++ b/features/steps/invariant_reconciliation_actor_steps.py @@ -0,0 +1,423 @@ +"""Step definitions for invariant_reconciliation_actor.feature. + +Tests the InvariantReconciliationActor: multi-scope collection, +conflict detection, specificity-based resolution with non_overridable +support, invariant_enforced decision recording, and reconciled +InvariantSet production. +""" + +from __future__ import annotations + +from behave import given, then, when # type: ignore[import-untyped] + +from cleveragents.actor.reconciliation import ( + InvariantReconciliationActor, + ReconciliationResult, +) +from cleveragents.application.services.decision_service import DecisionService +from cleveragents.application.services.invariant_service import InvariantService +from cleveragents.domain.models.core.decision import DecisionType +from cleveragents.domain.models.core.invariant import ( + Invariant, + InvariantScope, + InvariantSet, +) + +# ================================================================ +# Background / Setup +# ================================================================ + + +@given("a fresh InvariantService for reconciliation") +def step_fresh_invariant_service(context): + """Create a fresh InvariantService instance.""" + context.invariant_service = InvariantService() + + +@given("a fresh DecisionService for reconciliation") +def step_fresh_decision_service(context): + """Create a fresh DecisionService instance.""" + context.decision_service = DecisionService() + + +# ================================================================ +# Adding Invariants +# ================================================================ + + +@given('a global invariant "{text}" from source "{source}"') +def step_add_global_invariant(context, text, source): + """Add a global-scope invariant.""" + context.invariant_service.add_invariant( + text=text, scope=InvariantScope.GLOBAL, source_name=source + ) + + +@given('a non_overridable global invariant "{text}" from source "{source}"') +def step_add_non_overridable_global(context, text, source): + """Add a non_overridable global-scope invariant.""" + inv = context.invariant_service.add_invariant( + text=text, scope=InvariantScope.GLOBAL, source_name=source + ) + # Set non_overridable on the stored invariant + inv.non_overridable = True + + +@given('a project invariant "{text}" from source "{source}" for project "{project}"') +def step_add_project_invariant(context, text, source, project): + """Add a project-scope invariant.""" + context.invariant_service.add_invariant( + text=text, scope=InvariantScope.PROJECT, source_name=source + ) + + +@given('an action invariant "{text}" from source "{source}" for action "{action}"') +def step_add_action_invariant(context, text, source, action): + """Add an action-scope invariant.""" + context.invariant_service.add_invariant( + text=text, scope=InvariantScope.ACTION, source_name=source + ) + + +@given('a plan invariant "{text}" from source "{source}"') +def step_add_plan_invariant(context, text, source): + """Add a plan-scope invariant.""" + context.invariant_service.add_invariant( + text=text, scope=InvariantScope.PLAN, source_name=source + ) + + +@given('an inactive global invariant "{text}" from source "{source}"') +def step_add_inactive_global(context, text, source): + """Add a global invariant then soft-delete it.""" + inv = context.invariant_service.add_invariant( + text=text, scope=InvariantScope.GLOBAL, source_name=source + ) + context.invariant_service.remove_invariant(inv.id) + + +# ================================================================ +# Running the actor +# ================================================================ + + +def _build_actor(context): + """Build the reconciliation actor from context services.""" + return InvariantReconciliationActor( + invariant_service=context.invariant_service, + decision_service=context.decision_service, + ) + + +@when('I reconcile plan-only for "{plan_id}"') +def step_run_reconciliation(context, plan_id): + """Run the reconciliation actor with plan-only scope.""" + actor = _build_actor(context) + context.reconciliation_result = actor.run(plan_id=plan_id) + + +@when('I reconcile with-project "{project}" for plan "{plan_id}"') +def step_run_reconciliation_with_project(context, project, plan_id): + """Run the reconciliation actor with plan and project scope.""" + actor = _build_actor(context) + context.reconciliation_result = actor.run(plan_id=plan_id, project_name=project) + + +@when('I reconcile with-project-action "{project}" and "{action}" for plan "{plan_id}"') +def step_run_reconciliation_with_project_and_action(context, project, action, plan_id): + """Run the reconciliation actor with plan, project, and action scope.""" + actor = _build_actor(context) + context.reconciliation_result = actor.run( + plan_id=plan_id, project_name=project, action_name=action + ) + + +@when('I reconcile with-action "{action}" for plan "{plan_id}"') +def step_run_reconciliation_with_action(context, action, plan_id): + """Run the reconciliation actor with plan and action scope.""" + actor = _build_actor(context) + context.reconciliation_result = actor.run(plan_id=plan_id, action_name=action) + + +@when('I reconcile with-parent "{parent_id}" for plan "{plan_id}"') +def step_run_reconciliation_with_parent(context, parent_id, plan_id): + """Run the reconciliation actor with a parent decision ID.""" + actor = _build_actor(context) + context.reconciliation_result = actor.run( + plan_id=plan_id, parent_decision_id=parent_id + ) + + +@when( + 'I collect invariants for plan "{plan_id}" ' + 'with project "{project}" and action "{action}"' +) +def step_collect_invariants(context, plan_id, project, action): + """Collect invariants without full reconciliation.""" + actor = _build_actor(context) + context.collected_scope = actor.collect_invariants( + plan_id=plan_id, project_name=project, action_name=action + ) + + +# ================================================================ +# Reconciled set assertions +# ================================================================ + + +@then("the reconciled set should contain {count:d} invariant") +@then("the reconciled set should contain {count:d} invariants") +def step_reconciled_count(context, count): + """Assert the reconciled set has the expected number of invariants.""" + result: ReconciliationResult = context.reconciliation_result + actual = len(result.reconciled_set.invariants) + assert actual == count, f"Expected {count} invariants, got {actual}" + + +@then('the reconciled set should contain "{text}"') +def step_reconciled_contains_text(context, text): + """Assert the reconciled set contains an invariant with the given text.""" + result: ReconciliationResult = context.reconciliation_result + texts = [inv.text for inv in result.reconciled_set.invariants] + assert text in texts, f"'{text}' not found in {texts}" + + +@then('the reconciled set should not contain "{text}"') +def step_reconciled_not_contains_text(context, text): + """Assert the reconciled set does not contain the given text.""" + result: ReconciliationResult = context.reconciliation_result + texts = [inv.text for inv in result.reconciled_set.invariants] + assert text not in texts, f"'{text}' unexpectedly found in {texts}" + + +# ================================================================ +# Conflict assertions +# ================================================================ + + +@then("{count:d} conflict should be detected") +@then("{count:d} conflicts should be detected") +def step_conflict_count(context, count): + """Assert the expected number of conflicts.""" + result: ReconciliationResult = context.reconciliation_result + actual = len(result.conflicts) + assert actual == count, f"Expected {count} conflicts, got {actual}" + + +@then('the winning invariant for "{key}" should be from "{scope}" scope') +def step_winning_scope(context, key, scope): + """Assert the winner for a given normalised key is from the expected scope.""" + result: ReconciliationResult = context.reconciliation_result + for inv in result.reconciled_set.invariants: + if inv.text.strip().lower() == key.strip().lower(): + assert inv.scope.value == scope, ( + f"Expected winner scope '{scope}', got '{inv.scope.value}'" + ) + return + raise AssertionError(f"No invariant found with key '{key}'") + + +@then('the conflict reason should mention "{text}"') +def step_conflict_reason_mentions(context, text): + """Assert at least one conflict reason contains the given text.""" + result: ReconciliationResult = context.reconciliation_result + reasons = [c.reason for c in result.conflicts] + assert any(text in r for r in reasons), ( + f"'{text}' not found in conflict reasons: {reasons}" + ) + + +# ================================================================ +# Decision assertions +# ================================================================ + + +@then("{count:d} invariant_enforced decision should be recorded") +@then("{count:d} invariant_enforced decisions should be recorded") +def step_decision_count(context, count): + """Assert the expected number of enforced decisions.""" + result: ReconciliationResult = context.reconciliation_result + actual = len(result.enforced_decision_ids) + assert actual == count, f"Expected {count} decisions, got {actual}" + + +@then('all recorded decisions should be of type "{dtype}"') +def step_all_decisions_type(context, dtype): + """Assert all recorded decisions are of the specified type.""" + result: ReconciliationResult = context.reconciliation_result + for did in result.enforced_decision_ids: + decision = context.decision_service.get_decision(did) + assert decision.decision_type == DecisionType(dtype), ( + f"Decision {did} type is {decision.decision_type}, expected {dtype}" + ) + + +@then('each decision should reference the plan "{plan_id}"') +def step_decisions_reference_plan(context, plan_id): + """Assert each decision references the given plan.""" + result: ReconciliationResult = context.reconciliation_result + for did in result.enforced_decision_ids: + decision = context.decision_service.get_decision(did) + assert decision.plan_id == plan_id, ( + f"Decision {did} plan_id is {decision.plan_id}, expected {plan_id}" + ) + + +@then('the decision rationale should mention "{text}"') +def step_decision_rationale_mentions(context, text): + """Assert at least one decision rationale mentions the text.""" + result: ReconciliationResult = context.reconciliation_result + for did in result.enforced_decision_ids: + decision = context.decision_service.get_decision(did) + if text.lower() in decision.rationale.lower(): + return + raise AssertionError(f"No decision rationale mentions '{text}'") + + +@then('the decision question should contain "{text}"') +def step_decision_question_contains(context, text): + """Assert at least one decision question contains the text.""" + result: ReconciliationResult = context.reconciliation_result + for did in result.enforced_decision_ids: + decision = context.decision_service.get_decision(did) + if text in decision.question: + return + raise AssertionError(f"No decision question contains '{text}'") + + +@then('the recorded decisions should have parent "{parent_id}"') +def step_decisions_have_parent(context, parent_id): + """Assert all recorded decisions have the given parent decision ID.""" + result: ReconciliationResult = context.reconciliation_result + for did in result.enforced_decision_ids: + decision = context.decision_service.get_decision(did) + assert decision.parent_decision_id == parent_id, ( + f"Decision {did} parent is {decision.parent_decision_id}, " + f"expected {parent_id}" + ) + + +# ================================================================ +# InvariantSet assertions +# ================================================================ + + +@then("the result should contain a reconciled InvariantSet") +def step_result_has_invariant_set(context): + """Assert the result contains a reconciled InvariantSet.""" + result: ReconciliationResult = context.reconciliation_result + assert result.reconciled_set is not None + assert isinstance(result.reconciled_set, InvariantSet) + + +@then("the InvariantSet should have {count:d} invariants") +def step_invariant_set_count(context, count): + """Assert the InvariantSet has the expected count.""" + result: ReconciliationResult = context.reconciliation_result + actual = len(result.reconciled_set.invariants) + assert actual == count, f"Expected {count} invariants in set, got {actual}" + + +@then("the InvariantSet invariants should be Invariant model instances") +def step_invariant_set_types(context): + """Assert each element in the InvariantSet is an Invariant instance.""" + result: ReconciliationResult = context.reconciliation_result + for inv in result.reconciled_set.invariants: + assert isinstance(inv, Invariant), ( + f"Expected Invariant, got {type(inv).__name__}" + ) + + +# ================================================================ +# Validation / error assertions +# ================================================================ + + +@then("running reconciliation with empty plan_id should raise ValueError") +def step_empty_plan_id_raises(context: object) -> None: + """Assert ValueError is raised for empty plan_id.""" + actor = _build_actor(context) + try: + actor.run(plan_id="") + raise AssertionError("Expected ValueError for empty plan_id") + except ValueError: + pass + + +@then("creating an actor with None invariant_service should raise ValueError") +def step_none_invariant_service_raises(context: object) -> None: + """Assert ValueError when invariant_service is None.""" + try: + InvariantReconciliationActor( + invariant_service=None, # type: ignore[arg-type] + decision_service=context.decision_service, + ) + raise AssertionError("Expected ValueError for None invariant_service") + except ValueError: + pass + + +@then("creating an actor with None decision_service should raise ValueError") +def step_none_decision_service_raises(context: object) -> None: + """Assert ValueError when decision_service is None.""" + try: + InvariantReconciliationActor( + invariant_service=context.invariant_service, + decision_service=None, # type: ignore[arg-type] + ) + raise AssertionError("Expected ValueError for None decision_service") + except ValueError: + pass + + +@when('I reconcile without-plan-id using project "{project}"') +def step_reconcile_without_plan_id(context: object, project: str) -> None: + """Collect invariants without specifying a plan_id.""" + actor = _build_actor(context) + context.collected_scope = actor.collect_invariants( + project_name=project, + ) + + +@then("the collected plan invariants should be empty") +def step_collected_plan_empty(context: object) -> None: + """Assert the collected plan invariants list is empty.""" + actual = len(context.collected_scope.plan_invariants) + assert actual == 0, f"Expected 0 plan invariants, got {actual}" + + +# ================================================================ +# Collection assertions +# ================================================================ + + +@then("the collected scope invariants should have {count:d} global invariant") +@then("the collected scope invariants should have {count:d} global invariants") +def step_collected_global_count(context, count): + """Assert the collected scope has the expected global count.""" + actual = len(context.collected_scope.global_invariants) + assert actual == count, f"Expected {count} global invariants, got {actual}" + + +@then("the collected scope invariants should have {count:d} project invariant") +@then("the collected scope invariants should have {count:d} project invariants") +def step_collected_project_count(context, count): + """Assert the collected scope has the expected project count.""" + actual = len(context.collected_scope.project_invariants) + assert actual == count, f"Expected {count} project invariants, got {actual}" + + +@then("the collected scope invariants should have {count:d} action invariant") +@then("the collected scope invariants should have {count:d} action invariants") +def step_collected_action_count(context, count): + """Assert the collected scope has the expected action count.""" + actual = len(context.collected_scope.action_invariants) + assert actual == count, f"Expected {count} action invariants, got {actual}" + + +@then("the collected scope invariants should have {count:d} plan invariant") +@then("the collected scope invariants should have {count:d} plan invariants") +def step_collected_plan_count(context, count): + """Assert the collected scope has the expected plan count.""" + actual = len(context.collected_scope.plan_invariants) + assert actual == count, f"Expected {count} plan invariants, got {actual}" diff --git a/robot/helper_invariant_reconciliation.py b/robot/helper_invariant_reconciliation.py new file mode 100644 index 000000000..8e33d55f4 --- /dev/null +++ b/robot/helper_invariant_reconciliation.py @@ -0,0 +1,219 @@ +"""Robot Framework helper for invariant reconciliation actor integration tests. + +Exercises the full reconciliation pipeline: collect → reconcile → enforce, +then verifies the reconciled set is respected by subsequent operations. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +# Ensure source tree is importable +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.actor.reconciliation import ( # noqa: E402 + InvariantReconciliationActor, +) +from cleveragents.application.services.decision_service import ( # noqa: E402 + DecisionService, +) +from cleveragents.application.services.invariant_service import ( # noqa: E402 + InvariantService, +) +from cleveragents.domain.models.core.decision import DecisionType # noqa: E402 +from cleveragents.domain.models.core.invariant import InvariantScope # noqa: E402 + + +def _run_reconciliation_pipeline() -> dict[str, object]: + """Run the full reconciliation pipeline and return results as dict.""" + inv_svc = InvariantService() + dec_svc = DecisionService() + + # Add invariants across scopes + inv_svc.add_invariant( + "Never delete production data", + InvariantScope.GLOBAL, + "system", + ) + inv_svc.add_invariant( + "Use ORM for all queries", + InvariantScope.PROJECT, + "local/api-service", + ) + inv_svc.add_invariant( + "Separate commits per file", + InvariantScope.ACTION, + "local/code-coverage", + ) + inv_svc.add_invariant( + "Mock all network calls", + InvariantScope.PLAN, + "01JQAAAAAAAAAAAAAAAAAAAA01", + ) + + actor = InvariantReconciliationActor( + invariant_service=inv_svc, + decision_service=dec_svc, + ) + + result = actor.run( + plan_id="01JQAAAAAAAAAAAAAAAAAAAA01", + project_name="local/api-service", + action_name="local/code-coverage", + ) + + return { + "invariant_count": len(result.reconciled_set.invariants), + "conflict_count": len(result.conflicts), + "decision_count": len(result.enforced_decision_ids), + "invariant_texts": [inv.text for inv in result.reconciled_set.invariants], + } + + +def _run_conflict_resolution() -> dict[str, object]: + """Run reconciliation with conflicts and verify resolution.""" + inv_svc = InvariantService() + dec_svc = DecisionService() + + inv_svc.add_invariant( + "All tests must pass", + InvariantScope.GLOBAL, + "system", + ) + inv_svc.add_invariant( + "All tests must pass", + InvariantScope.PLAN, + "01JQAAAAAAAAAAAAAAAAAAAA01", + ) + + actor = InvariantReconciliationActor( + invariant_service=inv_svc, + decision_service=dec_svc, + ) + + result = actor.run(plan_id="01JQAAAAAAAAAAAAAAAAAAAA01") + + winner_scope = "" + for inv in result.reconciled_set.invariants: + if inv.text.lower() == "all tests must pass": + winner_scope = inv.scope.value + break + + return { + "invariant_count": len(result.reconciled_set.invariants), + "conflict_count": len(result.conflicts), + "winner_scope": winner_scope, + } + + +def _run_non_overridable_test() -> dict[str, object]: + """Verify non_overridable global invariants block overrides.""" + inv_svc = InvariantService() + dec_svc = DecisionService() + + inv = inv_svc.add_invariant( + "Payment processing must be idempotent", + InvariantScope.GLOBAL, + "system", + ) + inv.non_overridable = True + + inv_svc.add_invariant( + "Payment processing must be idempotent", + InvariantScope.PLAN, + "01JQAAAAAAAAAAAAAAAAAAAA01", + ) + + actor = InvariantReconciliationActor( + invariant_service=inv_svc, + decision_service=dec_svc, + ) + + result = actor.run(plan_id="01JQAAAAAAAAAAAAAAAAAAAA01") + + winner_scope = "" + for r_inv in result.reconciled_set.invariants: + if "idempotent" in r_inv.text.lower(): + winner_scope = r_inv.scope.value + break + + return { + "invariant_count": len(result.reconciled_set.invariants), + "conflict_count": len(result.conflicts), + "winner_scope": winner_scope, + "has_non_overridable_reason": any( + "non_overridable" in c.reason for c in result.conflicts + ), + } + + +def _run_decision_verification() -> dict[str, object]: + """Verify invariant_enforced decisions are properly recorded.""" + inv_svc = InvariantService() + dec_svc = DecisionService() + + inv_svc.add_invariant( + "Never delete production data", + InvariantScope.GLOBAL, + "system", + ) + inv_svc.add_invariant( + "Mock all network calls", + InvariantScope.PLAN, + "01JQAAAAAAAAAAAAAAAAAAAA01", + ) + + actor = InvariantReconciliationActor( + invariant_service=inv_svc, + decision_service=dec_svc, + ) + + result = actor.run(plan_id="01JQAAAAAAAAAAAAAAAAAAAA01") + + # Verify decisions + all_correct_type = True + all_correct_plan = True + for did in result.enforced_decision_ids: + decision = dec_svc.get_decision(did) + if decision.decision_type != DecisionType.INVARIANT_ENFORCED: + all_correct_type = False + if decision.plan_id != "01JQAAAAAAAAAAAAAAAAAAAA01": + all_correct_plan = False + + return { + "decision_count": len(result.enforced_decision_ids), + "all_correct_type": all_correct_type, + "all_correct_plan": all_correct_plan, + } + + +def main() -> None: + """Entry point for Robot Framework helper.""" + if len(sys.argv) < 2: + print("Usage: helper_invariant_reconciliation.py ") + sys.exit(1) + + command = sys.argv[1] + handlers = { + "reconcile": _run_reconciliation_pipeline, + "conflict": _run_conflict_resolution, + "non-overridable": _run_non_overridable_test, + "decisions": _run_decision_verification, + } + + handler = handlers.get(command) + if handler is None: + print(f"Unknown command: {command}") + sys.exit(1) + + result = handler() + print(json.dumps(result)) + print("reconciliation-ok") + + +if __name__ == "__main__": + main() diff --git a/robot/invariant_reconciliation_actor.robot b/robot/invariant_reconciliation_actor.robot new file mode 100644 index 000000000..e42905a67 --- /dev/null +++ b/robot/invariant_reconciliation_actor.robot @@ -0,0 +1,53 @@ +*** Settings *** +Documentation Integration tests for Invariant Reconciliation Actor +... Verifies reconciliation followed by actor execution respecting invariants +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_invariant_reconciliation.py + +*** Test Cases *** +Full Reconciliation Pipeline With Four Scopes + [Documentation] Reconcile invariants from global, project, action, and plan scopes + ${result}= Run Process ${PYTHON} ${HELPER} reconcile cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} reconciliation-ok + Should Contain ${result.stdout} "invariant_count": 4 + Should Contain ${result.stdout} "conflict_count": 0 + Should Contain ${result.stdout} "decision_count": 4 + +Conflict Resolution By Specificity + [Documentation] Plan-scope invariant overrides global on same text + ${result}= Run Process ${PYTHON} ${HELPER} conflict cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} reconciliation-ok + Should Contain ${result.stdout} "invariant_count": 1 + Should Contain ${result.stdout} "conflict_count": 1 + Should Contain ${result.stdout} "winner_scope": "plan" + +Non-Overridable Global Invariant Blocks Override + [Documentation] Non-overridable global invariant wins over plan + ${result}= Run Process ${PYTHON} ${HELPER} non-overridable cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} reconciliation-ok + Should Contain ${result.stdout} "winner_scope": "global" + Should Contain ${result.stdout} "has_non_overridable_reason": true + +Decision Recording Verification + [Documentation] All invariant_enforced decisions correctly recorded + ${result}= Run Process ${PYTHON} ${HELPER} decisions cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} reconciliation-ok + Should Contain ${result.stdout} "decision_count": 2 + Should Contain ${result.stdout} "all_correct_type": true + Should Contain ${result.stdout} "all_correct_plan": true diff --git a/src/cleveragents/actor/reconciliation.py b/src/cleveragents/actor/reconciliation.py new file mode 100644 index 000000000..5a7a283ae --- /dev/null +++ b/src/cleveragents/actor/reconciliation.py @@ -0,0 +1,447 @@ +"""Built-in Invariant Reconciliation Actor. + +Runs at the start of the Strategize phase to reconcile invariants from +four scopes: global, project, action, and plan. + +**Algorithm**: + +1. Collect invariants from all scopes. +2. Group by normalised text (case-insensitive, stripped). +3. Detect conflicts: same text present at different scopes with + contradictory intent, or different invariants that the grouping + cannot resolve automatically. +4. Resolve conflicts using specificity: plan > action > project > global. + Exception: ``non_overridable`` global invariants always win. +5. Record an ``invariant_enforced`` decision for each active invariant. +6. Return a reconciled ``InvariantSet``. + +Based on specification §19440-19600 and issue #549. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import structlog + +from cleveragents.domain.models.core.decision import DecisionType +from cleveragents.domain.models.core.invariant import ( + Invariant, + InvariantScope, + InvariantSet, +) + +if TYPE_CHECKING: + from cleveragents.application.services.decision_service import DecisionService + from cleveragents.application.services.invariant_service import InvariantService + +logger = structlog.get_logger(__name__) + +# Precedence order (index 0 = highest) +_SCOPE_PRECEDENCE: dict[InvariantScope, int] = { + InvariantScope.PLAN: 0, + InvariantScope.ACTION: 1, + InvariantScope.PROJECT: 2, + InvariantScope.GLOBAL: 3, +} + + +@dataclass(frozen=True) +class ConflictRecord: + """A detected conflict between invariants from different scopes. + + Attributes: + key: Normalised invariant text used for grouping. + winner: The invariant that prevailed after resolution. + losers: Invariants that were overridden. + reason: Human-readable explanation of the resolution. + """ + + key: str + winner: Invariant + losers: list[Invariant] + reason: str + + +@dataclass(frozen=True) +class ReconciliationResult: + """Output of the reconciliation process. + + Attributes: + reconciled_set: The final effective ``InvariantSet``. + conflicts: All detected conflicts with resolution details. + enforced_decision_ids: ULIDs of ``invariant_enforced`` decisions. + """ + + reconciled_set: InvariantSet + conflicts: list[ConflictRecord] + enforced_decision_ids: list[str] + + +@dataclass(frozen=False) +class ScopeInvariants: + """Invariants collected from each scope tier. + + Convenience container passed to the reconciliation algorithm. + """ + + global_invariants: list[Invariant] = field(default_factory=list) + project_invariants: list[Invariant] = field(default_factory=list) + action_invariants: list[Invariant] = field(default_factory=list) + plan_invariants: list[Invariant] = field(default_factory=list) + + def all_invariants(self) -> list[Invariant]: + """Return a flat list of all invariants across scopes.""" + return ( + self.global_invariants + + self.project_invariants + + self.action_invariants + + self.plan_invariants + ) + + +def _normalise_key(text: str) -> str: + """Return a case-insensitive, whitespace-stripped key for grouping.""" + if not text: + return "" + return text.strip().lower() + + +def _scope_rank(scope: InvariantScope) -> int: + """Return the precedence rank for a scope (lower = higher priority).""" + return _SCOPE_PRECEDENCE.get(scope, 99) + + +def _resolve_group( + invariants: list[Invariant], +) -> tuple[Invariant, list[ConflictRecord]]: + """Resolve a group of invariants sharing the same normalised text. + + Args: + invariants: All invariants with the same normalised text. + + Returns: + A tuple of (winner, conflict_records). + + Raises: + ValueError: If *invariants* is empty. + """ + if not invariants: + raise ValueError("Cannot resolve an empty invariant group") + + if len(invariants) == 1: + return invariants[0], [] + + key = _normalise_key(invariants[0].text) + + # Check for non_overridable global invariants first + non_overridable_globals = [ + inv + for inv in invariants + if inv.scope == InvariantScope.GLOBAL and inv.non_overridable + ] + + if non_overridable_globals: + winner = non_overridable_globals[0] + losers = [inv for inv in invariants if inv is not winner] + conflict = ConflictRecord( + key=key, + winner=winner, + losers=losers, + reason=( + f"Global invariant is non_overridable; " + f"overrides {len(losers)} lower-scope variant(s)" + ), + ) + return winner, [conflict] + + # Standard specificity resolution: highest precedence wins + sorted_invs = sorted(invariants, key=lambda inv: _scope_rank(inv.scope)) + winner = sorted_invs[0] + losers = sorted_invs[1:] + + conflicts: list[ConflictRecord] = [] + if losers: + conflicts.append( + ConflictRecord( + key=key, + winner=winner, + losers=losers, + reason=( + f"{winner.scope.value} scope overrides " + f"{', '.join(inv.scope.value for inv in losers)} scope(s)" + ), + ) + ) + + return winner, conflicts + + +def reconcile_invariants( + scope_invariants: ScopeInvariants, +) -> tuple[list[Invariant], list[ConflictRecord]]: + """Run the reconciliation algorithm on collected invariants. + + Groups invariants by normalised text, resolves each group, and + returns the winners along with any detected conflicts. + + Args: + scope_invariants: Invariants from all four scopes. + + Returns: + Tuple of (reconciled_invariants, conflict_records). + """ + all_invs = [inv for inv in scope_invariants.all_invariants() if inv.active] + + # Group by normalised text + groups: dict[str, list[Invariant]] = {} + for inv in all_invs: + key = _normalise_key(inv.text) + groups.setdefault(key, []).append(inv) + + reconciled: list[Invariant] = [] + all_conflicts: list[ConflictRecord] = [] + + for _key, group in groups.items(): + winner, conflicts = _resolve_group(group) + reconciled.append(winner) + all_conflicts.extend(conflicts) + + return reconciled, all_conflicts + + +class InvariantReconciliationActor: + """Built-in actor that reconciles invariants at Strategize start. + + Collects invariants from global, project, action, and plan scopes, + detects and resolves conflicts using specificity-based precedence, + records ``invariant_enforced`` decisions, and produces a reconciled + ``InvariantSet`` for downstream actors. + + Usage:: + + actor = InvariantReconciliationActor( + invariant_service=inv_svc, + decision_service=dec_svc, + ) + result = actor.run(plan_id="...", project_name="...", action_name="...") + """ + + #: Actor name used for registration / lookup. + ACTOR_NAME: str = "builtin/invariant-reconciliation" + + def __init__( + self, + *, + invariant_service: InvariantService, + decision_service: DecisionService, + ) -> None: + if invariant_service is None: + raise ValueError("invariant_service must not be None") + if decision_service is None: + raise ValueError("decision_service must not be None") + self._invariant_service = invariant_service + self._decision_service = decision_service + self._logger = logger.bind(actor=self.ACTOR_NAME) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def collect_invariants( + self, + *, + plan_id: str | None = None, + project_name: str | None = None, + action_name: str | None = None, + ) -> ScopeInvariants: + """Collect invariants from all four scopes. + + Args: + plan_id: Optional plan identifier. + project_name: Optional project name. + action_name: Optional action name. + + Returns: + ``ScopeInvariants`` with invariants grouped by scope. + """ + svc = self._invariant_service + return ScopeInvariants( + global_invariants=svc.list_invariants(scope=InvariantScope.GLOBAL), + project_invariants=( + svc.list_invariants( + scope=InvariantScope.PROJECT, source_name=project_name + ) + if project_name + else [] + ), + action_invariants=( + svc.list_invariants( + scope=InvariantScope.ACTION, source_name=action_name + ) + if action_name + else [] + ), + plan_invariants=( + svc.list_invariants(scope=InvariantScope.PLAN, source_name=plan_id) + if plan_id + else [] + ), + ) + + def run( + self, + *, + plan_id: str, + project_name: str | None = None, + action_name: str | None = None, + parent_decision_id: str | None = None, + ) -> ReconciliationResult: + """Execute the full reconciliation lifecycle. + + 1. Collect invariants from all scopes. + 2. Reconcile conflicts. + 3. Record ``invariant_enforced`` decisions. + 4. Return the result. + + Args: + plan_id: ULID of the plan entering Strategize. + project_name: Optional project name for scoping. + action_name: Optional action name for scoping. + parent_decision_id: Optional parent decision for tree wiring. + + Returns: + A ``ReconciliationResult`` with the reconciled set, conflicts, + and decision IDs. + + Raises: + ValueError: If *plan_id* is empty or blank. + """ + if not plan_id or not plan_id.strip(): + raise ValueError("plan_id must not be empty") + + self._logger.info( + "reconciliation.start", + plan_id=plan_id, + project_name=project_name, + action_name=action_name, + ) + + # Step 1: Collect + scope_invs = self.collect_invariants( + plan_id=plan_id, + project_name=project_name, + action_name=action_name, + ) + + total = len(scope_invs.all_invariants()) + self._logger.info( + "reconciliation.collected", + plan_id=plan_id, + total_invariants=total, + global_count=len(scope_invs.global_invariants), + project_count=len(scope_invs.project_invariants), + action_count=len(scope_invs.action_invariants), + plan_count=len(scope_invs.plan_invariants), + ) + + # Step 2: Reconcile + reconciled, conflicts = reconcile_invariants(scope_invs) + + if conflicts: + self._logger.info( + "reconciliation.conflicts_detected", + plan_id=plan_id, + conflict_count=len(conflicts), + ) + for conflict in conflicts: + self._logger.info( + "reconciliation.conflict_resolved", + plan_id=plan_id, + key=conflict.key, + winner_scope=conflict.winner.scope.value, + reason=conflict.reason, + ) + + # Step 3: Record decisions + decision_ids = self._record_enforcement_decisions( + plan_id=plan_id, + invariants=reconciled, + parent_decision_id=parent_decision_id, + ) + + # Step 4: Build result + reconciled_set = InvariantSet(invariants=reconciled) + + self._logger.info( + "reconciliation.complete", + plan_id=plan_id, + effective_count=len(reconciled), + conflict_count=len(conflicts), + decision_count=len(decision_ids), + ) + + return ReconciliationResult( + reconciled_set=reconciled_set, + conflicts=conflicts, + enforced_decision_ids=decision_ids, + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _record_enforcement_decisions( + self, + *, + plan_id: str, + invariants: list[Invariant], + parent_decision_id: str | None, + ) -> list[str]: + """Record an ``invariant_enforced`` decision per invariant. + + Args: + plan_id: The plan ULID. + invariants: Reconciled invariants to enforce. + parent_decision_id: Optional parent decision for tree wiring. + + Returns: + List of decision ULIDs created. + """ + decision_ids: list[str] = [] + for inv in invariants: + decision = self._decision_service.record_decision( + plan_id=plan_id, + decision_type=DecisionType.INVARIANT_ENFORCED, + question=( + f"Should invariant be enforced? [{inv.scope.value}] {inv.text}" + ), + chosen_option=f"Enforce: {inv.text}", + parent_decision_id=parent_decision_id, + rationale=( + f"Invariant from {inv.scope.value} scope " + f"(source: {inv.source_name}) enforced during " + f"reconciliation" + ), + confidence_score=1.0, + ) + decision_ids.append(decision.decision_id) + + # Also record enforcement in the invariant service + self._invariant_service.enforce_invariants( + plan_id=plan_id, + invariants=invariants, + actor_response="Reconciliation complete", + ) + + return decision_ids + + +__all__ = [ + "ConflictRecord", + "InvariantReconciliationActor", + "ReconciliationResult", + "ScopeInvariants", + "reconcile_invariants", +] diff --git a/src/cleveragents/domain/models/core/invariant.py b/src/cleveragents/domain/models/core/invariant.py index 41d4ed25f..51124eb05 100644 --- a/src/cleveragents/domain/models/core/invariant.py +++ b/src/cleveragents/domain/models/core/invariant.py @@ -83,6 +83,13 @@ class Invariant(BaseModel): default=True, description="Whether this invariant is active (False = soft-deleted)", ) + non_overridable: bool = Field( + default=False, + description=( + "When True and scope is GLOBAL, this invariant cannot be " + "overridden by lower-scope invariants during reconciliation" + ), + ) @field_validator("text") @classmethod diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 886023d08..00418ec16 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -729,3 +729,10 @@ EscalationDecision # noqa: B018, F821 HistoricalOutcome # noqa: B018, F821 OperationContext # noqa: B018, F821 autonomy_controller # noqa: B018, F821 + +# Invariant Reconciliation Actor — public API (M6, issue #549) +InvariantReconciliationActor # noqa: B018, F821 +ReconciliationResult # noqa: B018, F821 +ConflictRecord # noqa: B018, F821 +ScopeInvariants # noqa: B018, F821 +reconcile_invariants # noqa: B018, F821