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
15 changes: 14 additions & 1 deletion docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -752,7 +752,19 @@ Known limits, stated so the check is not over-trusted:
side of a fork lowers the count without removing the drift. The advisory merge
report is the review aid here; value-set equality cannot be a hard budget
because `CONFIDENCE_LEVELS` and `EDGE_CASE_COMPLEXITIES` share `high/low/medium`
while meaning different things.
while meaning different things. The merge-candidate report alone does not cover
this limit: it groups *different* names carrying *identical* value sets, while a
fork is *one* name whose modules disagree, so the grouping never lists a fork.
`divergent_value_sets(inventory)`, printed by `--report`, is the name-keyed
companion that lists surviving forks with their count of disagreeing value sets,
where they were visible only as a number before. It is **not** a rename detector:
measured, a one-sided rename leaves the name with a single definition, so it
stops being a fork and drops out of both the budget and this report. The one
case that does fail closed is a **declared** name, because `scope_declarations`
names every defining module and a renamed side no longer matches. An undeclared
one-sided rename, and renaming every side at once, both lower the budget with
nothing reporting it. That residue is accepted for M0 along with the rest of
this entry.
- **Single-element carriers are invisible.** A closed set with one string member
is not a vocabulary, so reducing a two-value set to one removes it from the
inventory entirely.
Expand Down Expand Up @@ -1181,6 +1193,7 @@ introduce a competing target state.
| --- | --- | --- | --- | --- |
| 2026-09-16 | Q9: compute the full inventory on demand; retire the committed census | Implementation for [maintainer feedback](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394); PR review pending | Committed snapshot with post-merge regeneration; diff-only scan rejected | 1, I6, 3, 5, 9, 10, 12 |
| 2026-09-16 | B2: bind one unrenamed re-export hop in the Python producer scanner | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2; PR review pending | Require every consumer to import the owner module (fragile; failed silently in M2); unbounded multi-hop resolution rejected | 5, Appendix A |
| 2026-09-16 | B1 rename invariance: add the name-keyed divergence advisory; state the limit it does not close | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1; PR review pending | Keying the budget on value sets (rejected: `CONFIDENCE_LEVELS` and `EDGE_CASE_COMPLEXITIES` share `high/low/medium` with different meanings); a committed name ledger (rejected at M0: Q9 retired the committed census). The advisory lists surviving forks by name; it was first described as catching a one-sided rename, which measurement disproved, so both mirrors state the limit as it behaves | 9 |

## Appendix C: Evidence registry

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,15 @@ TypeScript `as const` 数组,以及拆为跨运行时孪生、同运行时分
- **改名可以洗白冲突。** 冲突按名字归组,因此把分叉的一侧改名会降低计数而
不消除漂移。这里的评审辅助是建议性合并报告;值集相同不能做成硬预算,因为
`CONFIDENCE_LEVELS` 与 `EDGE_CASE_COMPLEXITIES` 共享 `high/low/medium` 却
含义不同。
含义不同。合并候选报告本身并不覆盖这一边界:它把**不同**名字、**值集完全相同**
的项归为一组,而分叉是**同一个**名字下的模块互相分歧,所以那个分组永远不会列出
任何分叉。`divergent_value_sets(inventory)` 是按名字归组的补充报告,由
`--report` 打印,列出**仍然存在**的分叉及其分歧值集数量——此前它们只是一串数字。
它**不是**改名检测器:实测表明,单侧改名后该名字只剩一份定义,因而不再见得分叉,
会同时退出预算与这份报告。唯一会失败关闭的情形是**已声明**的名字,因为
`scope_declarations` 指明了每个定义模块,被改名的一侧不再匹配。未声明的单侧改名、
以及把所有一侧同时改名,都会让预算下降且没有任何报告会说。这一残余与本节其余
条目一样,在 M0 被接受。
- **单元素载体不可见。** 只有一个字符串成员的闭集不构成词表,因此把一个两值
集合降为一个值会让它完全退出清单。
- **字面量扫描可能误读同一行上无关的比较。** 形如
Expand Down Expand Up @@ -962,6 +970,7 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共
| --- | --- | --- | --- | --- |
| 2026-09-16 | Q9:全树按需计算;移除已提交结构清单 | 根据[维护者反馈](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394)实现,PR 评审待完成 | 取代合并后补再生成;拒绝只扫描 diff | 1、I6、3、5、9、10、12 |
| 2026-09-16 | B2:Python producer 扫描器绑定一跳未改名再导出 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2;PR 评审待完成 | 要求每个消费者都从 owner 模块导入(脆弱;M2 中已静默失效);拒绝无界多跳解析 | 5、附录 A |
| 2026-09-16 | B1 改名不变性:新增按名字归组的分歧报告;写明它未闭合的边界 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B1;PR 评审待完成 | 把预算改按值集归组(否决:`CONFIDENCE_LEVELS` 与 `EDGE_CASE_COMPLEXITIES` 共享 `high/low/medium` 而含义不同);提交名字账本(M0 否决:Q9 已退役提交式清单)。该报告列出仍然存在的分叉;初稿称它能抓住单侧改名,实测证否,故两份镜像按真实行为写明边界 | 9 |

## 附录 C:证据登记

Expand Down
36 changes: 36 additions & 0 deletions loopx/semantics/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,42 @@ def merge_candidate_groups(inventory: dict[str, Any]) -> list[dict[str, Any]]:
return sorted(groups, key=lambda group: (-len(group["names"]), group["names"]))


def divergent_value_sets(inventory: dict[str, Any]) -> list[dict[str, Any]]:
"""Advisory: forks, keyed by name, with how many value sets each carries.

Complements ``merge_candidate_groups``, which groups *different* names with
*identical* value sets and therefore never lists a fork at all: a fork is
*one* name whose modules disagree, so the two reports answer different
questions and the merge-candidate grouping cannot substitute for this one.

What this does **not** do is detect a rename. Renaming one side of a fork
leaves that name with a single definition, so it stops being a fork and
drops out of this report exactly as it drops out of the budget -- measured,
not assumed. The RFC Section 9 limit therefore stands unclosed; this report
only makes the *surviving* forks visible by name, with the count of
disagreeing value sets, where before they were visible only as a number.
A rename of one side is still caught when the name is declared, because the
declaration names every defining module and the renamed side no longer
matches.

Nothing here is a second authority: advisory output over the same computed
inventory, never committed, never a budget input.
"""
entries: list[dict[str, Any]] = []
for entry in inventory["duplicate_definitions"]["multi_value_forks"]:
value_sets = {tuple(sorted(item["values"])) for item in entry["definitions"]}
if len(value_sets) < 2:
continue
entries.append(
{
"name": entry["name"],
"value_sets": len(value_sets),
"definition_modules": sorted({item["module"] for item in entry["definitions"]}),
}
)
return sorted(entries, key=lambda item: (-item["value_sets"], item["name"]))


def consumer_ranking(inventory: dict[str, Any], sources: list[SourceFile]) -> list[dict[str, Any]]:
"""Advisory ranking: modules outside the definer that mention each symbol.

Expand Down
6 changes: 6 additions & 0 deletions scripts/generate_semantic_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from loopx.semantics.inventory import ( # noqa: E402
build_inventory,
consumer_ranking,
divergent_value_sets,
load_sources,
render_inventory,
)
Expand All @@ -46,6 +47,11 @@ def main() -> int:
print("external_consumer_modules values name module")
for row in rows:
print(f"{row['external_consumer_modules']:>25} {row['values']:>6} {row['name']:<{width}} {row['module']}")
divergent = divergent_value_sets(inventory)[: args.top]
print()
print("value_sets name definition_modules")
for row in divergent:
print(f"{row['value_sets']:>10} {row['name']} {', '.join(row['definition_modules'])}")
return 0
if args.output is None:
print(content, end="")
Expand Down
119 changes: 119 additions & 0 deletions tests/architecture/test_semantic_vocabulary_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,125 @@ def test_bounded_context_scope_excludes_only_declared_multi_value_fork() -> None
assert smoke["check_scope_declarations"](registry, inventory) == 3


def test_renaming_one_side_of_a_fork_launders_the_semantic_budget() -> None:
"""Pin the RFC Section 9 known limit so a future fix cannot be a silent edit.

Collisions are keyed by name, so renaming one module's definition removes the
name from ``multi_value_forks`` and lowers the semantic budget by one while
the drift stays in the tree. This test asserts the limit as it is documented,
not as it should be: if a change makes renaming refuse to lower the budget,
this test must fail so the RFC's known-limits section is updated with it.
"""
smoke = runpy.run_path(str(SMOKE))
registry = smoke["load_registry"]()
sources = smoke["load_sources"](REPO_ROOT)
before = smoke["build_inventory"](REPO_ROOT, sources=sources)
assert smoke["check_scope_declarations"](registry, before) == 3

path = "loopx/state_projection.py"
name = "AGENT_TODO_HEADER_MARKERS"
renamed = [
smoke["SourceFile"](source.path, source.suffix, source.text.replace(name, name + "_RENAMED", 1))
if source.path == path
else source
for source in sources
]
after = smoke["build_inventory"](REPO_ROOT, sources=renamed)
assert smoke["check_scope_declarations"](registry, after) == 2, (
"a rename no longer lowers the semantic budget; the RFC known-limits entry "
"('Renames launder a collision') is now stale and must be revised"
)
assert after["summary"]["multi_value_forks"] == before["summary"]["multi_value_forks"] - 1


def test_divergent_value_sets_lists_the_names_a_rename_would_hide() -> None:
"""The reviewer-facing signal for the laundering limit above.

