diff --git a/examples/semantic-vocabulary-drift-smoke.py b/examples/semantic-vocabulary-drift-smoke.py index 3559e8787..bd99c33d4 100755 --- a/examples/semantic-vocabulary-drift-smoke.py +++ b/examples/semantic-vocabulary-drift-smoke.py @@ -232,12 +232,12 @@ TWIN_ROOT_ANCHOR = "loopx/control_plane" TWIN_BUDGET_ANCHOR = 43 BUDGET_ANCHOR = { - "same_runtime_forks": 15, - "same_runtime_fork_definitions": 35, + "same_runtime_forks": 12, + "same_runtime_fork_definitions": 28, "conflicting_values": 16, "conflicting_definitions": 55, - "schema_version_same_runtime_forks": 5, - "multi_value_twins": 11, + "schema_version_same_runtime_forks": 2, + "multi_value_twins": 8, "multi_value_forks": 2, "multi_value_forks_semantic": 1, "multi_value_fork_definitions": 6, diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index cdd6a9687..a355fd4f0 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -1126,12 +1126,12 @@ }, "inventory_ratchets": { "meaning": "Counts read from the generated inventory. A same-runtime fork is one constant name with one value defined in two or more modules of the same runtime; a conflicting value is one name with different values. Both the number of affected names and the number of definitions are budgets, so a third spelling of an already-conflicting name is still a regression.", - "same_runtime_forks": 15, - "same_runtime_fork_definitions": 35, + "same_runtime_forks": 12, + "same_runtime_fork_definitions": 28, "conflicting_values": 16, "conflicting_definitions": 55, - "schema_version_same_runtime_forks": 5, - "multi_value_twins": 11, + "schema_version_same_runtime_forks": 2, + "multi_value_twins": 8, "multi_value_forks": 2, "multi_value_fork_definitions": 6, "same_runtime_forks_semantic": 10, diff --git a/loopx/status.py b/loopx/status.py index cb0c1912b..db57d26e1 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -2,7 +2,6 @@ from collections.abc import Mapping, Sequence from pathlib import Path -import re from typing import Any from .control_plane.work_items.replan_history_codec import ( @@ -11,6 +10,17 @@ from .control_plane import compact_control_plane_policy from .control_plane.effect_runtime import effect_runtime_request_scope +# Refs #4447: one definition for each carrier this facade used to restate. The +# owner is the projection that feeds the value into a read model; `loopx/status.py` +# keeps exporting the established public name as an identity alias instead of +# writing the same literal a second time, so a change in the owner cannot quietly +# diverge from a change here. +from .control_plane.status.active_state_projection import ( + BACKLOG_HYGIENE_BULLET_PATTERN as BACKLOG_HYGIENE_BULLET_PATTERN, + BACKLOG_HYGIENE_HINT_PATTERN as BACKLOG_HYGIENE_HINT_PATTERN, + BACKLOG_HYGIENE_SECTION_HEADINGS as BACKLOG_HYGIENE_SECTION_HEADINGS, + SECTION_HEADING_PATTERN as SECTION_HEADING_PATTERN, +) from .control_plane.status.adapter_status_vocabulary import ( CONNECTED_ADAPTER_STATUSES as CONNECTED_ADAPTER_STATUSES, CONNECTED_DELIVERY_ADAPTER_STATUSES as CONNECTED_DELIVERY_ADAPTER_STATUSES, @@ -20,27 +30,44 @@ # read models, so the owner is the control-plane status package and this facade # keeps exporting the established names for existing callers as identity aliases # instead of restating the values. +from .control_plane.status.autonomous_replan_projection import ( + AUTONOMOUS_REPLAN_PERIODIC_RUN_THRESHOLD as AUTONOMOUS_REPLAN_PERIODIC_RUN_THRESHOLD, + AUTONOMOUS_REPLAN_SCHEMA_VERSION as AUTONOMOUS_REPLAN_SCHEMA_VERSION, + DEAD_MONITOR_REPEAT_SCHEMA_VERSION as DEAD_MONITOR_REPEAT_SCHEMA_VERSION, + DEAD_MONITOR_REPEAT_THRESHOLD as DEAD_MONITOR_REPEAT_THRESHOLD, +) from .control_plane.status.collection import ( StatusCollectionContext, collect_status as _collect_status_read_model, ) from .control_plane.status.contract_projection import ( + MINIMUM_DASHBOARD_STATUS_CONTRACT_SCHEMA_VERSION as MINIMUM_DASHBOARD_STATUS_CONTRACT_SCHEMA_VERSION, STATUS_CONTRACT_RELOAD_HINT as STATUS_CONTRACT_RELOAD_HINT, + STATUS_CONTRACT_SCHEMA_VERSION as STATUS_CONTRACT_SCHEMA_VERSION, + STATUS_CONTRACT_SIGNAL_LIMIT as STATUS_CONTRACT_SIGNAL_LIMIT, ) from .control_plane.status.goal_attention_projection import ( + # Read, not only re-exported, by this facade, so this stays a plain import and + # the name is absent from the identity-alias allowlist below. + LEGACY_EXTERNAL_EVIDENCE_CLASSIFICATION_PREFIXES, PLANNED_CONTROLLER_OPT_IN_RECOMMENDED_ACTION as PLANNED_CONTROLLER_OPT_IN_RECOMMENDED_ACTION, + REGISTRY_WAITING_ON_OVERRIDES as REGISTRY_WAITING_ON_OVERRIDES, ) # Refs #4447: one definition for this vocabulary. The control_plane projection # owns it because it feeds the monitor/attention read models; this module keeps # re-exporting each name for existing callers instead of restating its value. from .control_plane.status.monitor_display_projection import ( MONITOR_DISPLAY_FALLBACK_ACTION as MONITOR_DISPLAY_FALLBACK_ACTION, + MONITOR_DISPLAY_SCHEMA_VERSION as MONITOR_DISPLAY_SCHEMA_VERSION, MONITOR_DISPLAY_STOP_CONDITION as MONITOR_DISPLAY_STOP_CONDITION, MONITOR_SIGNAL_WAITING_ON, ) from .control_plane.status.registry_health_projection import ( SOURCE_REGISTRY_SHADOW_FINDINGS, ) +from .control_plane.status.run_projection import ( + AGENT_LANE_PROGRESS_SCOPE, +) from .control_plane.status.runtime_summaries import ( StatusRuntimeSummaryContext, build_status_runtime_summaries as _build_status_runtime_summaries_read_model, @@ -113,7 +140,6 @@ build_attention_queue as _build_attention_queue_read_model, ) from .control_plane.work_items.autonomous_replan_ack import ( - AUTONOMOUS_REPLAN_ACK_MATERIAL_RUN_WINDOW, AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK as _AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK, compact_autonomous_replan_ack, ) @@ -259,6 +285,24 @@ "MONITOR_DISPLAY_FALLBACK_ACTION": "loopx.control_plane.status.monitor_display_projection", "STATUS_CONTRACT_RELOAD_HINT": "loopx.control_plane.status.contract_projection", "PLANNED_CONTROLLER_OPT_IN_RECOMMENDED_ACTION": "loopx.control_plane.status.goal_attention_projection", + # Refs #4447: the facade restated thirteen carriers its own projections + # already own - the status contract schema numbers, the monitor display schema + # version, the goal-attention override set, and the active-state, autonomous- + # replan, dead-monitor and backlog-hygiene carriers. Each is now defined once + # by the projection that feeds it to a read model. + "AUTONOMOUS_REPLAN_PERIODIC_RUN_THRESHOLD": "loopx.control_plane.status.autonomous_replan_projection", + "AUTONOMOUS_REPLAN_SCHEMA_VERSION": "loopx.control_plane.status.autonomous_replan_projection", + "BACKLOG_HYGIENE_BULLET_PATTERN": "loopx.control_plane.status.active_state_projection", + "BACKLOG_HYGIENE_HINT_PATTERN": "loopx.control_plane.status.active_state_projection", + "BACKLOG_HYGIENE_SECTION_HEADINGS": "loopx.control_plane.status.active_state_projection", + "DEAD_MONITOR_REPEAT_SCHEMA_VERSION": "loopx.control_plane.status.autonomous_replan_projection", + "DEAD_MONITOR_REPEAT_THRESHOLD": "loopx.control_plane.status.autonomous_replan_projection", + "MINIMUM_DASHBOARD_STATUS_CONTRACT_SCHEMA_VERSION": "loopx.control_plane.status.contract_projection", + "MONITOR_DISPLAY_SCHEMA_VERSION": "loopx.control_plane.status.monitor_display_projection", + "REGISTRY_WAITING_ON_OVERRIDES": "loopx.control_plane.status.goal_attention_projection", + "SECTION_HEADING_PATTERN": "loopx.control_plane.status.active_state_projection", + "STATUS_CONTRACT_SCHEMA_VERSION": "loopx.control_plane.status.contract_projection", + "STATUS_CONTRACT_SIGNAL_LIMIT": "loopx.control_plane.status.contract_projection", # Refs #4447: the connected-adapter sets were defined here and in both status # projections that pass them to the same injected read-model parameters. The # status package now owns each set once; the facade keeps exporting the @@ -270,21 +314,6 @@ STATUS_NEUTRAL_CLASSIFICATIONS = HISTORY_STATUS_NEUTRAL_CLASSIFICATIONS STATUS_CONTROL_PLANE_CONTEXT_LIMIT = 20 -AGENT_LANE_PROGRESS_SCOPE = "agent_lane" -REGISTRY_WAITING_ON_OVERRIDES = { - "user_or_controller", - "controller", - "codex", - "external_evidence", -} -LEGACY_EXTERNAL_EVIDENCE_CLASSIFICATION_PREFIXES = ( - "await_", - "external_evidence_observation_", -) -MONITOR_DISPLAY_SCHEMA_VERSION = "monitor_quiet_display_v0" -STATUS_CONTRACT_SCHEMA_VERSION = 2 -MINIMUM_DASHBOARD_STATUS_CONTRACT_SCHEMA_VERSION = 2 -STATUS_CONTRACT_SIGNAL_LIMIT = 3 EVENT_LEDGER_DECISION_CLASSIFICATIONS = USER_OR_CONTROLLER_CLASSIFICATIONS | { "operator_gate_approved", } @@ -330,7 +359,6 @@ "planned", "run_recorded", ) -SECTION_HEADING_PATTERN = re.compile(r"^##+\s+(.+?)\s*$") MAX_STATUS_TODOS_PER_ROLE = _TODO_SUMMARY_MAX_STATUS_TODOS_PER_ROLE MAX_ACTIVE_DONE_TODOS_BEFORE_ARCHIVE = MAX_STATUS_TODOS_PER_ROLE MAX_PROJECT_ASSET_TODO_ITEMS = _TODO_SUMMARY_MAX_PROJECT_ASSET_TODO_ITEMS @@ -343,16 +371,7 @@ MAX_BACKLOG_HYGIENE_EVIDENCE_ITEMS = _MAX_BACKLOG_HYGIENE_EVIDENCE_ITEMS_READ_MODEL MAX_AUTONOMOUS_REPLAN_TRIGGERS = _MAX_AUTONOMOUS_REPLAN_TRIGGERS_READ_MODEL AUTONOMOUS_REPLAN_STALL_THRESHOLD = _AUTONOMOUS_REPLAN_STALL_THRESHOLD_READ_MODEL -DEAD_MONITOR_REPEAT_THRESHOLD = 6 -AUTONOMOUS_REPLAN_PERIODIC_RUN_THRESHOLD = AUTONOMOUS_REPLAN_ACK_MATERIAL_RUN_WINDOW AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK = _AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK -BACKLOG_HYGIENE_SECTION_HEADINGS = ("Next Action", "Operating Lessons") -BACKLOG_HYGIENE_BULLET_PATTERN = re.compile(r"^\s*(?:[-*]|\d+[.)])\s+(.+?)\s*$") -BACKLOG_HYGIENE_HINT_PATTERN = re.compile( - r"(?i)(?:\[p[0-4]\]|todo|backlog|follow[- ]?up|queue|audit|regression|smoke|cadence|mirror|monitor|sub-?agent|待办|回归|审计|修复|检查|推进)" -) -AUTONOMOUS_REPLAN_SCHEMA_VERSION = "autonomous_replan_obligation_v0" -DEAD_MONITOR_REPEAT_SCHEMA_VERSION = "dead_monitor_repeat_v0" # Refs #4447: one definition for this vocabulary, owned by the control-plane # codec that now feeds the replan history policy across status and quota. The # facade keeps exporting the established public name for existing callers as an diff --git a/tests/control_plane/test_status_facade_vocabulary_owners.py b/tests/control_plane/test_status_facade_vocabulary_owners.py new file mode 100644 index 000000000..1a5b4d04b --- /dev/null +++ b/tests/control_plane/test_status_facade_vocabulary_owners.py @@ -0,0 +1,234 @@ +"""Refs #4447: the status facade stops restating what its own projections own. + +`loopx/status.py` is the public facade over `loopx.control_plane.status`, and it had +grown its own copy of fifteen module-level carriers that a projection already defined +with an identical value: the three status-contract schema numbers, the monitor display +schema version, the goal-attention override set and legacy evidence prefixes, and the +active-state, autonomous-replan, dead-monitor, backlog-hygiene and agent-lane carriers. + +Each of those values feeds an injected parameter of a read model inside the owning +projection, so the projection is the owner and the facade's job is only to keep +exporting the established name. Before this change the two copies were free to +diverge: an edit on one side changed the read model and left `loopx.status` reporting +the other literal, and the generated inventory counted every such pair as a duplicate +it had to carry in a budget. + +This test pins three things: the facade declares none of these names itself, every +facade binding is the owner's object rather than an equal-looking re-typed literal, +and the merged values are what the projections shipped with. +""" + +from __future__ import annotations + +import ast +import functools +import inspect +from pathlib import Path + +from loopx import status +from loopx.control_plane.status import ( + active_state_projection, + autonomous_replan_projection, + contract_projection, + goal_attention_projection, + lifecycle_projection, + monitor_display_projection, + run_projection, +) + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +PACKAGE_ROOT = REPOSITORY_ROOT / "loopx" +FACADE = "loopx/status.py" + +# name -> (module holding the one remaining definition, other modules importing it). +# These names were shared by the facade and exactly this owner, so after the merge the +# owner is the only declarer left in the package (twelve of the fifteen). +FULLY_MERGED: dict[str, tuple[object, tuple[object, ...]]] = { + "AUTONOMOUS_REPLAN_PERIODIC_RUN_THRESHOLD": ( + autonomous_replan_projection, + (status,), + ), + "AUTONOMOUS_REPLAN_SCHEMA_VERSION": (autonomous_replan_projection, (status,)), + "BACKLOG_HYGIENE_BULLET_PATTERN": (active_state_projection, (status,)), + "BACKLOG_HYGIENE_HINT_PATTERN": (active_state_projection, (status,)), + "BACKLOG_HYGIENE_SECTION_HEADINGS": (active_state_projection, (status,)), + "DEAD_MONITOR_REPEAT_SCHEMA_VERSION": (autonomous_replan_projection, (status,)), + "DEAD_MONITOR_REPEAT_THRESHOLD": (autonomous_replan_projection, (status,)), + "LEGACY_EXTERNAL_EVIDENCE_CLASSIFICATION_PREFIXES": ( + goal_attention_projection, + (status,), + ), + "MINIMUM_DASHBOARD_STATUS_CONTRACT_SCHEMA_VERSION": ( + contract_projection, + (status,), + ), + "MONITOR_DISPLAY_SCHEMA_VERSION": (monitor_display_projection, (status,)), + "REGISTRY_WAITING_ON_OVERRIDES": (goal_attention_projection, (status,)), + "STATUS_CONTRACT_SCHEMA_VERSION": (contract_projection, (status,)), +} + +# The facade copy is gone here too, but these names are still restated by modules +# outside the facade. Recorded with their remaining declarers so a follow-up starts +# from the list, and so one more copy cannot appear unnoticed. +STILL_FORKED_ELSEWHERE: dict[str, list[str]] = { + "AGENT_LANE_PROGRESS_SCOPE": [ + "loopx/control_plane/agents/agent_lane_recommendation.py", + "loopx/control_plane/status/run_projection.py", + "loopx/history.py", + "loopx/state_refresh.py", + ], + "SECTION_HEADING_PATTERN": [ + "loopx/control_plane/status/active_state_projection.py", + "loopx/state_projection.py", + ], + "STATUS_CONTRACT_SIGNAL_LIMIT": [ + "loopx/control_plane/status/contract_projection.py", + "loopx/diagnose.py", + ], +} + +OWNERSHIPS: dict[str, tuple[object, tuple[object, ...]]] = { + **FULLY_MERGED, + "AGENT_LANE_PROGRESS_SCOPE": (run_projection, (status,)), + "SECTION_HEADING_PATTERN": (active_state_projection, (status,)), + "STATUS_CONTRACT_SIGNAL_LIMIT": (contract_projection, (status,)), +} + +# The values those carriers already had in the projections before the merge. +EXPECTED_VALUES: dict[str, object] = { + "AGENT_LANE_PROGRESS_SCOPE": "agent_lane", + "AUTONOMOUS_REPLAN_PERIODIC_RUN_THRESHOLD": 20, + "AUTONOMOUS_REPLAN_SCHEMA_VERSION": "autonomous_replan_obligation_v0", + "BACKLOG_HYGIENE_SECTION_HEADINGS": ("Next Action", "Operating Lessons"), + "DEAD_MONITOR_REPEAT_SCHEMA_VERSION": "dead_monitor_repeat_v0", + "DEAD_MONITOR_REPEAT_THRESHOLD": 6, + "LEGACY_EXTERNAL_EVIDENCE_CLASSIFICATION_PREFIXES": ( + "await_", + "external_evidence_observation_", + ), + "MINIMUM_DASHBOARD_STATUS_CONTRACT_SCHEMA_VERSION": 2, + "MONITOR_DISPLAY_SCHEMA_VERSION": "monitor_quiet_display_v0", + "REGISTRY_WAITING_ON_OVERRIDES": { + "user_or_controller", + "controller", + "codex", + "external_evidence", + }, + "STATUS_CONTRACT_SCHEMA_VERSION": 2, + "STATUS_CONTRACT_SIGNAL_LIMIT": 3, +} +EXPECTED_PATTERNS: dict[str, str] = { + "BACKLOG_HYGIENE_BULLET_PATTERN": r"^\s*(?:[-*]|\d+[.)])\s+(.+?)\s*$", + "BACKLOG_HYGIENE_HINT_PATTERN": ( + r"(?i)(?:\[p[0-4]\]|todo|backlog|follow[- ]?up|queue|audit|regression|smoke|" + r"cadence|mirror|monitor|sub-?agent|待办|回归|审计|修复|检查|推进)" + ), + "SECTION_HEADING_PATTERN": r"^##+\s+(.+?)\s*$", +} + + +def _module_level_names(path: Path) -> set[str]: + """Collect the names a module assigns at module level.""" + tree = ast.parse(path.read_text(encoding="utf-8")) + names: set[str] = set() + for node in tree.body: + if isinstance(node, ast.Assign): + names.update( + target.id for target in node.targets if isinstance(target, ast.Name) + ) + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + names.add(node.target.id) + return names + + +def _index(root: Path) -> dict[str, list[str]]: + """Map every module-level name under ``root`` to the files that declare it. + + One parse per file: the tables here cover a dozen names, and re-walking the + package per name would make this the slowest thing in the lane for no signal. + """ + index: dict[str, list[str]] = {} + for path in sorted(root.rglob("*.py")): + try: + relative = str(path.relative_to(REPOSITORY_ROOT)) + except ValueError: # a seeded temporary tree is not inside the repository + relative = str(path.relative_to(root)) + for name in _module_level_names(path): + index.setdefault(name, []).append(relative) + return index + + +@functools.lru_cache(maxsize=1) +def _package_index() -> dict[str, list[str]]: + return _index(PACKAGE_ROOT) + + +def _declaring_modules(name: str, root: Path | None = None) -> list[str]: + """Report every module under ``root`` that declares ``name`` at module level.""" + index = _package_index() if root is None else _index(root) + return sorted(index.get(name, [])) + + +def _relative_path(module: object) -> str: + return str(Path(inspect.getfile(module)).relative_to(REPOSITORY_ROOT)) + + +def test_the_facade_declares_none_of_these_names() -> None: + for name in OWNERSHIPS: + assert FACADE not in _declaring_modules(name), name + + +def test_each_fully_merged_name_has_one_declarer_left() -> None: + for name, (owner, _importers) in FULLY_MERGED.items(): + assert _declaring_modules(name) == [_relative_path(owner)], name + + +def test_the_remaining_forks_are_exactly_the_recorded_ones() -> None: + for name, expected in STILL_FORKED_ELSEWHERE.items(): + assert _declaring_modules(name) == sorted(expected), name + + +def test_the_facade_and_its_projections_share_one_object() -> None: + """Identity, not equality: a re-typed literal would fork the carrier again.""" + for name, (owner, importers) in OWNERSHIPS.items(): + for importer in (*importers, owner): + assert getattr(importer, name) is getattr(owner, name), ( + f"{name} via {importer}" + ) + + +def test_merging_the_copies_changed_no_value() -> None: + for name, expected in EXPECTED_VALUES.items(): + assert getattr(OWNERSHIPS[name][0], name) == expected, name + for name, expected in EXPECTED_PATTERNS.items(): + assert getattr(OWNERSHIPS[name][0], name).pattern == expected, name + + +def test_the_lifecycle_priority_pair_is_left_in_place() -> None: + """Deliberate exclusion, recorded so a later reader sees it was a decision. + + `LIFECYCLE_PRIORITY` is the sixteenth duplicate and the one this change could not + clean up: no repository caller reaches it through the facade, so re-exporting it + as an identity alias would add a `_PUBLIC_COMPAT_REEXPORTS` entry the boundary + tests reject for want of a consumer, while dropping the facade binding would + remove a public attribute. Its two copies are also the example the convergence RFC + uses for order-sensitive identity, so retiring the pair needs an owner decision + rather than a rename inside this PR. + """ + assert _declaring_modules("LIFECYCLE_PRIORITY") == [ + _relative_path(lifecycle_projection), + FACADE, + ] + + +def test_the_scanner_reports_a_duplicated_name_and_not_an_empty_list( + tmp_path: Path, +) -> None: + """A guard that finds nothing in a seeded tree would pass every run by accident.""" + (tmp_path / "owner.py").write_text("CARRIER = 'one'\n", encoding="utf-8") + (tmp_path / "facade.py").write_text( + "from .owner import CARRIER\n\nCARRIER = 'one'\n", encoding="utf-8" + ) + (tmp_path / "quiet.py").write_text("OTHER = 'one'\n", encoding="utf-8") + assert _declaring_modules("CARRIER", tmp_path) == ["facade.py", "owner.py"] + assert _declaring_modules("OTHER", tmp_path) == ["quiet.py"]