forked from HAL9000/cleveragents-core
abd4c6de49
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
424 lines
17 KiB
Python
424 lines
17 KiB
Python
"""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}"
|