diff --git a/loopx/capabilities/material_lifecycle/README.md b/loopx/capabilities/material_lifecycle/README.md index 37af7d1748..d221649d73 100644 --- a/loopx/capabilities/material_lifecycle/README.md +++ b/loopx/capabilities/material_lifecycle/README.md @@ -165,6 +165,45 @@ contracts; the single-receipt limit remains 100. This SDK slice is independent of project-scope ownership support. It does not add a CLI writer, store authority, scoring policy or automatic reranker. +## Wave-Based Multi-Material Reranking + +`plan_material_wave_rerank(ordered_material_refs, annotations, ...)` (and the +stateless CLI preview `loopx material-lifecycle plan-wave-rerank --input `) +delegates multi-item shortlist ordering to the shared TypeScript control-plane +owner (`material_lifecycle.plan_wave_rerank`) while keeping `apply_authorized` +false and leaving source authority untouched. + +It deterministically resolves three common ranking failure modes across large +queues: + +1. **Horizon-wave and effort-band cost-adjusted ordering**: stratifies annotated + items across `imminent_gate`, `active_sprint`, `foundational_track`, and + `orthogonal_backlog` with effort-weighted VOI (`quick_slice` `/1.0`, + `focused_study` `/2.0`, `deep_monograph` `/6.0`). Unsliced `deep_monograph` + items (`scoped_slice_ready=False`) declared in `imminent_gate` or + `active_sprint` automatically stratify to `foundational_track` so multi-week + courses or books never block quick high-VOI slices unless a bounded + chapter/section slice is ready. +2. **Load-bearing primary-over-secondary lineage and unranked dependency gate**: + rejects rerank plans when any `required_load_bearing_refs` entry is absent + from `ordered_material_refs` (requiring candidate intake settlement first), + enforces topological precedence so ranked `primary_source_refs` always land + strictly ahead of secondary commentary, and verifies that every + `blocks_active_deliverable=True` + `load_bearing_primary` material lands + within `top_window_size`. +3. **Orthogonal Top-Window cap and passive backlog retention**: caps + `orthogonal_backlog` and `orthogonal_signal` items inside + `1..top_window_size` (`max_orthogonal_in_top_window`, default `0`) whenever + non-orthogonal ranked items exist below `top_window_size`. Unannotated + items retain exact original relative order after the structured active waves, + and demoted orthogonal items land cleanly at the start of `ranked_backlog` + (`max(top_window_size + 1, after_active_waves)`) rather than displacing the + entire tail. + +The returned `(reordered_refs, summary)` supplies `moves` and `constraints` +directly compatible with `build_material_rerank_proposal(..., moves=summary["moves"], **summary["constraints"])` +and `material_rerank_receipt_chunks`. + ## Project-Local Skill Delivery LoopX ships the canonical `loopx-material` skill, but deliberately does not @@ -182,8 +221,11 @@ loopx project-skill install \ Supported surfaces: - `codex` -> `.agents/skills/loopx-material/` +- `antigravity` -> `.agent/skills/loopx-material/` - `claude-code` -> `.claude/skills/loopx-material/` +- `kiro-cli` -> `.kiro/skills/loopx-material/` - `opencode` -> `.opencode/skills/loopx-material/` +- `pi` -> `.pi/skills/loopx-material/` Repeat `--surface` to install multiple host-native copies in one transaction. Inspect before or after installation: @@ -242,6 +284,14 @@ Inspect the provider-neutral contract: loopx material-lifecycle architecture --format json ``` +Preview a deterministic wave rerank from exact-read annotations: + +```bash +loopx material-lifecycle plan-wave-rerank \ + --input request.json \ + --format json +``` + Inspect the managed project skill: ```bash @@ -266,8 +316,11 @@ no Goal. Its project/profile/grant references select existing Core context and source ownership, and never grant access by themselves. Project intake/rollback require the source provider's `verify_project_scope` -to resolve the current Core caller, audience, exact profile/store, workspace -write grant and expiring owner gate. Verification runs before source access +to resolve the active native turn of the calling verified local owner host +(`Codex`, `Antigravity`, `Claude Code`, `OpenCode`, `Kiro CLI`, etc.) or +authorized native Bot turn under the same Core workspace write grant, audience, +exact profile/store, and expiring owner gate—never inventing a dummy Goal or +borrowing another host's turn. Verification runs before source access and publication; the source must retain its transaction authorization fence. Source initialization stays project owned. Migration, rebuild and Explore keep their existing Goal route. No new CLI or transport configuration path is added. diff --git a/loopx/capabilities/material_lifecycle/README.zh-CN.md b/loopx/capabilities/material_lifecycle/README.zh-CN.md index 87071a57fc..b04eea7e30 100644 --- a/loopx/capabilities/material_lifecycle/README.zh-CN.md +++ b/loopx/capabilities/material_lifecycle/README.zh-CN.md @@ -110,6 +110,35 @@ receipt。完整输入先验证,重复 ref 拒绝;每份 receipt 使用唯 空输入返回一份空分块,供现有 no-change / rejection / rollback 契约使用。 该 SDK 增量不依赖项目授权改动,不增设 CLI writer、素材 authority 或自动排序器。 +## 分波次多条目有界重排 + +`plan_material_wave_rerank(ordered_material_refs, annotations, ...)`(以及无状态 +CLI 预览入口 `loopx material-lifecycle plan-wave-rerank --input `)通过 +共享 TypeScript 控制面 owner(`material_lifecycle.plan_wave_rerank`)计算确定性的多条目 +重排预览,始终保持 `apply_authorized=False`,不会直接修改素材 authority。 + +它针对大型队列中的三类典型失真执行硬约束: + +1. **按周期波次与投入带宽的成本加权排序**:支持 `imminent_gate`、`active_sprint`、 + `foundational_track`、`orthogonal_backlog` 四档波次与 `quick_slice`(`/1.0`)、 + `focused_study`(`/2.0`)、`deep_monograph`(`/6.0`)投入带宽加权 VOI。未声明 + 有界章节切片(`scoped_slice_ready=False`)的 `deep_monograph` 若被放入 + `imminent_gate` 或 `active_sprint`,会自动分层到 `foundational_track`,避免多周课程 + 或长篇专著阻塞高收益短切片。 +2. **承重一手信源优先拓扑与未入列依赖门禁**:若 `required_load_bearing_refs` 中存在尚未入列 + 或未进入 `ordered_material_refs`(如 `rank=None`)的一手信源,直接拒绝盲目重排并要求先完成 + candidate intake 结算;对已入列引用强制执行 `primary_source_refs` 先于二手解读的拓扑序 + (检测环路与锚点倒置并 fail-closed),并校验 `blocks_active_deliverable=True` 且 + `evidence_role="load_bearing_primary"` 的条目落在 `top_window_size` 内。 +3. **正交热点 Top-Window 配额上限与未标注队列无损保序**:当 `top_window_size` 之外仍存在非正交 + 已排序条目时,将 `orthogonal_backlog` 与 `orthogonal_signal` 在 `1..top_window_size` + 内的数量限制为 `max_orthogonal_in_top_window`(默认 `0`)。未显式标注条目在主动波次之后严格保持 + 原有相对顺序,被降级的正交条目平顺移入 `ranked_backlog` 起点(`max(top_window_size + 1, after_active_waves)`), + 不会把前段条目挤压到数百条队列末尾。 + +返回的 `(reordered_refs, summary)` 可直接传入 `build_material_rerank_proposal(..., moves=summary["moves"], **summary["constraints"])` +及 `material_rerank_receipt_chunks`。 + ## 项目级 Skill 安装 LoopX 发布 canonical `loopx-material` skill,但刻意不安装到用户的全局 skill @@ -126,8 +155,11 @@ loopx project-skill install \ 目前支持: - `codex` -> `.agents/skills/loopx-material/` +- `antigravity` -> `.agent/skills/loopx-material/` - `claude-code` -> `.claude/skills/loopx-material/` +- `kiro-cli` -> `.kiro/skills/loopx-material/` - `opencode` -> `.opencode/skills/loopx-material/` +- `pi` -> `.pi/skills/loopx-material/` 重复传入 `--surface`,可以在一个 transaction 中安装多份 host-native 副本。 安装前后可检查状态: @@ -177,6 +209,14 @@ write scope 和 owner gate。 loopx material-lifecycle architecture --format json ``` +预览基于 exact-read 标注的分波次重排计划: + +```bash +loopx material-lifecycle plan-wave-rerank \ + --input request.json \ + --format json +``` + 检查项目级受管 skill: ```bash @@ -195,7 +235,7 @@ storage adapter、source profile 和 provider credential 仍由项目拥有。 普通项目对话可为已显式启用的素材来源使用 `MaterialProjectScope`,无需创建 Goal。inventory、candidate intake/rollback、rerank packet、readable projection 和 intake-ranking settlement 支持以 `project_scope` 替代 `goal_id`;两者必须且只能选一个。项目 packet 不含 Goal 标识,也不带入 manager 上下文。 -scope 的 `project_ref`、`source_profile_ref`、`workspace_grant_ref` 是既有 Core 授权与来源 profile 的选择器,不授予权限。project candidate provider 必须实现 `MaterialProjectScopeVerifier`,核对当前调用者/受众、精确 store/profile、工作区写入边界、owner gate 的有效期与撤销状态。SDK 在访问 provider 前及发布 authority 前调用该 verifier,授权事实仍由既有 Core owner 提供;adapter 还须在自身 staging/publication 事务内执行授权检查,不能把预检查的 Boolean 当作事务 fence。 +scope 的 `project_ref`、`source_profile_ref`、`workspace_grant_ref` 是既有 Core 授权与来源 profile 的选择器,不授予权限。project candidate provider 必须实现 `MaterialProjectScopeVerifier`,核对当前已验证本地 owner host(如 `Codex`、`Antigravity`、`Claude Code`、`OpenCode`、`Kiro CLI` 等)的活跃原生 turn(或同一 Core 工作区写入授权下的原生 Bot turn)、受众、精确 store/profile、工作区写入边界、owner gate 的有效期与撤销状态——不得虚构空壳 Goal,也不得借用其他 host 的 turn 身份。SDK 在访问 provider 前及发布 authority 前调用该 verifier,授权事实仍由既有 Core owner 提供;adapter 还须在自身 staging/publication 事务内执行授权检查,不能把预检查的 Boolean 当作事务 fence。 默认关闭不变;本路径不注册 Goal、不创建 source authority,不扩大 Goal-only migration/rebuild/Explore。新来源初始化、私有存储与 transport 接入仍由 source owner 提供,不能仅凭这些字段或安装 skill 开始写入。 diff --git a/loopx/capabilities/material_lifecycle/__init__.py b/loopx/capabilities/material_lifecycle/__init__.py index 6660624a47..e745a03a29 100644 --- a/loopx/capabilities/material_lifecycle/__init__.py +++ b/loopx/capabilities/material_lifecycle/__init__.py @@ -75,10 +75,12 @@ from .ranking import ( MATERIAL_RERANK_APPLY_RECEIPT_SCHEMA_VERSION, MATERIAL_RERANK_PROPOSAL_SCHEMA_VERSION, + MATERIAL_WAVE_RERANK_PLAN_SCHEMA_VERSION, build_material_rerank_apply_receipt, build_material_rerank_proposal, material_rerank_receipt_chunks, plan_material_single_move, + plan_material_wave_rerank, ) from .readable_projection import ( MATERIAL_READABLE_PROJECTION_RECEIPT_SCHEMA_VERSION, @@ -147,6 +149,7 @@ "MATERIAL_RANKED_STATE_REPAIR_PLAN_SCHEMA_VERSION", "MATERIAL_RERANK_APPLY_RECEIPT_SCHEMA_VERSION", "MATERIAL_RERANK_PROPOSAL_SCHEMA_VERSION", + "MATERIAL_WAVE_RERANK_PLAN_SCHEMA_VERSION", "MATERIAL_SKILL_ID", "MATERIAL_SKILL_MANAGED_MARKER", "MATERIAL_SKILL_SCOPE", @@ -195,6 +198,7 @@ "material_skill_digest", "material_rerank_receipt_chunks", "plan_material_single_move", + "plan_material_wave_rerank", "plan_material_decision_actions", "prepare_material_migration", "project_material_skill_target", diff --git a/loopx/capabilities/material_lifecycle/catalog_entry.py b/loopx/capabilities/material_lifecycle/catalog_entry.py index d21a4adbf7..c7f1512f91 100644 --- a/loopx/capabilities/material_lifecycle/catalog_entry.py +++ b/loopx/capabilities/material_lifecycle/catalog_entry.py @@ -41,7 +41,12 @@ "command": "loopx material-lifecycle architecture --format json", "purpose": "Render the default-off Stage-0 inventory, migration, lifecycle, and bounded-rerank boundaries.", "write_boundary": "stateless contract output only; no source store, backup, goal state, provider, or external write", - } + }, + { + "command": "loopx material-lifecycle plan-wave-rerank --input - --format json", + "purpose": "Preview deterministic wave-based material rerank moves and bounds across horizon waves, effort bands, primary-over-secondary lineage, and orthogonal shortlist caps.", + "write_boundary": "stateless preview output only; apply_authorized remains false and no source store or Core state is mutated", + }, ], "implemented_protocols": [ { diff --git a/loopx/capabilities/material_lifecycle/cli.py b/loopx/capabilities/material_lifecycle/cli.py index 336d2b7da0..9150505f07 100644 --- a/loopx/capabilities/material_lifecycle/cli.py +++ b/loopx/capabilities/material_lifecycle/cli.py @@ -1,16 +1,25 @@ from __future__ import annotations import argparse -from collections.abc import Callable, Collection +import json +import sys +from collections.abc import Callable, Collection, Mapping +from pathlib import Path +from typing import Any from loopx.capabilities.project_skill_delivery import PROJECT_SKILL_SURFACES +from ._validation import check_record_keys from .architecture import build_material_lifecycle_architecture_packet from .project_skill import ( install_project_material_skill, inspect_project_material_skill, uninstall_project_material_skill, ) +from .ranking import ( + MATERIAL_WAVE_RERANK_PLAN_SCHEMA_VERSION, + plan_material_wave_rerank, +) PrintPayload = Callable[ [dict[str, object], str, Callable[[dict[str, object]], str]], @@ -19,6 +28,16 @@ FormatSelector = Callable[..., str] AddFormat = Callable[[argparse.ArgumentParser], None] +_CLI_WAVE_RERANK_FIELDS = { + "annotations", + "default_evidence_refs", + "max_orthogonal_in_top_window", + "ordered_material_refs", + "protected_material_refs", + "required_load_bearing_refs", + "top_window_size", +} + def _collection_size(value: object) -> int: if isinstance(value, Collection) and not isinstance(value, (str, bytes)): @@ -26,6 +45,53 @@ def _collection_size(value: object) -> int: return 0 +def _load_wave_rerank_cli_payload(input_spec: str) -> dict[str, object]: + raw_text = ( + sys.stdin.read() + if input_spec == "-" + else Path(input_spec).expanduser().read_text(encoding="utf-8") + ) + decoded: Any = json.loads(raw_text) + if not isinstance(decoded, Mapping): + raise ValueError("wave rerank input must be a JSON object") + check_record_keys( + decoded, + field="request", + allowed=_CLI_WAVE_RERANK_FIELDS, + required={"ordered_material_refs"}, + ) + reordered_refs, summary = plan_material_wave_rerank( + decoded["ordered_material_refs"], + decoded.get("annotations"), + top_window_size=( + 30 + if decoded.get("top_window_size") is None + else decoded["top_window_size"] + ), + protected_material_refs=decoded.get("protected_material_refs"), + required_load_bearing_refs=decoded.get("required_load_bearing_refs"), + max_orthogonal_in_top_window=( + 0 + if decoded.get("max_orthogonal_in_top_window") is None + else decoded["max_orthogonal_in_top_window"] + ), + default_evidence_refs=decoded.get("default_evidence_refs"), + ) + return { + "ok": True, + "status": "planned", + "capability": { + "capability_id": "material_lifecycle", + "default_enabled": False, + "creates_authority": False, + "mutates_core_state": False, + }, + "apply_authorized": False, + "reordered_material_refs": reordered_refs, + **summary, + } + + def _render(payload: dict[str, object]) -> str: if payload.get("skill_id") == "loopx-material": lines = [ @@ -47,22 +113,40 @@ def _render(payload: dict[str, object]) -> str: if payload.get("error"): lines.append(f"- error: `{payload.get('error')}`") return "\n".join([*lines, ""]) + if payload.get("schema_version") == MATERIAL_WAVE_RERANK_PLAN_SCHEMA_VERSION: + return "\n".join( + [ + "# Material Wave Rerank Plan", + "", + f"- status: `{payload.get('status')}`", + f"- no_change: `{payload.get('no_change')}`", + f"- item_count: `{_collection_size(payload.get('reordered_material_refs'))}`", + f"- moved_count: `{_collection_size(payload.get('moves'))}`", + f"- top_window_size: `{payload.get('top_window_size')}`", + f"- top_window_orthogonal_count: `{payload.get('top_window_orthogonal_count')}`", + f"- apply_authorized: `{payload.get('apply_authorized')}`", + "", + ] + ) capability = payload.get("capability") capability_id = ( capability.get("capability_id") if isinstance(capability, dict) else "material_lifecycle" ) - return "\n".join( - [ - "# Material Lifecycle", - "", - f"- status: `{payload.get('status')}`", - f"- capability_id: `{capability_id}`", - f"- contract_schemas: `{_collection_size(payload.get('contract_schemas'))}`", - "", - ] - ) + lines = [ + "# Material Lifecycle", + "", + f"- status: `{payload.get('status')}`", + f"- capability_id: `{capability_id}`", + ] + if payload.get("error"): + lines.append(f"- error: `{payload.get('error')}`") + else: + lines.append( + f"- contract_schemas: `{_collection_size(payload.get('contract_schemas'))}`" + ) + return "\n".join([*lines, ""]) def register_material_lifecycle_commands( @@ -82,6 +166,16 @@ def register_material_lifecycle_commands( help="Render the default-off Stage-0 Material Lifecycle contract.", ) add_subcommand_format(architecture) + plan_wave_rerank = commands.add_parser( + "plan-wave-rerank", + help="Preview a deterministic wave-based material rerank from exact-read annotations.", + ) + plan_wave_rerank.add_argument( + "--input", + required=True, + help="Path to a public-safe wave rerank JSON request or '-' for stdin.", + ) + add_subcommand_format(plan_wave_rerank) skill_status = commands.add_parser( "skill-status", help="Inspect the project-local managed loopx-material skill.", @@ -130,6 +224,8 @@ def handle_material_lifecycle_command( try: if args.material_lifecycle_command == "architecture": payload = build_material_lifecycle_architecture_packet() + elif args.material_lifecycle_command == "plan-wave-rerank": + payload = _load_wave_rerank_cli_payload(args.input) elif args.material_lifecycle_command == "skill-status": payload = inspect_project_material_skill( args.project, @@ -147,14 +243,28 @@ def handle_material_lifecycle_command( surfaces=tuple(args.surface or ("codex",)), execute=args.execute, ) - except (OSError, ValueError) as exc: - payload = { - "ok": False, - "status": "blocked", - "skill_id": "loopx-material", - "error": str(exc), - "surfaces": [], - } + except (OSError, ValueError, TypeError) as exc: + if args.material_lifecycle_command == "plan-wave-rerank": + payload = { + "ok": False, + "status": "blocked", + "capability": { + "capability_id": "material_lifecycle", + "default_enabled": False, + "creates_authority": False, + "mutates_core_state": False, + }, + "apply_authorized": False, + "error": str(exc), + } + else: + payload = { + "ok": False, + "status": "blocked", + "skill_id": "loopx-material", + "error": str(exc), + "surfaces": [], + } print_payload(payload, output_format(args), _render) return 2 print_payload(payload, output_format(args), _render) diff --git a/loopx/capabilities/material_lifecycle/ranking.py b/loopx/capabilities/material_lifecycle/ranking.py index d3372cd56c..5649c61f60 100644 --- a/loopx/capabilities/material_lifecycle/ranking.py +++ b/loopx/capabilities/material_lifecycle/ranking.py @@ -3,9 +3,15 @@ from __future__ import annotations import json +import math from collections.abc import Mapping, Sequence from typing import Any +from ...control_plane.effect_runtime import ( + EffectRuntimeRejected, + EffectRuntimeStartupError, + effect_runtime_result, +) from .ownership import MaterialProjectScope, material_owner_fields from ._validation import ( @@ -15,6 +21,7 @@ compact_text, compact_token, iso_timestamp, + nonnegative_int, packet_ref, positive_int, token_list, @@ -22,8 +29,22 @@ MATERIAL_RERANK_PROPOSAL_SCHEMA_VERSION = "material_rerank_proposal_v0" MATERIAL_RERANK_APPLY_RECEIPT_SCHEMA_VERSION = "material_rerank_apply_receipt_v0" +MATERIAL_WAVE_RERANK_PLAN_SCHEMA_VERSION = "material_wave_rerank_plan_v0" MATERIAL_RERANK_RECEIPT_MAX_REFS = 100 +_WAVE_ANNOTATION_FIELDS = { + "blocks_active_deliverable", + "decision_gain_score", + "effort_band", + "evidence_refs", + "evidence_role", + "horizon_wave", + "material_ref", + "primary_source_refs", + "reason_code", + "scoped_slice_ready", +} + _MOVE_FIELDS = { "evidence_refs", "from_rank", @@ -96,6 +117,192 @@ def plan_material_single_move( } +def _deduplicated_tokens_preserving_order( + values: Sequence[Any] | None, + *, + field: str, + max_items: int = 10_000, +) -> list[str]: + if values is None: + return [] + if isinstance(values, (str, bytes)) or not isinstance(values, Sequence): + raise TypeError(f"{field} must be a sequence of compact tokens") + if len(values) > max_items: + raise ValueError(f"{field} must contain at most {max_items} items") + seen: set[str] = set() + ordered: list[str] = [] + for value in values: + token = compact_token(value, field=f"{field}[]") + if token not in seen: + seen.add(token) + ordered.append(token) + return ordered + + +def _normalize_wave_annotations( + annotations: Sequence[Mapping[str, Any]] | None, +) -> list[dict[str, Any]]: + if annotations is None: + return [] + if isinstance(annotations, (str, bytes)) or not isinstance(annotations, Sequence): + raise TypeError("annotations must be a sequence of objects") + normalized: list[dict[str, Any]] = [] + for index, raw in enumerate(annotations): + field = f"annotations[{index}]" + check_record_keys( + raw, + field=field, + allowed=_WAVE_ANNOTATION_FIELDS, + required={"material_ref"}, + ) + entry: dict[str, Any] = { + "material_ref": compact_token( + raw["material_ref"], + field=f"{field}.material_ref", + ) + } + if raw.get("horizon_wave") is not None: + entry["horizon_wave"] = compact_token( + raw["horizon_wave"], + field=f"{field}.horizon_wave", + ) + if raw.get("effort_band") is not None: + entry["effort_band"] = compact_token( + raw["effort_band"], + field=f"{field}.effort_band", + ) + if raw.get("scoped_slice_ready") is not None: + scoped_ready = raw["scoped_slice_ready"] + if not isinstance(scoped_ready, bool): + raise ValueError(f"{field}.scoped_slice_ready must be a boolean") + entry["scoped_slice_ready"] = scoped_ready + if raw.get("decision_gain_score") is not None: + score = raw["decision_gain_score"] + if ( + isinstance(score, bool) + or not isinstance(score, (int, float)) + or not math.isfinite(score) + or not 0.0 <= float(score) <= 100.0 + ): + raise ValueError( + f"{field}.decision_gain_score must be a finite number between 0 and 100" + ) + entry["decision_gain_score"] = float(score) + if raw.get("evidence_role") is not None: + entry["evidence_role"] = compact_token( + raw["evidence_role"], + field=f"{field}.evidence_role", + ) + if raw.get("blocks_active_deliverable") is not None: + blocks = raw["blocks_active_deliverable"] + if not isinstance(blocks, bool): + raise ValueError(f"{field}.blocks_active_deliverable must be a boolean") + entry["blocks_active_deliverable"] = blocks + if raw.get("primary_source_refs") is not None: + entry["primary_source_refs"] = _deduplicated_tokens_preserving_order( + raw["primary_source_refs"], + field=f"{field}.primary_source_refs", + max_items=20, + ) + if raw.get("reason_code") is not None: + entry["reason_code"] = compact_token( + raw["reason_code"], + field=f"{field}.reason_code", + ) + if raw.get("evidence_refs") is not None: + entry["evidence_refs"] = token_list( + raw["evidence_refs"], + field=f"{field}.evidence_refs", + max_items=10, + ) + normalized.append(entry) + return normalized + + +def plan_material_wave_rerank( + ordered_material_refs: Sequence[str], + annotations: Sequence[Mapping[str, Any]] | None = None, + *, + top_window_size: int = 30, + protected_material_refs: Sequence[str] | None = None, + required_load_bearing_refs: Sequence[str] | None = None, + max_orthogonal_in_top_window: int = 0, + default_evidence_refs: Sequence[str] | None = None, +) -> tuple[list[str], dict[str, Any]]: + """Preview a deterministic multi-item wave rerank via the shared TS owner. + + Stratifies ranked items across horizon waves and effort bands, enforces + primary-over-secondary lineage precedence and active-deliverable shortlist + guarantees, caps orthogonal signals inside ``top_window_size``, preserves + exact set membership, protected anchor positions, and untouched backlog + relative order, and returns moves plus bounds ready for + ``build_material_rerank_proposal``. Never authorizes or executes a write. + """ + refs = _ordered_material_refs(ordered_material_refs, field="ordered_material_refs") + if not refs: + raise ValueError("ordered_material_refs must contain at least one item") + window = positive_int(top_window_size, field="top_window_size") + max_orthogonal = nonnegative_int( + max_orthogonal_in_top_window, + field="max_orthogonal_in_top_window", + ) + protected = token_list( + protected_material_refs, + field="protected_material_refs", + ) + required_primaries = _deduplicated_tokens_preserving_order( + required_load_bearing_refs, + field="required_load_bearing_refs", + ) + default_evidence = token_list( + default_evidence_refs, + field="default_evidence_refs", + max_items=10, + ) + normalized_annotations = _normalize_wave_annotations(annotations) + + request_payload: dict[str, Any] = { + "ordered_material_refs": refs, + "annotations": normalized_annotations, + "top_window_size": window, + "protected_material_refs": protected, + "required_load_bearing_refs": required_primaries, + "max_orthogonal_in_top_window": max_orthogonal, + "default_evidence_refs": default_evidence, + } + try: + result = effect_runtime_result( + "material_lifecycle.plan_wave_rerank", + request_payload, + ) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from exc + except EffectRuntimeStartupError as exc: + raise ValueError( + "material wave ranking runtime unavailable; run loopx doctor before retrying" + ) from exc + if ( + not isinstance(result, Mapping) + or result.get("schema_version") != MATERIAL_WAVE_RERANK_PLAN_SCHEMA_VERSION + ): + raise TypeError("typed material wave rerank result mismatch") + reordered = [str(ref) for ref in result["reordered_material_refs"]] + summary: dict[str, Any] = { + "schema_version": result["schema_version"], + "top_window_size": result["top_window_size"], + "max_orthogonal_in_top_window": result["max_orthogonal_in_top_window"], + "no_change": result["no_change"], + "constraints": dict(result["constraints"]), + "moves": [dict(move) for move in result["moves"]], + "wave_counts": dict(result["wave_counts"]), + "top_window_orthogonal_count": result["top_window_orthogonal_count"], + "demoted_monograph_refs": list(result["demoted_monograph_refs"]), + "shifted_orthogonal_refs": list(result["shifted_orthogonal_refs"]), + "lineage_promoted_refs": list(result["lineage_promoted_refs"]), + } + return reordered, summary + + def material_rerank_receipt_chunks( applied_material_refs: Sequence[str], ) -> list[list[str]]: diff --git a/loopx/capabilities/project_skill_delivery/README.md b/loopx/capabilities/project_skill_delivery/README.md index 025bd6bf37..c1257682b2 100644 --- a/loopx/capabilities/project_skill_delivery/README.md +++ b/loopx/capabilities/project_skill_delivery/README.md @@ -32,7 +32,9 @@ loopx project-skill install \ | Surface | Project target | | --- | --- | | `codex` | `.agents/skills//` | +| `antigravity` | `.agent/skills//` | | `claude-code` | `.claude/skills//` | +| `kiro-cli` | `.kiro/skills//` | | `opencode` | `.opencode/skills//` | | `pi` | `.pi/skills//` | diff --git a/loopx/capabilities/project_skill_delivery/core.py b/loopx/capabilities/project_skill_delivery/core.py index 0f5be71ce6..54bd04cb19 100644 --- a/loopx/capabilities/project_skill_delivery/core.py +++ b/loopx/capabilities/project_skill_delivery/core.py @@ -18,6 +18,7 @@ PROJECT_SKILL_MARKER_SCHEMA_VERSION = "loopx_managed_project_skill_v0" PROJECT_SKILL_SURFACE_ROOTS = { "codex": Path(".agents") / "skills", + "antigravity": Path(".agent") / "skills", "claude-code": Path(".claude") / "skills", "kiro-cli": Path(".kiro") / "skills", "opencode": Path(".opencode") / "skills", diff --git a/loopx/control_plane/capabilities/material_wave_ranking.ts b/loopx/control_plane/capabilities/material_wave_ranking.ts new file mode 100644 index 0000000000..896159f5be --- /dev/null +++ b/loopx/control_plane/capabilities/material_wave_ranking.ts @@ -0,0 +1,1126 @@ +/** Deterministic wave-based material reranking preview for Material Lifecycle. + * + * Pure, browser-safe TypeScript decision owner with zero host or storage IO. + * Produces bounded rerank deltas and constraints; never authorizes source writes. + */ +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {requireJsonObject, requireStringLiteral} from "../runtime_decode.ts"; + +export const MATERIAL_WAVE_RERANK_PLAN_SCHEMA_VERSION = + "material_wave_rerank_plan_v0"; + +export const MATERIAL_HORIZON_WAVES = [ + "imminent_gate", + "active_sprint", + "foundational_track", + "orthogonal_backlog", +] as const; +export type MaterialHorizonWave = (typeof MATERIAL_HORIZON_WAVES)[number]; + +export const MATERIAL_EFFECTIVE_WAVES = [ + "imminent_gate", + "active_sprint", + "foundational_track", + "unannotated_retention", + "orthogonal_backlog", +] as const; +export type MaterialEffectiveWave = (typeof MATERIAL_EFFECTIVE_WAVES)[number]; + +export const MATERIAL_EFFORT_BANDS = [ + "quick_slice", + "focused_study", + "deep_monograph", +] as const; +export type MaterialEffortBand = (typeof MATERIAL_EFFORT_BANDS)[number]; + +export const MATERIAL_EVIDENCE_ROLES = [ + "load_bearing_primary", + "method_comparator", + "foundational_reference", + "secondary_or_commentary", + "orthogonal_signal", +] as const; +export type MaterialEvidenceRole = (typeof MATERIAL_EVIDENCE_ROLES)[number]; + +const COMPACT_TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/; +const MAX_QUEUE_REFS = 10_000; +const MAX_PROTECTED_REFS = 100; +const MAX_PRIMARY_SOURCE_REFS = 20; +const MAX_EVIDENCE_REFS = 10; + +const UNSAFE_FIELDS = new Set([ + "api_key", + "content", + "credential", + "credentials", + "private_locator", + "provider_payload", + "raw_chat", + "raw_content", + "raw_provider_payload", + "token", + "tool_output", +]); + +const REQUEST_ALLOWED_FIELDS = new Set([ + "annotations", + "default_evidence_refs", + "max_orthogonal_in_top_window", + "ordered_material_refs", + "protected_material_refs", + "required_load_bearing_refs", + "top_window_size", +]); + +const ANNOTATION_ALLOWED_FIELDS = new Set([ + "blocks_active_deliverable", + "decision_gain_score", + "effort_band", + "evidence_refs", + "evidence_role", + "horizon_wave", + "material_ref", + "primary_source_refs", + "reason_code", + "scoped_slice_ready", +]); + +const ACTIVE_WAVE_ORDER: Readonly> = { + imminent_gate: 0, + active_sprint: 1, + foundational_track: 2, +}; + +const EVIDENCE_ROLE_PRIORITY: Readonly> = { + load_bearing_primary: 0, + method_comparator: 1, + foundational_reference: 2, + secondary_or_commentary: 3, + orthogonal_signal: 4, +}; + +const EFFORT_BAND_ORDER: Readonly> = { + quick_slice: 0, + focused_study: 1, + deep_monograph: 2, +}; + +// Exact integer multipliers for LCM(1, 2, 6) = 6 so cost_adjusted_voi comparisons +// never suffer IEEE-754 1/6 rounding drift. +const EFFORT_SCALED_MULTIPLIER: Readonly> = { + quick_slice: 6, + focused_study: 3, + deep_monograph: 1, +}; + +interface NormalizedItem { + readonly materialRef: string; + readonly originalRank: number; + readonly annotated: boolean; + readonly declaredWave: MaterialHorizonWave | null; + readonly effectiveWave: MaterialEffectiveWave; + readonly effortBand: MaterialEffortBand | null; + readonly scopedSliceReady: boolean; + readonly decisionGainScore: number; + readonly scaledVoi: number; + readonly evidenceRole: MaterialEvidenceRole | null; + readonly blocksActiveDeliverable: boolean; + readonly primarySourceRefs: readonly string[]; + readonly explicitReasonCode: string | null; + readonly evidenceRefs: readonly string[] | null; + readonly monographStratified: boolean; + readonly isOrthogonal: boolean; +} + +function fail(message: string): never { + throw new EffectRuntimeRequestError(message); +} + +function checkAllowedFields( + record: JsonObject, + field: string, + allowed: ReadonlySet, + required: readonly string[] = [], +): void { + const keys = Object.keys(record); + const unsafe = keys.filter((key) => UNSAFE_FIELDS.has(key)).sort(); + if (unsafe.length > 0) { + fail(`${field} contains unsafe fields: ${unsafe.join(", ")}`); + } + const unexpected = keys.filter((key) => !allowed.has(key)).sort(); + if (unexpected.length > 0) { + fail(`${field} contains unsupported fields: ${unexpected.join(", ")}`); + } + const missing = required.filter((key) => record[key] === undefined || record[key] === null).sort(); + if (missing.length > 0) { + fail(`${field} is missing required fields: ${missing.join(", ")}`); + } +} + +function compactToken(value: unknown, field: string): string { + if (typeof value !== "string") { + fail(`${field} must be a compact token string`); + } + const normalized = value.trim().replace(/\s+/g, " "); + if (!normalized) { + fail(`${field} must be non-empty`); + } + if (normalized.length > 128) { + fail(`${field} must be at most 128 characters`); + } + if (normalized.includes("://") || normalized.startsWith("/")) { + fail(`${field} must use an opaque compact token, not a path or URL`); + } + if (!COMPACT_TOKEN_RE.test(normalized)) { + fail(`${field} must contain only letters, digits, dot, colon, dash, or underscore`); + } + return normalized; +} + +function positiveInt(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + fail(`${field} must be a positive integer`); + } + return value; +} + +function nonNegativeInt(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + fail(`${field} must be a non-negative integer`); + } + return value; +} + +function optionalBoolean(value: unknown, field: string, fallback: boolean): boolean { + if (value === undefined || value === null) return fallback; + if (typeof value !== "boolean") { + fail(`${field} must be a boolean`); + } + return value; +} + +function optionalGainScore(value: unknown, field: string): number { + if (value === undefined || value === null) return 0; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) { + fail(`${field} must be a finite number between 0 and 100`); + } + return value; +} + +function uniqueOrderedTokens( + value: unknown, + field: string, + maxItems: number, + requireNonEmpty = false, +): string[] { + if (!Array.isArray(value)) { + fail(`${field} must be an array of compact tokens`); + } + if (requireNonEmpty && value.length === 0) { + fail(`${field} must contain at least one item`); + } + if (value.length > maxItems) { + fail(`${field} must contain at most ${maxItems} items`); + } + const result: string[] = []; + const seen = new Set(); + for (let index = 0; index < value.length; index++) { + const token = compactToken(value[index], `${field}[${index}]`); + if (seen.has(token)) { + fail(`${field} values must be unique`); + } + seen.add(token); + result.push(token); + } + return result; +} + +function deduplicatedTokenList( + value: unknown, + field: string, + maxItems: number, + sortOutput: boolean, +): string[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + fail(`${field} must be an array of compact tokens`); + } + if (value.length > maxItems) { + fail(`${field} must contain at most ${maxItems} items`); + } + const result: string[] = []; + const seen = new Set(); + for (let index = 0; index < value.length; index++) { + const token = compactToken(value[index], `${field}[${index}]`); + if (!seen.has(token)) { + seen.add(token); + result.push(token); + } + } + return sortOutput ? [...result].sort() : result; +} + +function compareWithinWave(left: NormalizedItem, right: NormalizedItem): number { + if (left.blocksActiveDeliverable !== right.blocksActiveDeliverable) { + return left.blocksActiveDeliverable ? -1 : 1; + } + const leftRole = left.evidenceRole === null ? 2 : EVIDENCE_ROLE_PRIORITY[left.evidenceRole]; + const rightRole = right.evidenceRole === null ? 2 : EVIDENCE_ROLE_PRIORITY[right.evidenceRole]; + if (leftRole !== rightRole) { + return leftRole - rightRole; + } + if (left.scaledVoi !== right.scaledVoi) { + return right.scaledVoi - left.scaledVoi; + } + const leftEffort = left.effortBand === null ? 1 : EFFORT_BAND_ORDER[left.effortBand]; + const rightEffort = right.effortBand === null ? 1 : EFFORT_BAND_ORDER[right.effortBand]; + if (leftEffort !== rightEffort) { + return leftEffort - rightEffort; + } + return left.originalRank - right.originalRank; +} + +function findCyclePath( + nodes: readonly string[], + succs: ReadonlyMap>, +): string[] { + const state = new Map(); + const stack: string[] = []; + const dfs = (node: string): string[] | null => { + state.set(node, 1); + stack.push(node); + for (const next of succs.get(node) ?? []) { + const nextState = state.get(next) ?? 0; + if (nextState === 1) { + const cycleStart = stack.indexOf(next); + return [...stack.slice(cycleStart), next]; + } + if (nextState === 0) { + const found = dfs(next); + if (found) return found; + } + } + stack.pop(); + state.set(node, 2); + return null; + }; + for (const node of nodes) { + if ((state.get(node) ?? 0) === 0) { + const found = dfs(node); + if (found) return found; + } + } + return []; +} + +function topologicalOrderOrFail( + nodes: readonly string[], + preds: ReadonlyMap>, + succs: ReadonlyMap>, +): string[] { + const inDegree = new Map(); + const queue: string[] = []; + for (const node of nodes) { + const degree = preds.get(node)?.size ?? 0; + inDegree.set(node, degree); + if (degree === 0) queue.push(node); + } + const topo: string[] = []; + let head = 0; + while (head < queue.length) { + const current = queue[head++]!; + topo.push(current); + for (const next of succs.get(current) ?? []) { + const updated = (inDegree.get(next) ?? 0) - 1; + inDegree.set(next, updated); + if (updated === 0) queue.push(next); + } + } + if (topo.length !== nodes.length) { + const cycle = findCyclePath(nodes, succs); + fail(`primary_source_refs cycle detected: ${cycle.join(" -> ")}`); + } + return topo; +} + +export function planMaterialWaveRerank(rawRequest: JsonObject): JsonObject { + const request = requireJsonObject(rawRequest, "material wave rerank request"); + checkAllowedFields(request, "request", REQUEST_ALLOWED_FIELDS, ["ordered_material_refs"]); + + const orderedRefs = uniqueOrderedTokens( + request.ordered_material_refs, + "ordered_material_refs", + MAX_QUEUE_REFS, + true, + ); + const totalCount = orderedRefs.length; + const originalRankByRef = new Map(); + orderedRefs.forEach((ref, index) => { + originalRankByRef.set(ref, index + 1); + }); + + const topWindowSize = + request.top_window_size === undefined || request.top_window_size === null + ? 30 + : positiveInt(request.top_window_size, "top_window_size"); + const maxOrthogonalInTopWindow = + request.max_orthogonal_in_top_window === undefined || + request.max_orthogonal_in_top_window === null + ? 0 + : nonNegativeInt(request.max_orthogonal_in_top_window, "max_orthogonal_in_top_window"); + + const protectedRefs = deduplicatedTokenList( + request.protected_material_refs, + "protected_material_refs", + MAX_PROTECTED_REFS, + true, + ); + const protectedSet = new Set(protectedRefs); + for (const ref of protectedRefs) { + if (!originalRankByRef.has(ref)) { + fail("protected_material_refs must belong to the ranked queue"); + } + } + + const requiredLoadBearingRefs = deduplicatedTokenList( + request.required_load_bearing_refs, + "required_load_bearing_refs", + MAX_QUEUE_REFS, + false, + ); + const missingLoadBearing = requiredLoadBearingRefs.filter( + (ref) => !originalRankByRef.has(ref), + ); + if (missingLoadBearing.length > 0) { + fail( + `missing or unranked required load-bearing primary materials (settle candidate intake first): ${missingLoadBearing.join(", ")}`, + ); + } + + const defaultEvidenceRefs = deduplicatedTokenList( + request.default_evidence_refs, + "default_evidence_refs", + MAX_EVIDENCE_REFS, + true, + ); + + const rawAnnotations = + request.annotations === undefined || request.annotations === null + ? [] + : request.annotations; + if (!Array.isArray(rawAnnotations)) { + fail("annotations must be an array of objects"); + } + if (rawAnnotations.length > totalCount) { + fail("annotations cannot exceed ordered_material_refs length"); + } + + const annotationByRef = new Map(); + for (let index = 0; index < rawAnnotations.length; index++) { + const field = `annotations[${index}]`; + const record = requireJsonObject(rawAnnotations[index], field); + checkAllowedFields(record, field, ANNOTATION_ALLOWED_FIELDS, ["material_ref"]); + const materialRef = compactToken(record.material_ref, `${field}.material_ref`); + const originalRank = originalRankByRef.get(materialRef); + if (originalRank === undefined) { + fail( + `ranked material identity required; use intake settlement for unranked entry: ${materialRef}`, + ); + } + if (annotationByRef.has(materialRef)) { + fail(`annotations material_ref values must be unique: ${materialRef}`); + } + + const blocksActiveDeliverable = optionalBoolean( + record.blocks_active_deliverable, + `${field}.blocks_active_deliverable`, + false, + ); + const rawRole = + record.evidence_role === undefined || record.evidence_role === null + ? null + : requireStringLiteral( + record.evidence_role, + MATERIAL_EVIDENCE_ROLES, + `${field}.evidence_role`, + `unsupported evidence_role: ${String(record.evidence_role)}`, + ); + const declaredWave: MaterialHorizonWave = + record.horizon_wave === undefined || record.horizon_wave === null + ? rawRole === "orthogonal_signal" + ? "orthogonal_backlog" + : blocksActiveDeliverable + ? "imminent_gate" + : "active_sprint" + : requireStringLiteral( + record.horizon_wave, + MATERIAL_HORIZON_WAVES, + `${field}.horizon_wave`, + `unsupported horizon_wave: ${String(record.horizon_wave)}`, + ); + const evidenceRole: MaterialEvidenceRole = + rawRole ?? + (declaredWave === "orthogonal_backlog" + ? "orthogonal_signal" + : "foundational_reference"); + const effortBand: MaterialEffortBand = + record.effort_band === undefined || record.effort_band === null + ? "focused_study" + : requireStringLiteral( + record.effort_band, + MATERIAL_EFFORT_BANDS, + `${field}.effort_band`, + `unsupported effort_band: ${String(record.effort_band)}`, + ); + const scopedSliceReady = optionalBoolean( + record.scoped_slice_ready, + `${field}.scoped_slice_ready`, + false, + ); + const decisionGainScore = optionalGainScore( + record.decision_gain_score, + `${field}.decision_gain_score`, + ); + const primarySourceRefs = deduplicatedTokenList( + record.primary_source_refs, + `${field}.primary_source_refs`, + MAX_PRIMARY_SOURCE_REFS, + false, + ); + if (primarySourceRefs.includes(materialRef)) { + fail(`primary_source_refs cycle detected: ${materialRef} -> ${materialRef}`); + } + const explicitReasonCode = + record.reason_code === undefined || record.reason_code === null + ? null + : compactToken(record.reason_code, `${field}.reason_code`); + const evidenceRefs = + record.evidence_refs === undefined || record.evidence_refs === null + ? null + : deduplicatedTokenList( + record.evidence_refs, + `${field}.evidence_refs`, + MAX_EVIDENCE_REFS, + true, + ); + + const monographStratified = + effortBand === "deep_monograph" && + !scopedSliceReady && + (declaredWave === "imminent_gate" || declaredWave === "active_sprint"); + const effectiveWave: MaterialEffectiveWave = monographStratified + ? "foundational_track" + : declaredWave; + const isOrthogonal = + effectiveWave === "orthogonal_backlog" || evidenceRole === "orthogonal_signal"; + + annotationByRef.set(materialRef, { + materialRef, + originalRank, + annotated: true, + declaredWave, + effectiveWave, + effortBand, + scopedSliceReady, + decisionGainScore, + scaledVoi: decisionGainScore * EFFORT_SCALED_MULTIPLIER[effortBand], + evidenceRole, + blocksActiveDeliverable, + primarySourceRefs, + explicitReasonCode, + evidenceRefs, + monographStratified, + isOrthogonal, + }); + } + + const itemByRef = new Map(); + for (const ref of orderedRefs) { + const existing = annotationByRef.get(ref); + if (existing) { + itemByRef.set(ref, existing); + } else { + itemByRef.set(ref, { + materialRef: ref, + originalRank: originalRankByRef.get(ref)!, + annotated: false, + declaredWave: null, + effectiveWave: "unannotated_retention", + effortBand: null, + scopedSliceReady: false, + decisionGainScore: 0, + scaledVoi: 0, + evidenceRole: null, + blocksActiveDeliverable: false, + primarySourceRefs: [], + explicitReasonCode: null, + evidenceRefs: null, + monographStratified: false, + isOrthogonal: false, + }); + } + } + + // Build primary-over-secondary lineage DAG across ranked refs. + const preds = new Map>(); + const succs = new Map>(); + for (const ref of orderedRefs) { + preds.set(ref, new Set()); + succs.set(ref, new Set()); + } + for (const item of itemByRef.values()) { + for (const primaryRef of item.primarySourceRefs) { + if (originalRankByRef.has(primaryRef)) { + preds.get(item.materialRef)!.add(primaryRef); + succs.get(primaryRef)!.add(item.materialRef); + } + } + } + + const topoOrder = topologicalOrderOrFail(orderedRefs, preds, succs); + + // Map locked anchor ranks and open ranks. + const lockedRankToRef = new Map(); + for (const ref of protectedRefs) { + lockedRankToRef.set(originalRankByRef.get(ref)!, ref); + } + const openRanks: number[] = []; + for (let rank = 1; rank <= totalCount; rank++) { + if (!lockedRankToRef.has(rank)) openRanks.push(rank); + } + + const prevOpenRank = (limitRank: number): number => { + let best = 0; + for (const openRank of openRanks) { + if (openRank <= limitRank) best = openRank; + else break; + } + return best; + }; + + // Validate rank intervals implied by protected anchors and lineage edges. + const minRank = new Map(); + const maxRank = new Map(); + for (const ref of orderedRefs) { + const locked = originalRankByRef.get(ref)!; + minRank.set(ref, protectedSet.has(ref) ? locked : 1); + maxRank.set(ref, protectedSet.has(ref) ? locked : totalCount); + } + for (const ref of topoOrder) { + const currentMin = minRank.get(ref)!; + for (const next of succs.get(ref)!) { + const candidate = currentMin + 1; + if (candidate > minRank.get(next)!) { + minRank.set(next, candidate); + } + } + } + for (let index = topoOrder.length - 1; index >= 0; index--) { + const ref = topoOrder[index]!; + let currentMax = maxRank.get(ref)!; + for (const next of succs.get(ref)!) { + const candidate = maxRank.get(next)! - 1; + if (candidate < currentMax) { + currentMax = candidate; + } + } + if (!protectedSet.has(ref)) { + currentMax = prevOpenRank(currentMax); + } + maxRank.set(ref, currentMax); + if (currentMax < 1 || minRank.get(ref)! > currentMax) { + fail( + `protected anchor inversion prevents primary-over-secondary ordering for ${ref}`, + ); + } + } + + // Stage A: Partition movable items by effective wave and apply within-wave cost-adjusted VOI. + const activeWaveItems: NormalizedItem[] = []; + const unannotatedItems: NormalizedItem[] = []; + const orthogonalWaveItems: NormalizedItem[] = []; + for (const ref of orderedRefs) { + if (protectedSet.has(ref)) continue; + const item = itemByRef.get(ref)!; + if ( + item.effectiveWave === "imminent_gate" || + item.effectiveWave === "active_sprint" || + item.effectiveWave === "foundational_track" + ) { + activeWaveItems.push(item); + } else if (item.effectiveWave === "orthogonal_backlog") { + orthogonalWaveItems.push(item); + } else { + unannotatedItems.push(item); + } + } + + activeWaveItems.sort((left, right) => { + const waveDiff = + ACTIVE_WAVE_ORDER[left.effectiveWave]! - + ACTIVE_WAVE_ORDER[right.effectiveWave]!; + return waveDiff !== 0 ? waveDiff : compareWithinWave(left, right); + }); + orthogonalWaveItems.sort(compareWithinWave); + unannotatedItems.sort((left, right) => left.originalRank - right.originalRank); + + const effectiveTopWindow = Math.min(topWindowSize, totalCount); + let protectedInTop = 0; + let protectedOrthInTop = 0; + for (const [rank, ref] of lockedRankToRef.entries()) { + if (rank <= effectiveTopWindow) { + protectedInTop++; + if (itemByRef.get(ref)!.isOrthogonal) { + protectedOrthInTop++; + } + } + } + const openSlotsInTop = effectiveTopWindow - protectedInTop; + const totalNonOrthogonalCount = orderedRefs.filter( + (ref) => !itemByRef.get(ref)!.isOrthogonal, + ).length; + if ( + protectedOrthInTop > maxOrthogonalInTopWindow && + totalNonOrthogonalCount > effectiveTopWindow - protectedOrthInTop + ) { + fail( + "protected orthogonal anchors in top_window_size exceed max_orthogonal_in_top_window", + ); + } + + const allowedMovableOrthInTop = Math.max( + 0, + maxOrthogonalInTopWindow - protectedOrthInTop, + ); + const candidatesBeforeBacklog = [...activeWaveItems, ...unannotatedItems]; + let nonOrthRemaining = candidatesBeforeBacklog.filter( + (item) => !item.isOrthogonal, + ).length; + + const topWindowMovable: NormalizedItem[] = []; + const deferredOrthogonal: NormalizedItem[] = []; + const remainingActiveWaves: NormalizedItem[] = []; + const remainingPassive: NormalizedItem[] = []; + const deferredByCapSet = new Set(); + let movableOrthUsed = 0; + + for (const item of candidatesBeforeBacklog) { + if (!item.isOrthogonal) { + nonOrthRemaining--; + } + if (topWindowMovable.length < openSlotsInTop) { + const slotsStillNeededAfterSkip = openSlotsInTop - topWindowMovable.length; + if (item.isOrthogonal) { + if ( + movableOrthUsed < allowedMovableOrthInTop || + nonOrthRemaining < slotsStillNeededAfterSkip + ) { + topWindowMovable.push(item); + movableOrthUsed++; + } else { + deferredOrthogonal.push(item); + deferredByCapSet.add(item.materialRef); + } + } else { + topWindowMovable.push(item); + } + } else if ( + item.effectiveWave === "imminent_gate" || + item.effectiveWave === "active_sprint" || + item.effectiveWave === "foundational_track" + ) { + remainingActiveWaves.push(item); + } else { + remainingPassive.push(item); + } + } + + const initialMovable = [ + ...topWindowMovable, + ...remainingActiveWaves, + ...deferredOrthogonal, + ...orthogonalWaveItems, + ...remainingPassive, + ].map((item) => item.materialRef); + + const basePos = new Map(); + initialMovable.forEach((ref, index) => { + basePos.set(ref, index); + }); + + // Compute transitive movable predecessors so lineage pull-up works even across + // an intermediate protected anchor. + const movableAncestors = new Map(); + for (const ref of topoOrder) { + const ancestors = new Set(); + for (const pred of preds.get(ref)!) { + if (!protectedSet.has(pred)) { + ancestors.add(pred); + } + for (const transitive of movableAncestors.get(pred) ?? []) { + ancestors.add(transitive); + } + } + movableAncestors.set( + ref, + [...ancestors].sort((a, b) => basePos.get(a)! - basePos.get(b)!), + ); + } + + // Pull buried movable primary sources immediately ahead of dependent secondary items. + const emitted = new Set(); + const pulledList: string[] = []; + const lineagePromotedSet = new Set(); + const emitWithAncestors = (ref: string, pulledForDescendant: boolean): void => { + if (emitted.has(ref) || protectedSet.has(ref)) return; + for (const ancestor of movableAncestors.get(ref) ?? []) { + if (!emitted.has(ancestor)) { + emitWithAncestors(ancestor, true); + } + } + if (pulledForDescendant) { + lineagePromotedSet.add(ref); + } + emitted.add(ref); + pulledList.push(ref); + }; + for (const ref of initialMovable) { + emitWithAncestors(ref, false); + } + + const pulledPos = new Map(); + pulledList.forEach((ref, index) => { + pulledPos.set(ref, index); + }); + + // Schedule ranks 1..N honoring protected anchors, predecessor readiness, and deadlines. + const unplacedPredCount = new Map(); + for (const ref of orderedRefs) { + unplacedPredCount.set(ref, preds.get(ref)!.size); + } + const unplacedMovable = new Set(pulledList); + let finalOrder: string[] = []; + + const countOpenSlotsInRange = (startRank: number, endRank: number): number => { + let count = 0; + for (const openRank of openRanks) { + if (openRank >= startRank && openRank <= endRank) count++; + else if (openRank > endRank) break; + } + return count; + }; + + for (let rank = 1; rank <= totalCount; rank++) { + const lockedRef = lockedRankToRef.get(rank); + if (lockedRef !== undefined) { + if ((unplacedPredCount.get(lockedRef) ?? 0) !== 0) { + const missingPreds = [...preds.get(lockedRef)!].filter( + (pred) => !finalOrder.includes(pred), + ); + fail( + `protected anchor inversion: primary source ${missingPreds.join(", ")} cannot precede protected material ${lockedRef}`, + ); + } + finalOrder.push(lockedRef); + for (const next of succs.get(lockedRef)!) { + unplacedPredCount.set(next, unplacedPredCount.get(next)! - 1); + } + continue; + } + + // Check protected-descendant deadlines among remaining movable items. + let tightDeadline: number | null = null; + const distinctDeadlines = new Set(); + for (const candidate of unplacedMovable) { + const deadline = maxRank.get(candidate)!; + if (deadline < rank) { + fail( + `protected anchor inversion: primary source ${candidate} missed deadline before protected material`, + ); + } + if (deadline < totalCount) { + distinctDeadlines.add(deadline); + } + } + for (const deadline of [...distinctDeadlines].sort((a, b) => a - b)) { + let dueCount = 0; + for (const candidate of unplacedMovable) { + if (maxRank.get(candidate)! <= deadline) dueCount++; + } + const availableOpen = countOpenSlotsInRange(rank, deadline); + if (dueCount > availableOpen) { + fail( + `protected anchor inversion: insufficient open ranks before rank ${deadline + 1}`, + ); + } + if (dueCount === availableOpen && tightDeadline === null) { + tightDeadline = deadline; + } + } + + let chosen: string | null = null; + let bestPulledIndex = Number.POSITIVE_INFINITY; + for (const candidate of unplacedMovable) { + if (unplacedPredCount.get(candidate) !== 0) continue; + if (tightDeadline !== null && maxRank.get(candidate)! > tightDeadline) { + continue; + } + const candidatePos = pulledPos.get(candidate)!; + if (candidatePos < bestPulledIndex) { + bestPulledIndex = candidatePos; + chosen = candidate; + } + } + if (chosen === null) { + fail( + `protected anchor inversion prevents scheduling open rank ${rank}`, + ); + } + unplacedMovable.delete(chosen); + finalOrder.push(chosen); + for (const next of succs.get(chosen)!) { + unplacedPredCount.set(next, unplacedPredCount.get(next)! - 1); + } + } + + // Stage B: Re-verify Top-Window orthogonal cap after lineage pull-up / anchor delays. + const currentRankOf = (order: readonly string[]): Map => { + const map = new Map(); + order.forEach((ref, index) => map.set(ref, index + 1)); + return map; + }; + + let rankMap = currentRankOf(finalOrder); + const countOrthInTop = (order: readonly string[]): number => { + let count = 0; + for (let index = 0; index < effectiveTopWindow; index++) { + if (itemByRef.get(order[index]!)!.isOrthogonal) count++; + } + return count; + }; + const hasNonOrthBelowTop = (order: readonly string[]): boolean => { + for (let index = effectiveTopWindow; index < order.length; index++) { + if (!itemByRef.get(order[index]!)!.isOrthogonal) return true; + } + return false; + }; + const rebuildFinalOrder = (movableOrder: readonly string[]): string[] => { + const rebuilt = [...finalOrder]; + let cursor = 0; + for (let index = 0; index < totalCount; index++) { + if (!lockedRankToRef.has(index + 1)) { + rebuilt[index] = movableOrder[cursor++]!; + } + } + return rebuilt; + }; + const preservesPrimaryPrecedence = (order: readonly string[]): boolean => { + const ranks = currentRankOf(order); + for (const [ref, predSet] of preds.entries()) { + for (const pred of predSet) { + if (ranks.get(pred)! >= ranks.get(ref)!) return false; + } + } + return true; + }; + + while ( + countOrthInTop(finalOrder) > maxOrthogonalInTopWindow && + hasNonOrthBelowTop(finalOrder) + ) { + // Find rightmost movable orthogonal item in Top Window with no successors in Top Window. + let orthMovableIndex = -1; + const movableSeq = finalOrder.filter((ref) => !protectedSet.has(ref)); + for (let mIdx = openSlotsInTop - 1; mIdx >= 0; mIdx--) { + const candidate = movableSeq[mIdx]!; + if (!itemByRef.get(candidate)!.isOrthogonal) continue; + const hasTopWindowSuccessor = [...succs.get(candidate)!].some( + (succ) => rankMap.get(succ)! <= effectiveTopWindow, + ); + if (!hasTopWindowSuccessor) { + orthMovableIndex = mIdx; + break; + } + } + // Find leftmost movable non-orthogonal item below Top Window whose predecessors are all in Top Window. + let replacementMovableIndex = -1; + let replacementFinalOrder: string[] | null = null; + for (let mIdx = openSlotsInTop; mIdx < movableSeq.length; mIdx++) { + const candidate = movableSeq[mIdx]!; + if (itemByRef.get(candidate)!.isOrthogonal) continue; + const allPredsInTop = [...preds.get(candidate)!].every( + (pred) => rankMap.get(pred)! <= effectiveTopWindow, + ); + if (!allPredsInTop) continue; + const candidateMovable = [...movableSeq]; + candidateMovable.splice(mIdx, 1); + candidateMovable.splice(orthMovableIndex, 1); + candidateMovable.splice(openSlotsInTop - 1, 0, candidate); + candidateMovable.splice(openSlotsInTop, 0, movableSeq[orthMovableIndex]!); + const candidateOrder = rebuildFinalOrder(candidateMovable); + if (preservesPrimaryPrecedence(candidateOrder)) { + replacementMovableIndex = mIdx; + replacementFinalOrder = candidateOrder; + break; + } + } + if ( + orthMovableIndex === -1 || + replacementMovableIndex === -1 || + replacementFinalOrder === null + ) { + fail( + "cannot satisfy max_orthogonal_in_top_window without violating primary_source_refs or protected anchors", + ); + } + const shiftedOrth = movableSeq[orthMovableIndex]!; + const promotedNonOrth = movableSeq[replacementMovableIndex]!; + deferredByCapSet.add(shiftedOrth); + movableSeq.splice(replacementMovableIndex, 1); + movableSeq.splice(orthMovableIndex, 1); + movableSeq.splice(openSlotsInTop - 1, 0, promotedNonOrth); + movableSeq.splice(openSlotsInTop, 0, shiftedOrth); + finalOrder = replacementFinalOrder; + rankMap = currentRankOf(finalOrder); + } + + // Final closed-loop postcondition verification across all 4 structural invariants. + if (finalOrder.length !== totalCount || new Set(finalOrder).size !== totalCount) { + fail("wave rerank failed lossless membership verification"); + } + for (const ref of protectedRefs) { + if (rankMap.get(ref) !== originalRankByRef.get(ref)) { + fail(`wave rerank violated protected rank for ${ref}`); + } + } + for (const [ref, predSet] of preds.entries()) { + const secondaryRank = rankMap.get(ref)!; + for (const pred of predSet) { + if (rankMap.get(pred)! >= secondaryRank) { + fail( + `wave rerank violated primary-over-secondary precedence: ${pred} must precede ${ref}`, + ); + } + } + } + for (const item of itemByRef.values()) { + if ( + item.blocksActiveDeliverable && + item.evidenceRole === "load_bearing_primary" + ) { + const landedRank = rankMap.get(item.materialRef)!; + if (landedRank > topWindowSize) { + fail( + `load-bearing deliverable blocker ${item.materialRef} landed at rank ${landedRank} outside top_window_size=${topWindowSize} (open_top_window_slots=${openSlotsInTop})`, + ); + } + } + } + + let prevUntouchedOriginalRank = 0; + for (const ref of finalOrder) { + const item = itemByRef.get(ref)!; + if ( + !item.annotated && + !protectedSet.has(ref) && + (succs.get(ref)?.size ?? 0) === 0 + ) { + if (item.originalRank <= prevUntouchedOriginalRank) { + fail("wave rerank violated relative order of unannotated materials"); + } + prevUntouchedOriginalRank = item.originalRank; + } + } + + // Assemble moves, bounds, wave counts, and audit lists. + const waveCounts: Record = { + imminent_gate: 0, + active_sprint: 0, + foundational_track: 0, + unannotated_retention: 0, + orthogonal_backlog: 0, + }; + const demotedMonographRefs: string[] = []; + const shiftedOrthogonalRefs: string[] = []; + const lineagePromotedRefs: string[] = []; + const moves: JsonObject[] = []; + let maxDisplacement = 1; + + for (let index = 0; index < finalOrder.length; index++) { + const ref = finalOrder[index]!; + const toRank = index + 1; + const item = itemByRef.get(ref)!; + waveCounts[item.effectiveWave]++; + if (item.monographStratified) { + demotedMonographRefs.push(ref); + } + if ( + item.isOrthogonal && + toRank > effectiveTopWindow && + (deferredByCapSet.has(ref) || item.originalRank <= effectiveTopWindow) + ) { + shiftedOrthogonalRefs.push(ref); + } + if (lineagePromotedSet.has(ref) && toRank < item.originalRank) { + lineagePromotedRefs.push(ref); + } + if (item.originalRank !== toRank) { + const displacement = Math.abs(item.originalRank - toRank); + if (displacement > maxDisplacement) { + maxDisplacement = displacement; + } + let reasonCode = item.explicitReasonCode; + if (reasonCode === null) { + if (item.monographStratified) { + reasonCode = "unsliced_monograph_stratified"; + } else if ( + item.isOrthogonal && + toRank > effectiveTopWindow && + (deferredByCapSet.has(ref) || item.evidenceRole === "orthogonal_signal") + ) { + reasonCode = "orthogonal_top_window_cap"; + } else if (lineagePromotedSet.has(ref) && toRank < item.originalRank) { + reasonCode = "primary_source_lineage_precedence"; + } else if (item.effectiveWave === "imminent_gate") { + reasonCode = "imminent_gate_priority"; + } else if (item.effectiveWave === "active_sprint") { + reasonCode = "active_sprint_priority"; + } else if (item.effectiveWave === "foundational_track") { + reasonCode = "foundational_track_priority"; + } else if (item.effectiveWave === "orthogonal_backlog") { + reasonCode = "orthogonal_backlog_demotion"; + } else { + reasonCode = "wave_rebalance_shift"; + } + } + moves.push({ + material_ref: ref, + from_rank: item.originalRank, + to_rank: toRank, + reason_code: reasonCode, + evidence_refs: item.evidenceRefs ?? defaultEvidenceRefs, + }); + } + } + + return { + schema_version: MATERIAL_WAVE_RERANK_PLAN_SCHEMA_VERSION, + reordered_material_refs: finalOrder, + top_window_size: topWindowSize, + max_orthogonal_in_top_window: maxOrthogonalInTopWindow, + no_change: moves.length === 0, + constraints: { + target_window_size: totalCount, + max_moved_items: Math.max(1, moves.length), + max_rank_displacement: maxDisplacement, + protected_material_refs: protectedRefs, + }, + moves, + wave_counts: waveCounts, + top_window_orthogonal_count: countOrthInTop(finalOrder), + demoted_monograph_refs: demotedMonographRefs, + shifted_orthogonal_refs: shiftedOrthogonalRefs, + lineage_promoted_refs: lineagePromotedRefs, + }; +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index ae07c80851..8e3922cb15 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -478,6 +478,7 @@ export function createEffectRuntimeHandlers( ["capabilities.pr_review.conclusion.select", lazyHandler(() => import("./capabilities/pr_review_approval_closeout.ts"), ({selectPrReviewConclusion}) => selectPrReviewConclusion)], ["capabilities.pr_review.configuration", lazyHandler(() => import("./capabilities/pr_review_order.ts"), ({prReviewConfiguration}) => prReviewConfiguration)], ["capabilities.pr_review.order", lazyHandler(() => import("./capabilities/pr_review_order.ts"), ({orderPrReviewQueue}) => orderPrReviewQueue)], + ["material_lifecycle.plan_wave_rerank", lazyHandler(() => import("./capabilities/material_wave_ranking.ts"), ({planMaterialWaveRerank}) => planMaterialWaveRerank)], ["coordination.runtime_shadow.outbox_read", lazyHandler(() => import("./coordination/local_authority_shadow.ts"), ({readLocalAuthorityShadow}) => readLocalAuthorityShadow)], ["coordination.runtime_shadow.drain", lazyHandler(() => import("./coordination/shadow_drain.ts"), ({drainShadowOutbox}) => drainShadowOutbox)], ["coordination.cold_source.import", lazyHandler(() => Promise.all([import("./coordination/cold_source_import.ts"), import("./coordination/source_transfer.ts")]), ([{executeColdSourceImport}, {withCoordinationSourceTransfer}]) => withCoordinationSourceTransfer("coordination.cold_source.import", executeColdSourceImport))], diff --git a/skills/loopx-material/SKILL.md b/skills/loopx-material/SKILL.md index 8d60532385..3f69e99295 100644 --- a/skills/loopx-material/SKILL.md +++ b/skills/loopx-material/SKILL.md @@ -21,12 +21,14 @@ loopx project-skill install \ --execute ``` -Use `--surface claude-code` or `--surface opencode` for those hosts; repeat the -flag to install multiple host-native copies in one transaction. Managed copies -live under `.agents/skills/`, `.claude/skills/`, or `.opencode/skills/` and are -upgraded or removed through the same CLI. Project-local discovery does not -itself activate material-store writes; the selected execution owner still -needs explicit Material Lifecycle source authority. +Use `--surface antigravity`, `--surface claude-code`, `--surface kiro-cli`, +`--surface opencode`, or `--surface pi` for those hosts; repeat the flag to +install multiple host-native copies in one transaction. Managed copies live +under `.agents/skills/`, `.agent/skills/`, `.claude/skills/`, `.kiro/skills/`, +`.opencode/skills/`, or `.pi/skills/` and are upgraded or removed through the +same CLI. Project-local discovery does not itself activate material-store +writes; the selected execution owner still needs explicit Material Lifecycle +source authority. ## Activation Gate @@ -34,8 +36,12 @@ Before changing a material store: 1. Resolve the current project and execution owner. For a Goal route, use the existing registered agent/active Todo. For an ordinary project route, - resolve the current Core project context and accepted request; do not run - `start-goal` or create a dummy Goal merely to obtain material metadata. + resolve the current Core project context and accepted request from the + active native turn of the calling verified local owner host (`Codex`, + `Antigravity`, `Claude Code`, `OpenCode`, `Kiro CLI`, etc.) or an authorized + native Bot turn under the same Core workspace write grant; never run + `start-goal`, invent a dummy Goal merely to obtain material metadata, or + borrow another host's turn or session identity. 2. Run `loopx project-skill status --project . --skill loopx-material` and confirm the required host surfaces are current. 3. Require explicit activation: either the selected Goal Todo targets @@ -45,9 +51,10 @@ Before changing a material store: not activation. 4. Confirm the Goal boundary or Core project write grant covers the exact private adapter/authority paths. The project source verifier must bind the - current caller, audience, store/profile and owner gate, including expiry - and revocation. Ref names never grant access; enforce the authorization - fence within staging/publication as well as preflight checks. + calling verified local owner host's active native turn (or authorized native + Bot turn), audience, store/profile and owner gate, including expiry and + revocation. Ref names never grant access; enforce the authorization fence + within staging/publication as well as preflight checks. 5. Run `loopx material-lifecycle architecture --format json` and preserve its default-off, owner-gated, provider-neutral boundaries. The project source route supports inventory, candidate intake/rollback, rerank packets, @@ -171,6 +178,32 @@ Rerank from revision-bound Decision Context evidence. Preserve all other relative order and use its exact interval bounds in the existing proposal. Recompute that preview before apply; do not relax the constraints of a historical proposal. +- For multi-item shortlist rebalancing, use `plan_material_wave_rerank` (or + `loopx material-lifecycle plan-wave-rerank --input `) over the + complete ranked set before building `material_rerank_proposal_v0`, enforcing + all three ranking quality rules: + 1. **Horizon-wave and effort-band stratification**: order annotated materials + across `imminent_gate`, `active_sprint`, `foundational_track`, and + `orthogonal_backlog` with cost-adjusted VOI (`quick_slice` `/1.0`, + `focused_study` `/2.0`, `deep_monograph` `/6.0`). Automatically stratify + unsliced `deep_monograph` items (`scoped_slice_ready=False`) declared in + `imminent_gate` or `active_sprint` into `foundational_track` so multi-week + courses or monographs never block quick high-VOI slices unless an explicit + bounded chapter/section slice is ready. + 2. **Load-bearing primary-over-secondary audit before rerank**: verify + `required_load_bearing_refs` are already ingested and ranked before + planning a rerank (settle candidate intake first when any load-bearing + primary is missing or `rank=None`), enforce topological primary-over-secondary + precedence (`primary_source_refs` strictly ahead of secondary commentary), + and keep active-deliverable load-bearing primaries + (`blocks_active_deliverable=True`, `evidence_role="load_bearing_primary"`) + inside `top_window_size`. + 3. **Orthogonal-signal Top-Window cap**: cap `orthogonal_backlog` and + `orthogonal_signal` items inside `1..top_window_size` via + `max_orthogonal_in_top_window` (default `0`) whenever non-orthogonal + ranked items remain below `top_window_size`, shifting excess general-trend + items cleanly to the start of `ranked_backlog` while keeping untouched + backlog items in their exact original relative order. - Use `material_rerank_receipt_chunks` when a transition affects more than 100 refs. Build all existing receipts before CAS and persist all after readback; verify complete coverage under the same proposal, revisions and owner gate. diff --git a/tests/capabilities/test_material_project_skill_delivery.py b/tests/capabilities/test_material_project_skill_delivery.py index 6e3c79b4d5..7183be2475 100644 --- a/tests/capabilities/test_material_project_skill_delivery.py +++ b/tests/capabilities/test_material_project_skill_delivery.py @@ -183,7 +183,11 @@ def test_generic_delivery_supports_every_registered_surface( source = _source(tmp_path) project = _project(tmp_path) surfaces = PROJECT_SKILL_SURFACES + assert "antigravity" in surfaces assert "kiro-cli" in surfaces + assert project_skill_target(project, "loopx-material", "antigravity") == ( + project.resolve() / ".agent" / "skills" / "loopx-material" + ) preview = install_project_skill( project, diff --git a/tests/capabilities/test_material_wave_ranking.py b/tests/capabilities/test_material_wave_ranking.py new file mode 100644 index 0000000000..ab33c1d7f3 --- /dev/null +++ b/tests/capabilities/test_material_wave_ranking.py @@ -0,0 +1,413 @@ +"""Wave-based material reranking contracts over the shared TypeScript owner.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest + +from loopx.capabilities.material_lifecycle import ( + MATERIAL_WAVE_RERANK_PLAN_SCHEMA_VERSION, + MaterialProjectScope, + build_material_rerank_apply_receipt, + build_material_rerank_proposal, + material_rerank_receipt_chunks, + plan_material_wave_rerank, +) +from loopx.cli import main + +OBSERVED_AT = "2026-07-25T16:30:00+00:00" + + +def test_wave_rerank_stratifies_monographs_promotes_primaries_and_caps_orthogonal_signals() -> None: + refs = [ + "material-pinned-anchor", + "material-orthogonal-news-1", + "material-monograph-course", + "material-secondary-commentary", + "material-orthogonal-news-2", + "material-quick-baseline", + "material-method-comparator", + "material-primary-spec", + "material-untouched-1", + "material-untouched-2", + ] + reordered, summary = plan_material_wave_rerank( + refs, + [ + { + "material_ref": "material-monograph-course", + "horizon_wave": "imminent_gate", + "effort_band": "deep_monograph", + "scoped_slice_ready": False, + "decision_gain_score": 92, + "evidence_role": "foundational_reference", + }, + { + "material_ref": "material-quick-baseline", + "horizon_wave": "imminent_gate", + "effort_band": "quick_slice", + "decision_gain_score": 75, + "evidence_role": "load_bearing_primary", + "blocks_active_deliverable": True, + }, + { + "material_ref": "material-secondary-commentary", + "horizon_wave": "active_sprint", + "effort_band": "quick_slice", + "decision_gain_score": 80, + "evidence_role": "secondary_or_commentary", + "primary_source_refs": ["material-primary-spec"], + }, + { + "material_ref": "material-method-comparator", + "horizon_wave": "active_sprint", + "effort_band": "focused_study", + "decision_gain_score": 76, + "evidence_role": "method_comparator", + }, + { + "material_ref": "material-orthogonal-news-1", + "horizon_wave": "active_sprint", + "effort_band": "quick_slice", + "decision_gain_score": 88, + "evidence_role": "orthogonal_signal", + }, + { + "material_ref": "material-orthogonal-news-2", + "horizon_wave": "orthogonal_backlog", + "effort_band": "quick_slice", + "decision_gain_score": 65, + "evidence_role": "orthogonal_signal", + }, + ], + top_window_size=6, + protected_material_refs=["material-pinned-anchor"], + required_load_bearing_refs=[ + "material-quick-baseline", + "material-primary-spec", + ], + max_orthogonal_in_top_window=0, + default_evidence_refs=["decision:current"], + ) + + assert summary["schema_version"] == MATERIAL_WAVE_RERANK_PLAN_SCHEMA_VERSION + assert sorted(reordered) == sorted(refs) + assert reordered[0] == "material-pinned-anchor" + assert reordered[1] == "material-quick-baseline" + # Primary spec is pulled strictly ahead of secondary commentary. + assert reordered.index("material-primary-spec") < reordered.index( + "material-secondary-commentary" + ) + # Unsliced deep monograph is stratified behind imminent_gate and active_sprint items. + assert reordered.index("material-monograph-course") > reordered.index( + "material-method-comparator" + ) + assert summary["demoted_monograph_refs"] == ["material-monograph-course"] + # Zero orthogonal items remain inside Top-6 while non-orthogonal items fill Top-6. + assert summary["top_window_orthogonal_count"] == 0 + assert all( + reordered.index(orth) >= 6 + for orth in ("material-orthogonal-news-1", "material-orthogonal-news-2") + ) + assert summary["shifted_orthogonal_refs"] == [ + "material-orthogonal-news-1", + "material-orthogonal-news-2", + ] + # Untouched items preserve exact relative order. + assert reordered.index("material-untouched-1") < reordered.index( + "material-untouched-2" + ) + + scope = MaterialProjectScope( + "project:synthetic", + "profile:materials", + "grant:workspace-write", + ) + proposal = build_material_rerank_proposal( + project_scope=scope, + proposal_id="proposal:wave-rerank", + inventory_ref="inventory:rev-10", + decision_evidence_ref="decision:current", + observed_at=OBSERVED_AT, + moves=summary["moves"], + **summary["constraints"], + ) + assert proposal["no_change"] is False + assert proposal["apply_authorized"] is False + assert proposal["capability"]["scope"] == "project" + + affected = [move["material_ref"] for move in proposal["moves"]] + chunks = material_rerank_receipt_chunks(affected) + receipt = build_material_rerank_apply_receipt( + project_scope=scope, + receipt_id="receipt:wave-rerank-1", + proposal_ref=proposal["proposal_ref"], + observed_at=OBSERVED_AT, + status="applied", + before_revision="revision:10", + after_revision="revision:11", + owner_gate_ref="gate:owner-wave-1", + validation_ref="validation:wave-1", + rollback_ref="rollback:revision-10", + applied_material_refs=chunks[0], + ) + assert receipt["status"] == "applied" + + +def test_wave_rerank_no_change_produces_usable_proposal_bounds() -> None: + refs = ["material-alpha", "material-beta", "material-gamma"] + reordered, summary = plan_material_wave_rerank(refs, [], top_window_size=3) + assert reordered == refs + assert summary["no_change"] is True + assert summary["moves"] == [] + assert summary["constraints"] == { + "target_window_size": 3, + "max_moved_items": 1, + "max_rank_displacement": 1, + "protected_material_refs": [], + } + proposal = build_material_rerank_proposal( + goal_id="goal:materials", + proposal_id="proposal:wave-no-change", + inventory_ref="inventory:1", + decision_evidence_ref="decision:1", + observed_at=OBSERVED_AT, + moves=summary["moves"], + no_change_reason="Queue already matches wave priorities.", + **summary["constraints"], + ) + assert proposal["no_change"] is True + + +def test_wave_rerank_rejects_unranked_load_bearing_cycles_and_unsafe_inputs() -> None: + with pytest.raises(ValueError, match="settle candidate intake first"): + plan_material_wave_rerank( + ["material-secondary-summary"], + required_load_bearing_refs=["material-unranked-primary"], + ) + + with pytest.raises(ValueError, match="cycle"): + plan_material_wave_rerank( + ["material-a", "material-b"], + [ + {"material_ref": "material-a", "primary_source_refs": ["material-b"]}, + {"material_ref": "material-b", "primary_source_refs": ["material-a"]}, + ], + ) + + fake_token = "ghp_" + ("a" * 36) + for bad_ref in (fake_token, "/srv/private/material", "https://example.invalid/doc"): + with pytest.raises(ValueError): + plan_material_wave_rerank([bad_ref]) + + with pytest.raises(ValueError, match="unsafe fields"): + plan_material_wave_rerank( + ["material-a"], + [{"material_ref": "material-a", "raw_content": "secret"}], + ) + + with pytest.raises(ValueError, match="unsupported horizon_wave"): + plan_material_wave_rerank( + ["material-a"], + [{"material_ref": "material-a", "horizon_wave": "unregistered_wave"}], + ) + + +def test_cli_wave_rerank_handles_active_overflow_and_dependency_safe_replacement( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + overflow = { + "ordered_material_refs": [ + "material-orthogonal-backlog", + "material-active-a", + "material-active-b", + "material-active-c", + ], + "top_window_size": 2, + "max_orthogonal_in_top_window": 0, + "annotations": [ + { + "material_ref": "material-orthogonal-backlog", + "horizon_wave": "orthogonal_backlog", + "effort_band": "quick_slice", + "decision_gain_score": 100, + "evidence_role": "orthogonal_signal", + }, + *[ + { + "material_ref": material_ref, + "horizon_wave": "active_sprint", + "effort_band": "quick_slice", + "decision_gain_score": 90 - index, + "evidence_role": "method_comparator", + } + for index, material_ref in enumerate( + ( + "material-active-a", + "material-active-b", + "material-active-c", + ) + ) + ], + ], + } + dependency = { + "ordered_material_refs": [ + "material-primary", + "material-secondary", + "material-independent", + ], + "top_window_size": 1, + "max_orthogonal_in_top_window": 0, + "annotations": [ + { + "material_ref": "material-primary", + "horizon_wave": "orthogonal_backlog", + "evidence_role": "orthogonal_signal", + }, + { + "material_ref": "material-secondary", + "horizon_wave": "active_sprint", + "primary_source_refs": ["material-primary"], + }, + { + "material_ref": "material-independent", + "horizon_wave": "active_sprint", + }, + ], + } + + for request, expected in ( + ( + overflow, + [ + "material-active-a", + "material-active-b", + "material-active-c", + "material-orthogonal-backlog", + ], + ), + ( + dependency, + [ + "material-independent", + "material-primary", + "material-secondary", + ], + ), + ): + input_path = tmp_path / f"wave-request-{len(expected)}.json" + input_path.write_text(json.dumps(request), encoding="utf-8") + assert ( + main( + [ + "material-lifecycle", + "plan-wave-rerank", + "--input", + str(input_path), + "--format", + "json", + ] + ) + == 0 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["reordered_material_refs"] == expected + + +def test_material_lifecycle_cli_plan_wave_rerank_supports_file_stdin_and_fail_closed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + request = { + "ordered_material_refs": [ + "material-orthogonal-trend", + "material-monograph-course", + "material-primary-spec", + "material-untouched", + ], + "top_window_size": 3, + "max_orthogonal_in_top_window": 0, + "required_load_bearing_refs": ["material-primary-spec"], + "annotations": [ + { + "material_ref": "material-primary-spec", + "horizon_wave": "imminent_gate", + "effort_band": "quick_slice", + "decision_gain_score": 90, + "evidence_role": "load_bearing_primary", + "blocks_active_deliverable": True, + }, + { + "material_ref": "material-monograph-course", + "horizon_wave": "imminent_gate", + "effort_band": "deep_monograph", + "scoped_slice_ready": False, + "decision_gain_score": 85, + }, + { + "material_ref": "material-orthogonal-trend", + "evidence_role": "orthogonal_signal", + }, + ], + } + input_path = tmp_path / "wave-request.json" + input_path.write_text(json.dumps(request), encoding="utf-8") + + assert ( + main( + [ + "material-lifecycle", + "plan-wave-rerank", + "--input", + str(input_path), + "--format", + "json", + ] + ) + == 0 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is True + assert payload["status"] == "planned" + assert payload["apply_authorized"] is False + assert payload["reordered_material_refs"] == [ + "material-primary-spec", + "material-monograph-course", + "material-untouched", + "material-orthogonal-trend", + ] + + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(request))) + assert main(["material-lifecycle", "plan-wave-rerank", "--input", "-"]) == 0 + rendered = capsys.readouterr().out + assert "# Material Wave Rerank Plan" in rendered + assert "apply_authorized: `False`" in rendered + + blocked_request = { + "ordered_material_refs": ["material-untouched"], + "required_load_bearing_refs": ["material-missing-primary"], + } + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(blocked_request))) + assert ( + main( + [ + "material-lifecycle", + "plan-wave-rerank", + "--input", + "-", + "--format", + "json", + ] + ) + == 2 + ) + blocked = json.loads(capsys.readouterr().out) + assert blocked["ok"] is False + assert blocked["status"] == "blocked" + assert "material-missing-primary" in blocked["error"] diff --git a/tests/control_plane_ts/material_wave_ranking.test.ts b/tests/control_plane_ts/material_wave_ranking.test.ts new file mode 100644 index 0000000000..e139669740 --- /dev/null +++ b/tests/control_plane_ts/material_wave_ranking.test.ts @@ -0,0 +1,366 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import {planMaterialWaveRerank} from "../../loopx/control_plane/capabilities/material_wave_ranking.ts"; +import { + createEffectRuntimeHandlers, + dispatchEffectRuntimeMethod, +} from "../../loopx/control_plane/effect_runtime_handlers.ts"; + +const handlers = createEffectRuntimeHandlers({ + fingerprint: "wave-ranking-test", + requestShutdown: () => undefined, +}); + +test("stratifies unsliced deep monographs behind quick and focused sprint slices unless scoped_slice_ready is true", async () => { + const ordered = [ + "material-monograph-course", + "material-focused-study", + "material-quick-baseline", + "material-sliced-monograph", + ]; + const result = (await dispatchEffectRuntimeMethod( + handlers, + "material_lifecycle.plan_wave_rerank", + { + ordered_material_refs: ordered, + top_window_size: 4, + annotations: [ + { + material_ref: "material-monograph-course", + horizon_wave: "imminent_gate", + effort_band: "deep_monograph", + scoped_slice_ready: false, + decision_gain_score: 95, + evidence_role: "foundational_reference", + }, + { + material_ref: "material-focused-study", + horizon_wave: "imminent_gate", + effort_band: "focused_study", + decision_gain_score: 70, + evidence_role: "method_comparator", + }, + { + material_ref: "material-quick-baseline", + horizon_wave: "imminent_gate", + effort_band: "quick_slice", + decision_gain_score: 60, + evidence_role: "method_comparator", + }, + { + material_ref: "material-sliced-monograph", + horizon_wave: "imminent_gate", + effort_band: "deep_monograph", + scoped_slice_ready: true, + decision_gain_score: 90, + evidence_role: "method_comparator", + }, + ], + }, + )) as Record; + + // quick_slice VOI = 60/1 = 60, focused_study VOI = 70/2 = 35, sliced deep_monograph VOI = 90/6 = 15; + // unsliced deep_monograph is stratified to foundational_track at the end. + assert.deepEqual(result.reordered_material_refs, [ + "material-quick-baseline", + "material-focused-study", + "material-sliced-monograph", + "material-monograph-course", + ]); + assert.deepEqual(result.demoted_monograph_refs, ["material-monograph-course"]); +}); + +test("enforces unranked load-bearing primary gate, topological primary-over-secondary lineage, cycles, and anchor deadlines", () => { + assert.throws( + () => + planMaterialWaveRerank({ + ordered_material_refs: ["material-secondary-review", "material-quick-baseline"], + required_load_bearing_refs: ["material-primary-spec"], + }), + /missing or unranked required load-bearing primary materials.*material-primary-spec/, + ); + + // Buried primary at rank 4 is pulled immediately ahead of secondary commentary in imminent_gate. + const promoted = planMaterialWaveRerank({ + ordered_material_refs: [ + "material-quick-baseline", + "material-secondary-review", + "material-backlog-item", + "material-primary-spec", + ], + top_window_size: 4, + required_load_bearing_refs: ["material-primary-spec"], + annotations: [ + { + material_ref: "material-quick-baseline", + horizon_wave: "imminent_gate", + effort_band: "quick_slice", + decision_gain_score: 90, + evidence_role: "load_bearing_primary", + blocks_active_deliverable: true, + }, + { + material_ref: "material-secondary-review", + horizon_wave: "imminent_gate", + effort_band: "quick_slice", + decision_gain_score: 70, + evidence_role: "secondary_or_commentary", + primary_source_refs: ["material-primary-spec"], + }, + ], + }); + assert.deepEqual(promoted.reordered_material_refs, [ + "material-quick-baseline", + "material-primary-spec", + "material-secondary-review", + "material-backlog-item", + ]); + assert.deepEqual(promoted.lineage_promoted_refs, ["material-primary-spec"]); + + // Cycle detection fails closed. + assert.throws( + () => + planMaterialWaveRerank({ + ordered_material_refs: ["material-alpha", "material-beta"], + annotations: [ + { + material_ref: "material-alpha", + primary_source_refs: ["material-beta"], + }, + { + material_ref: "material-beta", + primary_source_refs: ["material-alpha"], + }, + ], + }), + /primary_source_refs cycle detected/, + ); + + // Protected secondary at rank 3 with protected anchor at rank 1 places primary into open slot 2. + const tightSlot = planMaterialWaveRerank({ + ordered_material_refs: [ + "material-anchor-one", + "material-other-candidate", + "material-protected-secondary", + "material-buried-primary", + ], + protected_material_refs: ["material-anchor-one", "material-protected-secondary"], + annotations: [ + { + material_ref: "material-other-candidate", + horizon_wave: "imminent_gate", + effort_band: "quick_slice", + decision_gain_score: 95, + }, + { + material_ref: "material-protected-secondary", + primary_source_refs: ["material-buried-primary"], + }, + ], + }); + assert.deepEqual(tightSlot.reordered_material_refs, [ + "material-anchor-one", + "material-buried-primary", + "material-protected-secondary", + "material-other-candidate", + ]); + + // Protected anchor inversion with zero open slots before protected secondary fails closed. + assert.throws( + () => + planMaterialWaveRerank({ + ordered_material_refs: [ + "material-anchor-one", + "material-protected-secondary", + "material-buried-primary", + ], + protected_material_refs: ["material-anchor-one", "material-protected-secondary"], + annotations: [ + { + material_ref: "material-protected-secondary", + primary_source_refs: ["material-buried-primary"], + }, + ], + }), + /protected anchor inversion/, + ); +}); + +test("places orthogonal backlog after active waves that overflow the top window", () => { + const result = planMaterialWaveRerank({ + ordered_material_refs: [ + "material-orthogonal-backlog", + "material-active-a", + "material-active-b", + "material-active-c", + ], + top_window_size: 2, + max_orthogonal_in_top_window: 0, + annotations: [ + { + material_ref: "material-orthogonal-backlog", + horizon_wave: "orthogonal_backlog", + effort_band: "quick_slice", + decision_gain_score: 100, + evidence_role: "orthogonal_signal", + }, + ...["material-active-a", "material-active-b", "material-active-c"].map( + (materialRef, index) => ({ + material_ref: materialRef, + horizon_wave: "active_sprint" as const, + effort_band: "quick_slice" as const, + decision_gain_score: 90 - index, + evidence_role: "method_comparator" as const, + }), + ), + ], + }); + + assert.deepEqual(result.reordered_material_refs, [ + "material-active-a", + "material-active-b", + "material-active-c", + "material-orthogonal-backlog", + ]); +}); + +test("Top-Window replacement skips a candidate that depends on the shifted orthogonal source", () => { + const result = planMaterialWaveRerank({ + ordered_material_refs: [ + "material-primary", + "material-secondary", + "material-independent", + ], + top_window_size: 1, + max_orthogonal_in_top_window: 0, + annotations: [ + { + material_ref: "material-primary", + horizon_wave: "orthogonal_backlog", + evidence_role: "orthogonal_signal", + }, + { + material_ref: "material-secondary", + horizon_wave: "active_sprint", + primary_source_refs: ["material-primary"], + }, + { + material_ref: "material-independent", + horizon_wave: "active_sprint", + }, + ], + }); + + assert.deepEqual(result.reordered_material_refs, [ + "material-independent", + "material-primary", + "material-secondary", + ]); +}); + +test("preserves 300-item queue ergonomics: active waves 1..16, untouched 17..30, demoted orthogonal 31..34, untouched 35..300", () => { + const ordered = Array.from( + {length: 300}, + (_, idx) => `material:item-${String(idx + 1).padStart(3, "0")}`, + ); + const orthogonalRefs = [ + "material:item-003", + "material:item-007", + "material:item-012", + "material:item-019", + ]; + const activeRefs = [ + "material:item-041", + "material:item-042", + "material:item-043", + "material:item-044", + "material:item-051", + "material:item-052", + "material:item-053", + "material:item-054", + "material:item-055", + "material:item-056", + "material:item-071", + "material:item-072", + "material:item-073", + "material:item-074", + "material:item-075", + "material:item-076", + ]; + + const annotations = [ + ...activeRefs.slice(0, 4).map((ref, idx) => ({ + material_ref: ref, + horizon_wave: "imminent_gate", + effort_band: "quick_slice", + decision_gain_score: 90 - idx, + evidence_role: "load_bearing_primary", + blocks_active_deliverable: true, + })), + ...activeRefs.slice(4, 10).map((ref, idx) => ({ + material_ref: ref, + horizon_wave: "active_sprint", + effort_band: "focused_study", + decision_gain_score: 80 - idx, + evidence_role: "method_comparator", + })), + ...activeRefs.slice(10, 16).map((ref, idx) => ({ + material_ref: ref, + horizon_wave: "foundational_track", + effort_band: "focused_study", + decision_gain_score: 70 - idx, + evidence_role: "foundational_reference", + })), + ...orthogonalRefs.map((ref, idx) => ({ + material_ref: ref, + horizon_wave: "orthogonal_backlog", + effort_band: "quick_slice", + decision_gain_score: 50 - idx, + evidence_role: "orthogonal_signal", + })), + ]; + + const result = planMaterialWaveRerank({ + ordered_material_refs: ordered, + annotations, + top_window_size: 30, + max_orthogonal_in_top_window: 0, + }); + const reordered = result.reordered_material_refs as string[]; + + assert.equal(reordered.length, 300); + assert.deepEqual([...reordered].sort(), [...ordered].sort()); + assert.deepEqual(reordered.slice(0, 16), activeRefs); + assert.equal(result.top_window_orthogonal_count, 0); + assert.deepEqual(reordered.slice(30, 34), orthogonalRefs); + + const annotatedSet = new Set([...activeRefs, ...orthogonalRefs]); + const untouchedExpected = ordered.filter((ref) => !annotatedSet.has(ref)); + assert.deepEqual(reordered.slice(16, 30), untouchedExpected.slice(0, 14)); + assert.deepEqual(reordered.slice(34), untouchedExpected.slice(14)); +}); + +test("fails closed when load-bearing deliverable blocker cannot fit in top_window_size", () => { + assert.throws( + () => + planMaterialWaveRerank({ + ordered_material_refs: [ + "material-anchor-1", + "material-anchor-2", + "material-blocking-primary", + ], + top_window_size: 2, + protected_material_refs: ["material-anchor-1", "material-anchor-2"], + annotations: [ + { + material_ref: "material-blocking-primary", + horizon_wave: "imminent_gate", + evidence_role: "load_bearing_primary", + blocks_active_deliverable: true, + }, + ], + }), + /load-bearing deliverable blocker material-blocking-primary landed at rank 3 outside top_window_size=2/, + ); +}); diff --git a/tests/test_project_skill_cli.py b/tests/test_project_skill_cli.py index 3e173a3c48..64c7354b51 100644 --- a/tests/test_project_skill_cli.py +++ b/tests/test_project_skill_cli.py @@ -35,6 +35,8 @@ def test_project_skill_cli_installs_multiple_host_surfaces( "--surface", "codex", "--surface", + "antigravity", + "--surface", "claude-code", "--surface", "opencode", diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index ac4a331094..4c1734cfef 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -123,6 +123,7 @@ "tests/control_plane_ts/test_python_runtime.test.ts", "tests/control_plane_ts/direction_authority.test.ts", "tests/control_plane_ts/direction_baseline.test.ts", + "tests/control_plane_ts/material_wave_ranking.test.ts", "tests/control_plane_ts/zcode*.test.ts" ] }