From 76e3ab0b6e3fbdf57c292282972f8ab7b29414b1 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:29:55 +0800 Subject: [PATCH] feat(materials): share single-material rerank planning and complete receipt chunks Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../capabilities/material_lifecycle/README.md | 34 ++++ .../material_lifecycle/README.zh-CN.md | 17 ++ .../material_lifecycle/__init__.py | 4 + .../material_lifecycle/ranking.py | 79 +++++++++ skills/loopx-material/SKILL.md | 11 +- .../capabilities/test_material_single_move.py | 160 ++++++++++++++++++ 6 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 tests/capabilities/test_material_single_move.py diff --git a/loopx/capabilities/material_lifecycle/README.md b/loopx/capabilities/material_lifecycle/README.md index 60aa0354d1..ec10237553 100644 --- a/loopx/capabilities/material_lifecycle/README.md +++ b/loopx/capabilities/material_lifecycle/README.md @@ -76,6 +76,8 @@ flowchart LR index. - The ranked set may extend beyond a visible Top-N through an explicit ranked backlog. +- A visible Top-N is not an implicit protected prefix. Only explicitly declared + pinned entries or stable prefixes constrain a rerank. - Recall is advisory; ranking evidence must be promoted by exact read. - Proposal and apply receipt are separate. - Apply and rollback require explicit owner gates and revision checks. @@ -96,6 +98,38 @@ Ordinary one-off reading, summarization, or web research does not require this capability unless the project has explicitly activated a managed material store. +## Single-Material Reranking + +`plan_material_single_move(ordered_material_refs, material_ref, to_rank)` +previews a move within the **complete** ranked set, including its backlog. It +returns the new order and constraints for `build_material_rerank_proposal`. +The moved-item and displacement bounds describe exactly the affected interval; +every other material keeps its relative order. It rejects duplicate identities, +out-of-range targets and unranked materials, which must use candidate intake. +Pass `protected_material_refs` explicitly: moving or displacing an anchor fails. +Grouped reading units still require the project's existing semantic review; +this helper does not flatten a catalog or alter source records. + +The SDK helpers remain previews. The source adapter must verify the inventory +and Decision Context backing, recompute the exact preview on apply, and retain +the existing owner gate, CAS, readback, publication and rollback boundary. No +helper authorizes a write or changes an older proposal's constraints. + +For moves affecting more than 100 materials, use +`material_rerank_receipt_chunks(affected_material_refs)`, then build one existing +`material_rerank_apply_receipt_v0` per chunk. The input is validated completely +before any chunk is returned; duplicates fail instead of losing coverage. +Give every receipt a unique id and the **same** proposal, before/after revision, +gate and validation references for the one atomic source transition. Build all +receipts before switching authority, persist the complete list after successful +readback, and verify its flattened membership against the exact preview. +Persisting only the first receipt does not establish complete coverage. Empty +input yields one empty chunk for the existing no-change/rejection/rollback +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. + ## Project-Local Skill Delivery LoopX ships the canonical `loopx-material` skill, but deliberately does not diff --git a/loopx/capabilities/material_lifecycle/README.zh-CN.md b/loopx/capabilities/material_lifecycle/README.zh-CN.md index b88e085cec..2d3c091bbd 100644 --- a/loopx/capabilities/material_lifecycle/README.zh-CN.md +++ b/loopx/capabilities/material_lifecycle/README.zh-CN.md @@ -69,6 +69,7 @@ flowchart LR - 默认每个 ranked entry 最多包含 3 条 primary material。 - Overflow 必须变成新的、可独立排序的条目,不能藏入 supporting index。 - 可见 Top-N 之外保留显式 ranked backlog。 +- 可见 Top-N 不是隐式保护前缀;只有明确声明的 pinned entry 或稳定前缀限制重排。 - Recall 只是线索;影响排序的证据必须经过 exact read。 - Proposal 与 apply receipt 必须分离。 - Apply 与 rollback 都需要显式 owner gate 和 revision 校验。 @@ -85,6 +86,22 @@ flowchart LR 普通的一次性阅读、摘要或网页调研不需要启用这项能力,除非项目已经显式激活了 受管素材库。 +## 单条素材重排 + +`plan_material_single_move(ordered_material_refs, material_ref, to_rank)` 接收 +包含 backlog 的完整排名,返回新顺序和现有 proposal builder 所需的精确区间约束。 +其它素材保留相对顺序,未排序素材走 intake;显式 `protected_material_refs` +既不能主动移动,也不能被挤动。它不拆散分组阅读单元、不改原始记录、不授权写入。 +adapter 仍须验证 inventory / Decision Context 正文,在 apply 时重新计算 preview, +保留 owner gate、CAS、读回、发布与回滚;历史 proposal 的约束不自动放宽。 + +超过 100 个受影响 ref 时,用 `material_rerank_receipt_chunks` 分块,再构建现有 v0 +receipt。完整输入先验证,重复 ref 拒绝;每份 receipt 使用唯一 id、相同 proposal、 +前后 revision、gate 与 validation,表示同一次原子变更。CAS 前构建全部 receipt, +读回后保存完整列表并核对覆盖;只保留第一份不能证明完整。单份上限仍为 100, +空输入返回一份空分块,供现有 no-change / rejection / rollback 契约使用。 +该 SDK 增量不依赖项目授权改动,不增设 CLI writer、素材 authority 或自动排序器。 + ## 项目级 Skill 安装 LoopX 发布 canonical `loopx-material` skill,但刻意不安装到用户的全局 skill diff --git a/loopx/capabilities/material_lifecycle/__init__.py b/loopx/capabilities/material_lifecycle/__init__.py index e4f52b0bae..7d885ac1ff 100644 --- a/loopx/capabilities/material_lifecycle/__init__.py +++ b/loopx/capabilities/material_lifecycle/__init__.py @@ -75,6 +75,8 @@ MATERIAL_RERANK_PROPOSAL_SCHEMA_VERSION, build_material_rerank_apply_receipt, build_material_rerank_proposal, + material_rerank_receipt_chunks, + plan_material_single_move, ) from .readable_projection import ( MATERIAL_READABLE_PROJECTION_RECEIPT_SCHEMA_VERSION, @@ -152,6 +154,8 @@ "inspect_project_material_skill", "install_project_material_skill", "material_skill_digest", + "material_rerank_receipt_chunks", + "plan_material_single_move", "plan_material_decision_actions", "prepare_material_migration", "project_material_skill_target", diff --git a/loopx/capabilities/material_lifecycle/ranking.py b/loopx/capabilities/material_lifecycle/ranking.py index 7e770c3ed0..a9042d6edb 100644 --- a/loopx/capabilities/material_lifecycle/ranking.py +++ b/loopx/capabilities/material_lifecycle/ranking.py @@ -19,6 +19,7 @@ MATERIAL_RERANK_PROPOSAL_SCHEMA_VERSION = "material_rerank_proposal_v0" MATERIAL_RERANK_APPLY_RECEIPT_SCHEMA_VERSION = "material_rerank_apply_receipt_v0" +MATERIAL_RERANK_RECEIPT_MAX_REFS = 100 _MOVE_FIELDS = { "evidence_refs", @@ -36,6 +37,83 @@ _APPLY_STATUSES = {"applied", "no_change", "rejected", "rolled_back"} +def _ordered_material_refs(values: Sequence[str], *, field: str) -> list[str]: + if isinstance(values, (str, bytes)) or not isinstance(values, Sequence): + raise TypeError(f"{field} must be a sequence of compact tokens") + refs = [compact_token(value, field=f"{field}[]") for value in values] + if len(set(refs)) != len(refs): + raise ValueError(f"{field} values must be unique") + return refs + + +def plan_material_single_move( + ordered_material_refs: Sequence[str], + material_ref: str, + to_rank: int, + *, + protected_material_refs: Sequence[str] | None = None, +) -> tuple[list[str], dict[str, Any]]: + """Preview one ranked material move, preserving all other relative order. + + The complete ranked set includes the backlog; a visible Top-N does not + implicitly pin its entries. Explicit anchors retain their exact rank, + including when the selected material would displace them. These bounds + describe the affected interval, not permission to apply it. The caller + still supplies verified inventory/Decision Context to the proposal builder + and checks the exact preview, owner gate, revision and rollback on apply. + """ + 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") + selected = compact_token(material_ref, field="material_ref") + if selected not in refs: + raise ValueError( + "ranked material identity required; use intake settlement for new entries" + ) + if type(to_rank) is not int or not 1 <= to_rank <= len(refs): + raise ValueError("target rank must be an integer within the ranked queue") + protected = token_list(protected_material_refs, field="protected_material_refs") + if not set(protected).issubset(refs): + raise ValueError("protected_material_refs must belong to the ranked queue") + start = refs.index(selected) + target = to_rank - 1 + reordered = refs.copy() + reordered.pop(start) + reordered.insert(target, selected) + affected = ( + set(refs[min(start, target) : max(start, target) + 1]) + if start != target + else set() + ) + if affected.intersection(protected): + raise ValueError("single material move would change a protected rank") + displacement = abs(start - target) + return reordered, { + "target_window_size": len(refs), + "max_moved_items": displacement + 1 if displacement else 1, + "max_rank_displacement": max(1, displacement), + "protected_material_refs": protected, + } + + +def material_rerank_receipt_chunks( + applied_material_refs: Sequence[str], +) -> list[list[str]]: + """Cover one transition without truncating its affected material refs. + + Each chunk fits the existing v0 receipt. Validate the complete input before + returning any chunks; duplicates are errors, not silently deduplicated. + Build all receipts before CAS, keep their proposal/revisions/gate identical, + then persist all of them after readback. This function performs no apply. + Empty input produces one empty chunk for no-change/rejection/rollback. + """ + refs = _ordered_material_refs(applied_material_refs, field="applied_material_refs") + return [ + refs[index : index + MATERIAL_RERANK_RECEIPT_MAX_REFS] + for index in range(0, len(refs), MATERIAL_RERANK_RECEIPT_MAX_REFS) + ] or [[]] + + def _normalize_moves( values: Sequence[Mapping[str, Any]] | None, *, @@ -208,6 +286,7 @@ def build_material_rerank_apply_receipt( applied_refs = token_list( applied_material_refs, field="applied_material_refs", + max_items=MATERIAL_RERANK_RECEIPT_MAX_REFS, ) if normalized_status == "applied": diff --git a/skills/loopx-material/SKILL.md b/skills/loopx-material/SKILL.md index 4a39e8dde7..60d68ee595 100644 --- a/skills/loopx-material/SKILL.md +++ b/skills/loopx-material/SKILL.md @@ -152,9 +152,18 @@ urgency, reader action, or evidence maturity differs. Rerank from revision-bound Decision Context evidence. -- Protect pinned entries and the project's declared stable prefix. +- Protect explicitly pinned entries and the project's declared stable prefix; + a visible Top-N alone does not declare either protection. - Limit moved entries and rank displacement unless the owner explicitly approves a structural rebuild. +- For one ranked material's evidence-backed move, use + `plan_material_single_move` with the complete ranked set, including backlog. + 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. +- 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. - Distinguish a rank move from lifecycle change and from new candidate intake. - Emit a no-change proposal when evidence does not justify movement. - Keep proposal and apply receipt separate. diff --git a/tests/capabilities/test_material_single_move.py b/tests/capabilities/test_material_single_move.py new file mode 100644 index 0000000000..bb413d30df --- /dev/null +++ b/tests/capabilities/test_material_single_move.py @@ -0,0 +1,160 @@ +"""Single-item intent is bounded by its interval, not the visible shortlist.""" + +import pytest + +from loopx.capabilities.material_lifecycle import ( + build_material_rerank_apply_receipt, + build_material_rerank_proposal, + material_rerank_receipt_chunks, + plan_material_single_move, +) + + +def test_backlog_promotion_preserves_membership_and_other_relative_order() -> None: + refs = [f"material:{i}" for i in range(1, 172)] + reordered, limits = plan_material_single_move(refs, "material:171", 5) + assert reordered == refs[:4] + ["material:171"] + refs[4:-1] + assert refs[-1] == "material:171" # Preview cannot mutate its input. + assert limits == { + "target_window_size": 171, + "max_moved_items": 167, + "max_rank_displacement": 166, + "protected_material_refs": [], + } + + +def test_visible_first_item_can_move_to_backlog() -> None: + refs = [f"material:{i}" for i in range(1, 41)] + reordered, limits = plan_material_single_move(refs, refs[0], 35) + assert reordered == refs[1:35] + [refs[0]] + refs[35:] + assert limits["max_moved_items"] == 35 + + +def test_no_change_and_one_item_have_usable_positive_bounds() -> None: + reordered, limits = plan_material_single_move(["material:1"], "material:1", 1) + assert reordered == ["material:1"] + assert limits["max_moved_items"] == limits["max_rank_displacement"] == 1 + + +@pytest.mark.parametrize("rank", [0, 4, True, "2", 1.5]) +def test_target_rank_is_strict_and_within_complete_ranked_set(rank: object) -> None: + with pytest.raises(ValueError, match="target rank"): + plan_material_single_move( + ["material:1", "material:2", "material:3"], "material:3", rank + ) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "refs", [[], ["material:1", "material:1"], ["/private/material"]] +) +def test_source_requires_unique_public_safe_ranked_identities(refs: list[str]) -> None: + with pytest.raises(ValueError): + plan_material_single_move(refs, "material:1", 1) + + +def test_unranked_material_requires_intake_instead_of_silent_insertion() -> None: + with pytest.raises(ValueError, match="intake"): + plan_material_single_move(["material:1"], "material:new", 1) + + +def test_explicit_protection_blocks_both_selected_and_displaced_anchors() -> None: + refs = [f"material:{i}" for i in range(1, 6)] + for anchor in ("material:5", "material:3"): + with pytest.raises(ValueError, match="protected"): + plan_material_single_move( + refs, "material:5", 2, protected_material_refs=[anchor] + ) + reordered, limits = plan_material_single_move( + refs, "material:5", 3, protected_material_refs=["material:1"] + ) + assert reordered[0] == "material:1" + assert limits["protected_material_refs"] == ["material:1"] + + +@pytest.mark.parametrize( + "count, sizes", + [(0, [0]), (1, [1]), (100, [100]), (101, [100, 1]), (167, [100, 67])], +) +def test_receipt_chunks_cover_every_input_exactly_once( + count: int, sizes: list[int] +) -> None: + refs = [f"material:{i}" for i in range(count)] + chunks = material_rerank_receipt_chunks(refs) + assert [len(chunk) for chunk in chunks] == sizes + assert [ref for chunk in chunks for ref in chunk] == refs + + +def test_receipt_chunks_reject_duplicates_and_unsafe_refs_before_any_apply() -> None: + for refs in (["material:1", "material:1"], ["https://private.invalid/material"]): + with pytest.raises(ValueError): + material_rerank_receipt_chunks(refs) + with pytest.raises(TypeError): + material_rerank_receipt_chunks("material:1") # type: ignore[arg-type] + + +def test_existing_proposal_and_receipt_entrypoints_cover_one_atomic_transition() -> ( + None +): + refs = [f"material:{i}" for i in range(1, 172)] + reordered, limits = plan_material_single_move(refs, "material:171", 5) + old_ranks = {ref: i for i, ref in enumerate(refs, 1)} + moves = [ + { + "material_ref": ref, + "from_rank": old_ranks[ref], + "to_rank": rank, + "reason_code": "objective_fit", + "evidence_refs": ["decision:current"], + } + for rank, ref in enumerate(reordered, 1) + if old_ranks[ref] != rank + ] + proposal = build_material_rerank_proposal( + goal_id="goal:materials", + proposal_id="proposal:move", + inventory_ref="inventory:current", + decision_evidence_ref="decision:current", + observed_at="2026-01-01T00:00:00Z", + moves=moves, + **limits, + ) + assert len(proposal["moves"]) == 167 + assert proposal["apply_authorized"] is False + affected = [move["material_ref"] for move in proposal["moves"]] + receipts = [ + build_material_rerank_apply_receipt( + goal_id="goal:materials", + receipt_id=f"receipt:move:{i}", + proposal_ref=proposal["proposal_ref"], + observed_at="2026-01-01T00:00:00Z", + status="applied", + before_revision="revision:before", + after_revision="revision:after", + owner_gate_ref="gate:exact-move", + validation_ref="validation:readback", + rollback_ref="rollback:before", + applied_material_refs=chunk, + ) + for i, chunk in enumerate(material_rerank_receipt_chunks(affected), 1) + ] + assert [len(r["applied_material_refs"]) for r in receipts] == [100, 67] + assert sorted( + ref for r in receipts for ref in r["applied_material_refs"] + ) == sorted(affected) + assert len({r["proposal_ref"] for r in receipts}) == 1 + assert len({r["before_revision"] for r in receipts}) == 1 + assert len({r["after_revision"] for r in receipts}) == 1 + with pytest.raises(ValueError, match="at most 100"): + build_material_rerank_apply_receipt( + goal_id="goal:materials", + receipt_id="receipt:oversized", + proposal_ref=proposal["proposal_ref"], + observed_at="2026-01-01T00:00:00Z", + status="applied", + before_revision="revision:before", + after_revision="revision:after", + owner_gate_ref="gate:exact-move", + validation_ref="validation:readback", + rollback_ref="rollback:before", + applied_material_refs=affected, + )