Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions loopx/capabilities/material_lifecycle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions loopx/capabilities/material_lifecycle/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 校验。
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions loopx/capabilities/material_lifecycle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
79 changes: 79 additions & 0 deletions loopx/capabilities/material_lifecycle/ranking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
*,
Expand Down Expand Up @@ -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":
Expand Down
11 changes: 10 additions & 1 deletion skills/loopx-material/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
160 changes: 160 additions & 0 deletions tests/capabilities/test_material_single_move.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading