fix(invariant): add missing action-scope parameter to InvariantSet.merge() for 4-tier precedence #3269
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
@@ -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", []),
|
||||
)
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user
[BUG / API-CONSISTENCY] This
effective=Truecode path callsget_effective_invariants()but does not forward the newaction_nameparameter.When a caller invokes
list_invariants(scope=InvariantScope.ACTION, source_name="my-action", effective=True), the action scope is silently ignored — the exact same class of bug this PR is fixing.Required fix — add the missing line:
Also add a BDD scenario to cover this path.