fix(invariant): restore ACTION scope in merge_invariants and InvariantSet.merge precedence chain #11143

Merged
HAL9000 merged 4 commits from bugfix/m3-fix-action-scope-invariant-merge into master 2026-05-16 04:14:35 +00:00
8 changed files with 265 additions and 30 deletions
+2 -1
View File
@@ -186,7 +186,8 @@ Changed `wf10_batch.robot` to be less likely to create files, and
were cleaned up in `features/actor_add_update_enforcement.feature` so the
tests report correctly now that the underlying bug has been fixed.
- **Resolved Behave AmbiguousStep collisions in step definitions** (#4186): Renamed
- **Fixed `merge_invariants()` missing ACTION scope — 4-tier precedence restored** (#9126): Updated ``merge_invariants()`` and ``InvariantSet.merge()`` to accept a fourth parameter ``action_invariants`` alongside plan, project, and global tiers. The module docstring, ``InvariantScope`` docstring, and ``InvariantService.get_effective_invariants()`` now all reflect the correct precedence chain: ``plan > action > project > global``. Added ``action_name`` parameter to ``get_effective_invariants()`` so action-scoped invariants are collected and passed through the merge pipeline instead of silently dropped. All docstrings across both files were corrected from ``plan > project > global`` to ``plan > action > project > global``. Comprehensive Behave scenarios added covering four-tier merge precedence, action-before-project ordering, action override of project with same text, and effective invariant computation with all four scopes.
step texts to avoid case-sensitive collisions between different step modules that
prevented all Behave tests from loading. Renamed steps in
`edge_case_plan_steps.py`, `plan_executor_coverage_boost_steps.py`,
+1 -1
View File
@@ -15,7 +15,7 @@
Below are some of the specific details of various contributions.
* Jeffrey Phillips Freeman has acted as Lead Developer, daily contributor, and Project Owner.
* Jeffrey Phillips Freeman has contributed an implementation for the invariant propagation fix (PR #10881 / issue #9131): added `_propagate_invariant_decisions()` to `SubplanService` to propagate all `invariant_enforced` decisions from parent plans to child plan decision trees during subplan spawn, satisfying the specification requirement for invariant propagation across hierarchical plan execution.
* Jeffrey Phillips Freeman has contributed the invariant merge precedence fix (#9126): restored the missing ACTION scope in ``merge_invariants()`` and ``InvariantSet.merge()``, corrected all module docstrings from ``plan > project > global`` to the spec-compliant ``plan > action > project > global``, and added comprehensive BDD test coverage for four-tier merge precedence.
* Brent E. Edwards has contributed quality assurance, test coverage, and CI pipeline improvements.
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
* HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution.
+5 -5
View File
@@ -74,7 +74,7 @@ class MergeSmallSuite:
def time_merge_small(self) -> None:
"""Benchmark merge with ~13 invariants."""
merge_invariants(self.plan, self.project, self.global_invs)
merge_invariants(self.plan, [], self.project, self.global_invs)
class MergeMediumSuite:
@@ -88,7 +88,7 @@ class MergeMediumSuite:
def time_merge_medium(self) -> None:
"""Benchmark merge with ~50 invariants."""
merge_invariants(self.plan, self.project, self.global_invs)
merge_invariants(self.plan, [], self.project, self.global_invs)
class MergeLargeSuite:
@@ -102,7 +102,7 @@ class MergeLargeSuite:
def time_merge_large(self) -> None:
"""Benchmark merge with ~250 invariants."""
merge_invariants(self.plan, self.project, self.global_invs)
merge_invariants(self.plan, [], self.project, self.global_invs)
class MergeDeduplicationSuite:
@@ -137,7 +137,7 @@ class MergeDeduplicationSuite:
def time_merge_dedup(self) -> None:
"""Benchmark merge with 60 invariants, all duplicates."""
merge_invariants(self.plan, self.project, self.global_invs)
merge_invariants(self.plan, [], self.project, self.global_invs)
class InvariantSetMergeSuite:
@@ -151,7 +151,7 @@ class InvariantSetMergeSuite:
def time_invariant_set_merge(self) -> None:
"""Benchmark InvariantSet.merge()."""
InvariantSet.merge(self.plan, self.project, self.global_invs)
InvariantSet.merge(self.plan, [], self.project, self.global_invs)
class ServiceEffectiveSuite:
+100 -3
View File
@@ -418,7 +418,7 @@ 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
@@ -480,6 +480,75 @@ Feature: Consolidated Domain Models
And the merged invariant at index 0 should have text "LOG ALL CHANGES"
Scenario: Merge with all four scopes preserves action tier — Issue #9126
Given I have plan invariants
| 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 |
And I have global invariants
| text | source |
| Global rule | system |
When I merge the invariants
Then the merged set should have 4 invariants
And plan invariants appear before action invariants in merge
And action invariants appear before project invariants in merge
And the merged invariant at index 0 should have text "Plan rule"
And the merged invariant at index 1 should have text "Action rule"
And the merged invariant at index 2 should have text "Project rule"
And the merged invariant at index 3 should have text "Global rule"
Scenario: Action invariant overrides project 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 is included in de-duplication with plan override
Given I have plan invariants
| text | source |
| Log all changes | plan1 |
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 |
When I merge the invariants
Then the merged set should have 1 invariants
And the merged invariant at index 0 should have scope "plan"
Scenario: Inactive action invariants are excluded from merge
Given I have plan invariants with an inactive entry
And I have action invariants
| text | source |
| Active | action1 |
And I have project invariants
| text | source |
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: Inactive invariants are excluded from merge
Given I have plan invariants with an inactive entry
And I have project invariants
@@ -492,7 +561,7 @@ Feature: Consolidated Domain Models
# === InvariantSet merge class method ===
Scenario: InvariantSet.merge produces correct result
Scenario: InvariantSet.merge produces correct result
Given I have plan invariants
| text | source |
| Plan rule | plan1 |
@@ -505,7 +574,25 @@ Feature: Consolidated Domain Models
When I merge using InvariantSet
Then the invariant set should have 3 invariants
# === Service: Add/List/Remove ===
Scenario: InvariantSet.merge with four-tier precedence — Issue #9126
Given I have plan invariants
| 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 |
And I have global invariants
| text | source |
| Global rule | system |
When I merge using InvariantSet
Then the invariant set should have 4 invariants
# === Service: Add/List/Remove ===
Scenario: Service add invariant
@@ -574,6 +661,16 @@ Feature: Consolidated Domain Models
And the effective set should contain global invariants last
Scenario: Effective invariants include action scope — Issue #9126
Given an invariant service with invariants at all four scopes
When I get effective invariants for plan "plan1", action "action1", and project "proj1"
Then the effective set should contain plan invariants first
And the effective set should contain action invariants second
And the effective set should contain project invariants after action
And the effective set should contain global invariants last
And the effective set should have 4 invariants
Scenario: Effective invariants de-duplicate across scopes
Given an invariant service with duplicate text across scopes
When I get effective invariants for plan "plan1" and project "proj1"
+98
View File
@@ -135,6 +135,11 @@ def step_plan_invariants(context):
context.plan_invariants = _parse_invariant_table(context, InvariantScope.PLAN)
@given("I have action invariants")
def step_action_invariants(context):
context.action_invariants = _parse_invariant_table(context, InvariantScope.ACTION)
@given("I have project invariants")
def step_project_invariants(context):
context.project_invariants = _parse_invariant_table(context, InvariantScope.PROJECT)
@@ -160,6 +165,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", []),
)
@@ -180,6 +186,43 @@ def step_merged_scope(context, idx, scope):
assert context.merged[idx].scope.value == scope
@then("action invariants appear before project invariants in merge")
def step_action_before_project(context):
"""Verify ACTION tier comes before PROJECT tier in the merged result."""
action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION]
project_invs = [i for i in context.merged if i.scope == InvariantScope.PROJECT]
assert len(action_invs) > 0, "No action invariants found in merged result"
assert len(project_invs) > 0, "No project invariants found in merged result"
first_action_idx = context.merged.index(action_invs[0])
first_project_idx = context.merged.index(project_invs[0])
assert first_action_idx < first_project_idx, (
f"Action invariant at index {first_action_idx} "
f"should appear before project invariant at index {first_project_idx}"
)
@then("plan invariants appear before action invariants in merge")
def step_plan_before_action(context):
"""Verify PLAN tier comes before ACTION tier in the merged result."""
plan_invs = [i for i in context.merged if i.scope == InvariantScope.PLAN]
action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION]
assert len(plan_invs) > 0, "No plan invariants found in merged result"
assert len(action_invs) > 0, "No action invariants found in merged result"
first_plan_idx = context.merged.index(plan_invs[0])
first_action_idx = context.merged.index(action_invs[0])
assert first_plan_idx < first_action_idx, (
f"Plan invariant at index {first_plan_idx} "
f"should appear before action invariant at index {first_action_idx}"
)
@then("action invariants are preserved in merge result")
def step_action_invariants_preserved(context):
"""Verify that action-scoped invariants appear in the merged output."""
action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION]
assert len(action_invs) > 0, "Expected action-scoped invariants in merge result"
# ================================================================
# InvariantSet
# ================================================================
@@ -189,6 +232,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", []),
)
@@ -389,6 +433,15 @@ def step_service_all_scopes(context):
context.service.add_invariant("Plan rule", InvariantScope.PLAN, "plan1")
@given("an invariant service with invariants at all four scopes")
def step_service_all_four_scopes(context):
context.service = InvariantService()
context.service.add_invariant("Global rule", InvariantScope.GLOBAL, "system")
context.service.add_invariant("Project rule", InvariantScope.PROJECT, "proj1")
context.service.add_invariant("Action rule", InvariantScope.ACTION, "action1")
context.service.add_invariant("Plan rule", InvariantScope.PLAN, "plan1")
@when('I get effective invariants for plan "{plan_id}" and project "{project}"')
def step_effective(context, plan_id, project):
context.effective = context.service.get_effective_invariants(
@@ -396,6 +449,15 @@ def step_effective(context, plan_id, project):
)
@when(
'I get effective invariants for plan "{plan_id}", action "{action}", and project "{project}"'
)
def step_effective_with_action(context, plan_id, action, project):
context.effective = context.service.get_effective_invariants(
plan_id=plan_id, action_name=action, project_name=project
)
@then("the effective set should contain plan invariants first")
def step_effective_plan_first(context):
plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
@@ -404,6 +466,35 @@ def step_effective_plan_first(context):
assert first_plan_idx == 0
@then("the effective set should contain action invariants second")
def step_effective_action_second(context):
"""Verify ACTION tier appears after PLAN and before PROJECT."""
plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
action_invs = [i for i in context.effective if i.scope == InvariantScope.ACTION]
project_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
assert len(plan_invs) > 0, "No plan invariants found"
assert len(action_invs) > 0, "No action invariants found — bug #9126 not fixed!"
assert len(project_invs) > 0, "No project invariants found"
first_action_idx = context.effective.index(action_invs[0])
if plan_invs:
last_plan_idx = context.effective.index(plan_invs[-1])
assert first_action_idx > last_plan_idx
first_project_idx = context.effective.index(project_invs[0])
assert first_project_idx > first_action_idx
@then("the effective set should contain project invariants after action")
def step_effective_project_after_action(context):
"""Verify PROJECT tier appears after ACTION tier."""
action_invs = [i for i in context.effective if i.scope == InvariantScope.ACTION]
project_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
assert len(action_invs) > 0, "No action invariants found"
assert len(project_invs) > 0, "No project invariants found"
first_proj_idx = context.effective.index(project_invs[0])
last_action_idx = context.effective.index(action_invs[-1])
assert first_proj_idx > last_action_idx
@then("the effective set should contain project invariants second")
def step_effective_project_second(context):
proj_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
@@ -435,6 +526,13 @@ def step_no_duplicates(context):
assert len(texts) == len(set(texts)), f"Duplicates found: {texts}"
@then("the effective set should have {count:d} invariants")
def step_effective_count(context, count):
assert len(context.effective) == count, (
f"Expected {count}, got {len(context.effective)}"
)
# ================================================================
# Enforcement
# ================================================================
+2
View File
@@ -867,6 +867,7 @@ def invariants_enforced_during_strategize() -> None:
merged = merge_invariants(
plan_invariants=[plan_inv],
action_invariants=[],
project_invariants=[project_inv],
global_invariants=[global_inv],
)
@@ -890,6 +891,7 @@ def invariants_enforced_during_strategize() -> None:
invariant_set = InvariantSet.merge(
plan_invariants=[plan_inv],
action_invariants=[],
project_invariants=[project_inv],
global_invariants=[global_inv],
)
@@ -11,8 +11,8 @@ a dict keyed by invariant ID.
## Merge Precedence
Effective invariants are computed using plan > project > global order.
See ``merge_invariants`` for de-duplication semantics.
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.
"""
@@ -124,6 +124,7 @@ class InvariantService:
if effective:
return self.get_effective_invariants(
plan_id=source_name if scope == InvariantScope.PLAN else None,
action_name=source_name if scope == InvariantScope.ACTION else None,
project_name=source_name if scope == InvariantScope.PROJECT else None,
)
@@ -167,16 +168,22 @@ class InvariantService:
def get_effective_invariants(
self,
plan_id: str | None = None,
action_name: str | None = None,
project_name: str | None = None,
) -> list[Invariant]:
"""Return the merged precedence chain for a plan/project context.
"""Return the merged precedence chain for a plan/action/project 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.
action_name: Optional action name to collect action-scoped
invariants (promoted to plan-level during reconciliation).
When ``None``, the action tier is omitted for backward
compatibility. Pass ``"*"`` to include all action-scoped
invariants regardless of source name.
project_name: Optional project name to collect project-scoped
invariants.
@@ -191,6 +198,13 @@ 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 not None # Only include when explicitly requested
and (inv.source_name == action_name or action_name == "*")
]
project_invs = [
inv
for inv in active
@@ -199,7 +213,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,
@@ -2,9 +2,10 @@
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:
invariants are promoted to plan-level and participate in the merge.
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.
@@ -21,8 +22,8 @@ of Strategize and recorded as ``invariant_enforced`` decisions.
## 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.
"""
@@ -39,8 +40,10 @@ 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.
ACTION invariants are promoted to plan-level and participate in the
merge during reconciliation, sitting between PLAN and PROJECT in the
precedence chain.
"""
GLOBAL = "global"
@@ -137,10 +140,11 @@ class InvariantSet(BaseModel):
def merge(
cls,
plan_invariants: list[Invariant],
project_invariants: list[Invariant],
global_invariants: list[Invariant],
action_invariants: list[Invariant] | None = None,
project_invariants: list[Invariant] | None = None,
global_invariants: list[Invariant] | None = None,
) -> 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
@@ -148,6 +152,9 @@ class InvariantSet(BaseModel):
Args:
plan_invariants: Plan-level invariants (highest precedence).
action_invariants: Action-scoped invariants (second-highest
precedence; promoted to plan-level when an
action is used).
project_invariants: Project-level invariants.
global_invariants: Global-level invariants (lowest precedence).
@@ -156,7 +163,12 @@ class InvariantSet(BaseModel):
"""
return cls(
invariants=tuple(
merge_invariants(plan_invariants, project_invariants, global_invariants)
merge_invariants(
plan_invariants,
action_invariants or [],
project_invariants or [],
global_invariants or [],
)
)
)
@@ -165,10 +177,11 @@ class InvariantSet(BaseModel):
def merge_invariants(
plan_invariants: list[Invariant],
project_invariants: list[Invariant],
global_invariants: list[Invariant],
action_invariants: list[Invariant] | None = None,
project_invariants: list[Invariant] | None = None,
global_invariants: list[Invariant] | None = None,
) -> 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
@@ -176,8 +189,13 @@ def merge_invariants(
Args:
plan_invariants: Plan-level invariants (highest precedence).
project_invariants: Project-level invariants.
action_invariants: Action-scoped invariants (second-highest
precedence; promoted to plan level when an action is used).
Defaults to empty list when ``None``.
project_invariants: Project-level invariants. Defaults to empty
list when ``None``.
global_invariants: Global-level invariants (lowest precedence).
Defaults to empty list when ``None``.
Returns:
A de-duplicated list of invariants in precedence order.
@@ -185,7 +203,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 or [],
project_invariants or [],
global_invariants or [],
):
for inv in inv_list:
if not inv.active:
continue