feat(actor): implement built-in invariant reconciliation actor
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 32s
CI / security (pull_request) Successful in 30s
CI / quality (pull_request) Successful in 18s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 16s
CI / unit_tests (pull_request) Successful in 1m59s
CI / integration_tests (pull_request) Successful in 3m47s
CI / docker (pull_request) Successful in 47s
CI / coverage (pull_request) Successful in 5m48s
CI / lint (push) Successful in 13s
CI / typecheck (push) Successful in 35s
CI / security (push) Successful in 34s
CI / quality (push) Successful in 19s
CI / build (push) Successful in 17s
CI / unit_tests (push) Successful in 2m27s
CI / integration_tests (push) Successful in 2m59s
CI / benchmark-regression (push) Has been skipped
CI / coverage (push) Successful in 4m9s
CI / benchmark-publish (push) Successful in 16m43s
CI / docker (push) Successful in 39s
CI / benchmark-regression (pull_request) Successful in 30m2s
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 32s
CI / security (pull_request) Successful in 30s
CI / quality (pull_request) Successful in 18s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 16s
CI / unit_tests (pull_request) Successful in 1m59s
CI / integration_tests (pull_request) Successful in 3m47s
CI / docker (pull_request) Successful in 47s
CI / coverage (pull_request) Successful in 5m48s
CI / lint (push) Successful in 13s
CI / typecheck (push) Successful in 35s
CI / security (push) Successful in 34s
CI / quality (push) Successful in 19s
CI / build (push) Successful in 17s
CI / unit_tests (push) Successful in 2m27s
CI / integration_tests (push) Successful in 2m59s
CI / benchmark-regression (push) Has been skipped
CI / coverage (push) Successful in 4m9s
CI / benchmark-publish (push) Successful in 16m43s
CI / docker (push) Successful in 39s
CI / benchmark-regression (pull_request) Successful in 30m2s
Add InvariantReconciliationActor that runs at the start of the Strategize phase to reconcile invariants from four scopes (global, project, action, plan). The actor detects conflicts, resolves them using specificity-based precedence (plan > action > project > global), honours non_overridable global invariants, records invariant_enforced decisions, and produces a reconciled InvariantSet. Changes: - New: src/cleveragents/actor/reconciliation.py - InvariantReconciliationActor class with collect_invariants() and run() - reconcile_invariants() pure function - ScopeInvariants, ConflictRecord, ReconciliationResult dataclasses - Modified: src/cleveragents/domain/models/core/invariant.py - Added non_overridable: bool field to Invariant model - New: features/invariant_reconciliation_actor.feature (26 BDD scenarios) - New: features/steps/invariant_reconciliation_actor_steps.py - New: robot/invariant_reconciliation_actor.robot - New: robot/helper_invariant_reconciliation.py - New: benchmarks/invariant_reconciliation_bench.py Closes #549
This commit was merged in pull request #561.
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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 <command>")
|
||||
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()
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user