``merge_candidate_groups`` groups *different* names with *identical* value
sets, so it cannot see a fork at all -- a fork is one name whose value sets
disagree. Renaming one side hides the name from both the budget and that
grouping, so this advisory is keyed by name and still lists the abandoned
name whenever the surviving definitions disagree.
"""
from loopx.semantics.inventory import divergent_value_sets

smoke = runpy.run_path(str(SMOKE))
sources = smoke["load_sources"](REPO_ROOT)
inventory = smoke["build_inventory"](REPO_ROOT, sources=sources)
listed = {row["name"] for row in divergent_value_sets(inventory)}
assert {"AGENT_TODO_HEADER_MARKERS", "USER_TODO_HEADER_MARKERS", "RAW_MATERIAL_KEY_HINTS"} <= listed

# The advisory is not a budget input: it must not appear in the committed
# inventory, which stays the single computed authority.
assert "divergent_value_sets" not in inventory


def _restate(smoke, sources, path, name, replacement):
return [
smoke["SourceFile"](source.path, source.suffix, source.text.replace(name, replacement, 1))
if source.path == path
else source
for source in sources
]


def test_rename_visibility_splits_into_three_cases() -> None:
"""The complete boundary, measured, so no reader has to re-derive it.

Case 1: a partial rename of a **declared** name is rejected outright -- the
declaration names every defining module and the renamed side no longer
matches, so the rename cannot lower the budget.

Cases 2 and 3 are the RFC Section 9 limit, and this test records it as it
actually behaves rather than as the limit's heading suggests: renaming one
side leaves the name with a single definition, so it stops being a fork and
drops out of ``divergent_value_sets`` exactly as it drops out of the budget.
The advisory makes *surviving* forks visible by name; it does not detect the
rename. Both cases assert that so a future claim of coverage fails here.
"""
from loopx.semantics.inventory import divergent_value_sets

smoke = runpy.run_path(str(SMOKE))
registry = smoke["load_registry"]()
sources = smoke["load_sources"](REPO_ROOT)
name = "AGENT_TODO_HEADER_MARKERS"

def renamed_in(paths):
out = sources
for path in paths:
out = [
smoke["SourceFile"](source.path, source.suffix, source.text.replace(name, name + "_RENAMED", 1))
if source.path == path
else source
for source in out
]
return out

# Case 1: declared name, one side renamed -> the declaration no longer resolves.
declared = _restate(smoke, sources, "loopx/global_todos.py", "SOURCE_SURFACES", "GT_SOURCE_SURFACES")
with pytest.raises(smoke["Drift"], match="every defining module"):
smoke["check_scope_declarations"](registry, smoke["build_inventory"](REPO_ROOT, sources=declared))

# Case 2: undeclared name, one side renamed -> gone from the budget AND the advisory.
partial = smoke["build_inventory"](REPO_ROOT, sources=renamed_in(["loopx/state_projection.py"]))
assert name not in {entry["name"] for entry in partial["duplicate_definitions"]["multi_value_forks"]}
assert name not in {row["name"] for row in divergent_value_sets(partial)}

# Case 3: every side renamed -> also invisible; indistinguishable from an honest rename.
whole = smoke["build_inventory"](
REPO_ROOT,
sources=renamed_in([
"loopx/state_projection.py",
"loopx/control_plane/goals/active_state_metadata.py",
]),
)
assert name not in {entry["name"] for entry in whole["duplicate_definitions"]["multi_value_forks"]}
assert name not in {row["name"] for row in divergent_value_sets(whole)}

# The surviving forks are what the advisory does list, by name.
assert "USER_TODO_HEADER_MARKERS" in {row["name"] for row in divergent_value_sets(partial)}


def test_bounded_context_scope_requires_every_distinct_defining_module() -> None:
smoke = runpy.run_path(str(SMOKE))
registry = copy.deepcopy(smoke["load_registry"]())
Expand Down