diff --git a/benchmarks/invariant_merge_bench.py b/benchmarks/invariant_merge_bench.py index d68db24cf..3c6c1aa41 100644 --- a/benchmarks/invariant_merge_bench.py +++ b/benchmarks/invariant_merge_bench.py @@ -69,12 +69,13 @@ class MergeSmallSuite: def setup(self) -> None: """Create small invariant lists.""" self.plan = _make_invariants(InvariantScope.PLAN, 3) + self.action = _make_invariants(InvariantScope.ACTION, 2) self.project = _make_invariants(InvariantScope.PROJECT, 5) self.global_invs = _make_invariants(InvariantScope.GLOBAL, 5) def time_merge_small(self) -> None: - """Benchmark merge with ~13 invariants.""" - merge_invariants(self.plan, self.project, self.global_invs) + """Benchmark merge with ~15 invariants.""" + merge_invariants(self.plan, self.action, self.project, self.global_invs) class MergeMediumSuite: @@ -83,12 +84,13 @@ class MergeMediumSuite: def setup(self) -> None: """Create medium invariant lists.""" self.plan = _make_invariants(InvariantScope.PLAN, 10) + self.action = _make_invariants(InvariantScope.ACTION, 10) self.project = _make_invariants(InvariantScope.PROJECT, 20) self.global_invs = _make_invariants(InvariantScope.GLOBAL, 20) def time_merge_medium(self) -> None: - """Benchmark merge with ~50 invariants.""" - merge_invariants(self.plan, self.project, self.global_invs) + """Benchmark merge with ~60 invariants.""" + merge_invariants(self.plan, self.action, self.project, self.global_invs) class MergeLargeSuite: @@ -97,12 +99,13 @@ class MergeLargeSuite: def setup(self) -> None: """Create large invariant lists.""" self.plan = _make_invariants(InvariantScope.PLAN, 50) + self.action = _make_invariants(InvariantScope.ACTION, 50) self.project = _make_invariants(InvariantScope.PROJECT, 100) self.global_invs = _make_invariants(InvariantScope.GLOBAL, 100) def time_merge_large(self) -> None: - """Benchmark merge with ~250 invariants.""" - merge_invariants(self.plan, self.project, self.global_invs) + """Benchmark merge with ~300 invariants.""" + merge_invariants(self.plan, self.action, self.project, self.global_invs) class MergeDeduplicationSuite: @@ -118,6 +121,14 @@ class MergeDeduplicationSuite: ) for i in range(20) ] + self.action = [ + Invariant( + text=f"Shared constraint {i}", + scope=InvariantScope.ACTION, + source_name="action-001", + ) + for i in range(20) + ] self.project = [ Invariant( text=f"Shared constraint {i}", @@ -136,8 +147,8 @@ class MergeDeduplicationSuite: ] def time_merge_dedup(self) -> None: - """Benchmark merge with 60 invariants, all duplicates.""" - merge_invariants(self.plan, self.project, self.global_invs) + """Benchmark merge with 80 invariants, all duplicates.""" + merge_invariants(self.plan, self.action, self.project, self.global_invs) class InvariantSetMergeSuite: @@ -146,12 +157,13 @@ class InvariantSetMergeSuite: def setup(self) -> None: """Create invariant lists.""" self.plan = _make_invariants(InvariantScope.PLAN, 5) + self.action = _make_invariants(InvariantScope.ACTION, 5) self.project = _make_invariants(InvariantScope.PROJECT, 10) self.global_invs = _make_invariants(InvariantScope.GLOBAL, 10) def time_invariant_set_merge(self) -> None: """Benchmark InvariantSet.merge().""" - InvariantSet.merge(self.plan, self.project, self.global_invs) + InvariantSet.merge(self.plan, self.action, self.project, self.global_invs) class ServiceEffectiveSuite: @@ -164,9 +176,15 @@ class ServiceEffectiveSuite: self.service.add_invariant(f"Global {i}", InvariantScope.GLOBAL, "system") for i in range(10): self.service.add_invariant(f"Project {i}", InvariantScope.PROJECT, "myapp") + for i in range(5): + self.service.add_invariant(f"Action {i}", InvariantScope.ACTION, "my-action") for i in range(5): self.service.add_invariant(f"Plan {i}", InvariantScope.PLAN, "plan-001") def time_get_effective(self) -> None: """Benchmark get_effective_invariants().""" - self.service.get_effective_invariants(plan_id="plan-001", project_name="myapp") + self.service.get_effective_invariants( + plan_id="plan-001", + project_name="myapp", + action_name="my-action", + ) diff --git a/features/consolidated_domain_models.feature b/features/consolidated_domain_models.feature index 36bfb0d9e..d185b84cd 100644 --- a/features/consolidated_domain_models.feature +++ b/features/consolidated_domain_models.feature @@ -418,13 +418,16 @@ Feature: Consolidated Domain Models Scenario: InvariantScope has four values Then InvariantScope should have values "global, project, action, plan" - # === Merge Precedence (plan > project > global) === + # === Merge Precedence (plan > action > project > global) === Scenario: Merge with no duplicates preserves all invariants Given I have plan invariants | text | source | | Plan constraint | plan1 | + And I have action invariants + | text | source | + | Action constraint | action1 | And I have project invariants | text | source | | Project constraint | proj1 | @@ -432,16 +435,19 @@ Feature: Consolidated Domain Models | text | source | | Global constraint | system | When I merge the invariants - Then the merged set should have 3 invariants + Then the merged set should have 4 invariants And the merged invariant at index 0 should have text "Plan constraint" - And the merged invariant at index 1 should have text "Project constraint" - And the merged invariant at index 2 should have text "Global constraint" + And the merged invariant at index 1 should have text "Action constraint" + And the merged invariant at index 2 should have text "Project constraint" + And the merged invariant at index 3 should have text "Global constraint" Scenario: Plan invariant overrides project invariant with same text Given I have plan invariants | text | source | | Log all changes | plan1 | + And I have action invariants + | text | source | And I have project invariants | text | source | | Log all changes | proj1 | @@ -455,6 +461,8 @@ Feature: Consolidated Domain Models Scenario: Project invariant overrides global invariant with same text Given I have plan invariants | text | source | + And I have action invariants + | text | source | And I have project invariants | text | source | | Log all changes | proj1 | @@ -470,6 +478,8 @@ Feature: Consolidated Domain Models Given I have plan invariants | text | source | | LOG ALL CHANGES | plan1 | + And I have action invariants + | text | source | And I have global invariants | text | source | | log all changes | system | @@ -482,6 +492,8 @@ Feature: Consolidated Domain Models Scenario: Inactive invariants are excluded from merge Given I have plan invariants with an inactive entry + And I have action invariants + | text | source | And I have project invariants | text | source | And I have global invariants @@ -494,8 +506,11 @@ Feature: Consolidated Domain Models Scenario: InvariantSet.merge produces correct result Given I have plan invariants - | text | source | - | Plan rule | plan1 | + | text | source | + | Plan rule | plan1 | + And I have action invariants + | text | source | + | Action rule | action1 | And I have project invariants | text | source | | Project rule | proj1 | @@ -503,7 +518,57 @@ Feature: Consolidated Domain Models | text | source | | Global rule | system | When I merge using InvariantSet - Then the invariant set should have 3 invariants + Then the invariant set should have 4 invariants + + Scenario: Action invariant overrides project invariant with same text + Given I have plan invariants + | text | source | + And I have action invariants + | text | source | + | Log all changes | action1 | + And I have project invariants + | text | source | + | Log all changes | proj1 | + And I have global invariants + | text | source | + When I merge the invariants + Then the merged set should have 1 invariants + And the merged invariant at index 0 should have scope "action" + + Scenario: Action invariant overrides global invariant with same text + Given I have plan invariants + | text | source | + And I have action invariants + | text | source | + | Log all changes | action1 | + And I have project invariants + | text | source | + And I have global invariants + | text | source | + | Log all changes | system | + When I merge the invariants + Then the merged set should have 1 invariants + And the merged invariant at index 0 should have scope "action" + + Scenario: Four-tier merge preserves correct precedence order + Given I have plan invariants + | text | source | + | Plan only | plan1 | + And I have action invariants + | text | source | + | Action only | action1 | + And I have project invariants + | text | source | + | Project only | proj1 | + And I have global invariants + | text | source | + | Global only | system | + When I merge the invariants + Then the merged set should have 4 invariants + And the merged invariant at index 0 should have scope "plan" + And the merged invariant at index 1 should have scope "action" + And the merged invariant at index 2 should have scope "project" + And the merged invariant at index 3 should have scope "global" # === Service: Add/List/Remove === diff --git a/features/steps/invariant_models_steps.py b/features/steps/invariant_models_steps.py index 5b7befa42..9a7c5f14d 100644 --- a/features/steps/invariant_models_steps.py +++ b/features/steps/invariant_models_steps.py @@ -139,6 +139,11 @@ def step_project_invariants(context): context.project_invariants = _parse_invariant_table(context, InvariantScope.PROJECT) +@given("I have action invariants") +def step_action_invariants(context): + context.action_invariants = _parse_invariant_table(context, InvariantScope.ACTION) + + @given("I have global invariants") def step_global_invariants(context): context.global_invariants = _parse_invariant_table(context, InvariantScope.GLOBAL) @@ -159,6 +164,7 @@ def step_plan_invariants_inactive(context): def step_merge(context): context.merged = merge_invariants( getattr(context, "plan_invariants", []), + getattr(context, "action_invariants", []), getattr(context, "project_invariants", []), getattr(context, "global_invariants", []), ) @@ -188,6 +194,7 @@ def step_merged_scope(context, idx, scope): def step_merge_invariant_set(context): inv_set = InvariantSet.merge( getattr(context, "plan_invariants", []), + getattr(context, "action_invariants", []), getattr(context, "project_invariants", []), getattr(context, "global_invariants", []), ) diff --git a/robot/helper_m3_e2e_verification.py b/robot/helper_m3_e2e_verification.py index bfd06a911..decad147d 100644 --- a/robot/helper_m3_e2e_verification.py +++ b/robot/helper_m3_e2e_verification.py @@ -858,20 +858,29 @@ def invariants_enforced_during_strategize() -> None: source_name=_plan_ulid, ) + action_inv = service.add_invariant( + text="Minimum 80% test coverage", + scope=InvariantScope.ACTION, + source_name="local/code-coverage", + ) + effective = service.get_effective_invariants( plan_id=_plan_ulid, project_name=_PROJECT_NAME, + action_name="local/code-coverage", ) - if len(effective) != 3: - _fail(f"expected 3 effective invariants, got {len(effective)}") + if len(effective) != 4: + _fail(f"expected 4 effective invariants, got {len(effective)}") merged = merge_invariants( plan_invariants=[plan_inv], + action_invariants=[action_inv], project_invariants=[project_inv], global_invariants=[global_inv], ) if [inv.text for inv in merged] != [ plan_inv.text, + action_inv.text, project_inv.text, global_inv.text, ]: @@ -882,20 +891,21 @@ def invariants_enforced_during_strategize() -> None: invariants=effective, actor_response="All constraints acknowledged", ) - if len(records) != 3: - _fail(f"expected 3 enforcement records, got {len(records)}") + if len(records) != 4: + _fail(f"expected 4 enforcement records, got {len(records)}") for record in records: if not record.decision_id: _fail(f"enforcement record missing decision_id: {record}") invariant_set = InvariantSet.merge( plan_invariants=[plan_inv], + action_invariants=[action_inv], project_invariants=[project_inv], global_invariants=[global_inv], ) - if len(invariant_set.invariants) != 3: + if len(invariant_set.invariants) != 4: _fail( - "InvariantSet.merge should preserve all three precedence tiers " + "InvariantSet.merge should preserve all four precedence tiers " f"for this input, got {len(invariant_set.invariants)}" ) diff --git a/src/cleveragents/application/services/invariant_service.py b/src/cleveragents/application/services/invariant_service.py index 913e77276..28b52112c 100644 --- a/src/cleveragents/application/services/invariant_service.py +++ b/src/cleveragents/application/services/invariant_service.py @@ -11,7 +11,7 @@ a dict keyed by invariant ID. ## Merge Precedence -Effective invariants are computed using plan > project > global order. +Effective invariants are computed using plan > action > project > global order. See ``merge_invariants`` for de-duplication semantics. Based on ``docs/specification.md`` and implementation plan Stage M3.5. @@ -166,17 +166,20 @@ class InvariantService: self, plan_id: str | None = None, project_name: str | None = None, + action_name: str | None = None, ) -> list[Invariant]: - """Return the merged precedence chain for a plan/project context. + """Return the merged precedence chain for a plan/project/action context. Collects active invariants from each scope tier and merges them - using plan > project > global precedence. + using plan > action > project > global precedence. Args: plan_id: Optional plan identifier to collect plan-scoped invariants. project_name: Optional project name to collect project-scoped invariants. + action_name: Optional action name to collect action-scoped + invariants. Returns: Merged, de-duplicated list of effective invariants. @@ -189,6 +192,12 @@ class InvariantService: if inv.scope == InvariantScope.PLAN and (plan_id is None or inv.source_name == plan_id) ] + action_invs = [ + inv + for inv in active + if inv.scope == InvariantScope.ACTION + and (action_name is None or inv.source_name == action_name) + ] project_invs = [ inv for inv in active @@ -197,7 +206,7 @@ class InvariantService: ] global_invs = [inv for inv in active if inv.scope == InvariantScope.GLOBAL] - return merge_invariants(plan_invs, project_invs, global_invs) + return merge_invariants(plan_invs, action_invs, project_invs, global_invs) def enforce_invariants( self, diff --git a/src/cleveragents/domain/models/core/invariant.py b/src/cleveragents/domain/models/core/invariant.py index 51124eb05..47f48fd29 100644 --- a/src/cleveragents/domain/models/core/invariant.py +++ b/src/cleveragents/domain/models/core/invariant.py @@ -1,10 +1,9 @@ """Invariant domain models for CleverAgents. Invariants are natural-language constraints on plan execution, scoped at -global, project, action, or plan level. When an action is used, its -invariants are promoted to plan-level. The runtime precedence chain is: +global, project, action, or plan level. The runtime precedence chain is: - plan > project > global + plan > action > project > global They are reconciled by the Invariant Reconciliation Actor at the start of Strategize and recorded as ``invariant_enforced`` decisions. @@ -15,16 +14,16 @@ of Strategize and recorded as ``invariant_enforced`` decisions. |------------|----------------------------------------------------| | ``GLOBAL`` | Applies to every plan in the system | | ``PROJECT``| Applies to plans targeting a specific project | -| ``ACTION`` | Defined in an action template; promoted on ``use`` | +| ``ACTION`` | Defined in an action template | | ``PLAN`` | Attached directly to a specific plan | ## Merge Precedence When computing the effective set of invariants for a plan, the merge -order is **plan > project > global**. Duplicate texts (case-insensitive) -are de-duplicated, keeping the highest-precedence copy. +order is **plan > action > project > global**. Duplicate texts +(case-insensitive) are de-duplicated, keeping the highest-precedence copy. -Based on ``docs/specification.md`` and implementation plan Stage M3.5. +Based on ``docs/specification.md`` (ADR-016) and implementation plan Stage M3.5. """ from __future__ import annotations @@ -39,8 +38,7 @@ from ulid import ULID class InvariantScope(StrEnum): """Scope at which an invariant applies. - Precedence (highest to lowest): PLAN > PROJECT > GLOBAL. - ACTION invariants are promoted to PLAN scope at ``plan use`` time. + Precedence (highest to lowest): PLAN > ACTION > PROJECT > GLOBAL. """ GLOBAL = "global" @@ -131,10 +129,11 @@ class InvariantSet(BaseModel): def merge( cls, plan_invariants: list[Invariant], + action_invariants: list[Invariant], project_invariants: list[Invariant], global_invariants: list[Invariant], ) -> InvariantSet: - """Merge invariants respecting plan > project > global precedence. + """Merge invariants respecting plan > action > project > global precedence. De-duplicates by text (case-insensitive), keeping the copy from the highest-precedence tier. Within each tier, source ordering @@ -142,7 +141,8 @@ class InvariantSet(BaseModel): Args: plan_invariants: Plan-level invariants (highest precedence). - project_invariants: Project-level invariants. + action_invariants: Action-level invariants (second precedence). + project_invariants: Project-level invariants (third precedence). global_invariants: Global-level invariants (lowest precedence). Returns: @@ -150,7 +150,10 @@ class InvariantSet(BaseModel): """ return cls( invariants=merge_invariants( - plan_invariants, project_invariants, global_invariants + plan_invariants, + action_invariants, + project_invariants, + global_invariants, ) ) @@ -159,10 +162,11 @@ class InvariantSet(BaseModel): def merge_invariants( plan_invariants: list[Invariant], + action_invariants: list[Invariant], project_invariants: list[Invariant], global_invariants: list[Invariant], ) -> list[Invariant]: - """Merge invariants implementing plan > project > global precedence. + """Merge invariants implementing plan > action > project > global precedence. De-duplicates by text (case-insensitive). The first occurrence (from the highest-precedence tier) wins. Within each tier, the @@ -170,7 +174,8 @@ def merge_invariants( Args: plan_invariants: Plan-level invariants (highest precedence). - project_invariants: Project-level invariants. + action_invariants: Action-level invariants (second precedence). + project_invariants: Project-level invariants (third precedence). global_invariants: Global-level invariants (lowest precedence). Returns: @@ -179,7 +184,12 @@ def merge_invariants( seen: set[str] = set() result: list[Invariant] = [] - for inv_list in (plan_invariants, project_invariants, global_invariants): + for inv_list in ( + plan_invariants, + action_invariants, + project_invariants, + global_invariants, + ): for inv in inv_list: if not inv.active: continue