Skip to content

Commit c09b7e2

Browse files
authored
Merge pull request #4630 from songoow/codex/merge-candidate-visibility
feat(semantics): print the merge candidates the registry does not explain
2 parents 4b6bbc9 + 6f11b95 commit c09b7e2

5 files changed

Lines changed: 218 additions & 11 deletions

File tree

‎docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -643,10 +643,19 @@ lists Python enums, closed sets, `Literal` aliases, TypeScript `as const`
643643
arrays, and duplicate definitions split into cross-runtime twins, same-runtime
644644
forks, conflicting values, and multi-value twins and forks, one entry per line.
645645
Every multi-value collision carries each defining module and its value set, so
646-
the divergence itself is reviewable rather than only its count. Consumer counts are printed by `--report`; merge-candidate groups are available
647-
through `merge_candidate_groups`; all inventory output is uncommitted.
646+
the divergence itself is reviewable rather than only its count. Consumer counts
647+
and merge-candidate groups are both printed by `--report`;
648+
`merge_candidate_groups` returns the groups; all inventory output is
649+
uncommitted.
648650
Merge candidates are advisory
649-
because an equal value set is not proof of one concept. Single-module string
651+
because an equal value set is not proof of one concept. The printed list drops
652+
the groups whose names are exactly one registered vocabulary's own owner
653+
symbols: `EffectiveAction` and `EFFECTIVE_ACTIONS` are two runtimes spelling one
654+
registered concept, not two concepts to merge. Calling `merge_candidate_groups`
655+
without the registry keeps the unfiltered list. A dropped pair is already
656+
ruled on, so dropping it retires nothing and classifies nothing; each remaining
657+
group is printed with its names, values, modules, and whether its modules span
658+
both runtimes, which is the shape of a registry gap. Single-module string
650659
constants are counted, not listed.
651660

652661
Values are additive. Removing a value, a field, an owner, or a relation is a

‎docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -523,9 +523,14 @@ external_input | compatibility_only | unknown
523523
报告不入库;它每行一条地列出 Python 枚举、闭集、`Literal` 别名、
524524
TypeScript `as const` 数组,以及拆为跨运行时孪生、同运行时分叉、冲突值、多值
525525
孪生与多值分叉四类的重复定义。每个多值冲突都带上全部定义模块及其值集,因此
526-
可评审的是分叉本身而不只是计数。消费者计数由 `--report` 打印,合并候选组通过 `merge_candidate_groups` 获取,
527-
所有清单输出均不提交;合并候选是建议性的,因为值集
528-
相同并不能证明是同一个概念。单模块的字符串常量只计数,不列出。
526+
可评审的是分叉本身而不只是计数。消费者计数与合并候选组都由 `--report` 打印,
527+
`merge_candidate_groups` 返回这些组,所有清单输出均不提交;合并候选是建议性的,因为值集
528+
相同并不能证明是同一个概念。打印的列表会剔除那些名字恰好等于某个已注册词表自身
529+
owner 符号集合的组:`EffectiveAction` 与 `EFFECTIVE_ACTIONS` 是同一个已注册概念在两个
530+
运行时的两种拼法,不是两个待合并的概念。不传注册表调用 `merge_candidate_groups`
531+
仍可得到未过滤列表。被剔除的对已有定论,因此剔除既不退休任何东西也不做任何分类;
532+
留下的每一组都带上名字、值、模块,以及模块是否横跨两个运行时——后者正是注册表
533+
缺口的形状。单模块的字符串常量只计数,不列出。
529534

530535
值是只增的。删除一个值、字段、owner 或关系属于 schema 缩减,遵循 `AGENTS.md`
531536
规则:枚举受影响表面、调研生产者与读者、在同一 diff 中调低下限、记录维护者

‎loopx/semantics/inventory.py‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -412,15 +412,52 @@ def one_line(value: Any) -> str:
412412
return "\n".join(lines) + "\n"
413413

414414

415-
def merge_candidate_groups(inventory: dict[str, Any]) -> list[dict[str, Any]]:
415+
def registered_owner_symbol_sets(registry: dict[str, Any]) -> set[frozenset[str]]:
416+
"""Symbol sets that one registered vocabulary already binds into one concept.
417+
418+
A cross-runtime vocabulary names its owner twice because the runtimes spell
419+
it differently: ``EffectiveAction`` in Python and ``EFFECTIVE_ACTIONS`` in
420+
TypeScript are the registered owners of ``effective_action``. The registry
421+
entry is the decision that the two spellings are one vocabulary, so their
422+
equal value sets carry no review question. Only sets with more than one
423+
distinct symbol are returned; a vocabulary with a single owner, or with the
424+
same symbol on both sides, explains nothing.
425+
"""
426+
sets: set[frozenset[str]] = set()
427+
for vocabulary in registry["vocabularies"].values():
428+
symbols = frozenset(
429+
owner.split("::")[-1] for owner in (vocabulary.get("owners") or {}).values() if owner
430+
)
431+
if len(symbols) > 1:
432+
sets.add(symbols)
433+
return sets
434+
435+
436+
def merge_candidate_groups(
437+
inventory: dict[str, Any], registry: dict[str, Any] | None = None
438+
) -> list[dict[str, Any]]:
416439
"""Advisory: distinct names carrying an identical multi-value set.
417440
418441
Value-set equality is a candidate signal, not proof of one concept:
419442
``CONFIDENCE_LEVELS`` and ``EDGE_CASE_COMPLEXITIES`` share ``high/low/medium``
420443
while meaning different things. Each group is for review, never auto-merged,
421444
and is printed on demand rather than committed so it cannot be mistaken for a
422445
ratified decision.
446+
447+
``registry`` drops the groups whose names are exactly the owner symbols of
448+
one registered vocabulary. Those are a naming convention, not duplication:
449+
the registry has already ruled the two spellings one concept, and leaving
450+
them in the list buries the groups that nobody has ruled on. Passing
451+
``None`` keeps the unfiltered list, which is what an audit of the raw
452+
value-set collisions wants. Filtering removes noise from a review list; it
453+
retires nothing and settles no group that stays.
454+
455+
``cross_runtime`` marks a group whose modules span both runtimes. Such a
456+
group is a registry gap by construction, because a value set living in
457+
Python and TypeScript with no vocabulary binding them is exactly what the
458+
registry exists to record.
423459
"""
460+
explained = registered_owner_symbol_sets(registry) if registry is not None else set()
424461
by_values: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
425462
for section in ("python_enums", "python_closed_sets", "python_literal_aliases", "typescript_const_arrays"):
426463
for entry in inventory[section]:
@@ -429,13 +466,16 @@ def merge_candidate_groups(inventory: dict[str, Any]) -> list[dict[str, Any]]:
429466
groups: list[dict[str, Any]] = []
430467
for values, entries in by_values.items():
431468
names = sorted({entry["name"] for entry in entries})
432-
if len(names) < 2:
469+
if len(names) < 2 or frozenset(names) in explained:
433470
continue
471+
modules = sorted({entry["module"] for entry in entries})
434472
groups.append(
435473
{
436474
"names": names,
437475
"values": list(values),
438-
"modules": sorted({entry["module"] for entry in entries}),
476+
"modules": modules,
477+
"cross_runtime": any(module.endswith(".py") for module in modules)
478+
and any(module.endswith(".ts") for module in modules),
439479
}
440480
)
441481
return sorted(groups, key=lambda group: (-len(group["names"]), group["names"]))

‎scripts/generate_semantic_inventory.py‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
uv run python scripts/generate_semantic_inventory.py # JSON to stdout, no writes
66
uv run python scripts/generate_semantic_inventory.py --output .local/inventory.json
77
uv run python scripts/generate_semantic_inventory.py --output .local/inventory.json --check
8-
uv run python scripts/generate_semantic_inventory.py --report # advisory consumer ranking
8+
uv run python scripts/generate_semantic_inventory.py --report # advisory consumer ranking + merge candidates
99
"""
1010

1111
from __future__ import annotations
@@ -24,14 +24,47 @@
2424
consumer_ranking,
2525
divergent_value_sets,
2626
load_sources,
27+
merge_candidate_groups,
2728
render_inventory,
2829
)
2930

31+
REGISTRY_RELATIVE = "loopx/semantics/vocabulary_v0.json"
32+
33+
34+
def print_merge_candidates(inventory: dict) -> None:
35+
"""Print the merge candidates the registry does not already explain.
36+
37+
Advisory, like the ranking above it: an equal value set is a question for a
38+
reviewer, never an auto-merge. Groups that are one registered vocabulary's
39+
own Python and TypeScript owner symbols are dropped because the registry
40+
has already ruled them one concept; hiding them retires nothing and
41+
classifies nothing, it only leaves the unruled groups readable. A group
42+
whose modules span both runtimes is a registry gap: the same value set
43+
lives in two runtimes with no vocabulary binding them.
44+
"""
45+
registry = json.loads((ROOT / REGISTRY_RELATIVE).read_text(encoding="utf-8"))
46+
groups = merge_candidate_groups(inventory, registry)
47+
raw = len(merge_candidate_groups(inventory))
48+
print(
49+
f"merge candidates (advisory, never auto-merged): {len(groups)} to review, "
50+
f"{raw - len(groups)} explained by a registered vocabulary's own owner symbols, "
51+
f"{raw} raw groups"
52+
)
53+
for group in sorted(groups, key=lambda item: (not item["cross_runtime"], item["names"])):
54+
scope = "cross-runtime" if group["cross_runtime"] else "python-only"
55+
print(f" [{scope}] {', '.join(group['names'])}")
56+
print(f" values: {', '.join(group['values'])}")
57+
print(f" modules: {', '.join(group['modules'])}")
58+
59+
3060
def main() -> int:
3161
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
3262
destination = parser.add_mutually_exclusive_group()
3363
destination.add_argument("--output", type=Path, help="write an optional report to this path instead of stdout")
34-
destination.add_argument("--report", action="store_true", help="print the advisory consumer ranking")
64+
destination.add_argument(
65+
"--report", action="store_true",
66+
help="print the advisory consumer ranking and the merge candidates the registry does not explain",
67+
)
3568
parser.add_argument("--check", action="store_true", help="compare an explicit --output report without writing")
3669
parser.add_argument("--top", type=int, default=25, help="rows to print with --report")
3770
args = parser.parse_args()
@@ -52,6 +85,8 @@ def main() -> int:
5285
print("value_sets name definition_modules")
5386
for row in divergent:
5487
print(f"{row['value_sets']:>10} {row['name']} {', '.join(row['definition_modules'])}")
88+
print()
89+
print_merge_candidates(inventory)
5590
return 0
5691
if args.output is None:
5792
print(content, end="")

‎tests/architecture/test_semantic_inventory.py‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
SourceFile,
2222
build_inventory,
2323
load_sources,
24+
merge_candidate_groups,
2425
python_facts,
26+
registered_owner_symbol_sets,
2527
render_inventory,
2628
)
2729

@@ -180,6 +182,122 @@ def test_module_local_convention_names_stay_out_of_semantic_budgets(collision_re
180182
assert summary["multi_value_twins"] == 1
181183

182184

185+
@pytest.fixture
186+
def merge_candidate_repo(tmp_path: Path) -> Path:
187+
"""Three name pairs carry one value set each; only one pair is registered.
188+
189+
``Kind``/``KINDS`` are the two owners of a single registered vocabulary, so
190+
their equal value set is a naming convention across runtimes rather than
191+
duplication. ``ALPHA_STAGES``/``MIRROR_STAGES`` is unregistered and lives
192+
only in Python; ``OTHER_SIDES``/``ZED_SIDES`` is unregistered and spans both
193+
runtimes, which is the registry-gap shape. ``MIRROR_STAGES`` is a registered
194+
owner with no TypeScript counterpart, so its vocabulary explains no pair.
195+
"""
196+
_write(
197+
tmp_path,
198+
"loopx/a.py",
199+
'from enum import Enum\n'
200+
'class Kind(str, Enum):\n ONE = "one"\n TWO = "two"\n'
201+
'ALPHA_STAGES = ("draft", "final")\n',
202+
)
203+
_write(tmp_path, "loopx/b.ts", 'export const KINDS = ["one", "two"] as const;\n')
204+
_write(
205+
tmp_path,
206+
"loopx/c.py",
207+
'MIRROR_STAGES = ("draft", "final")\nOTHER_SIDES = ("left", "right")\n',
208+
)
209+
_write(tmp_path, "loopx/d.ts", 'export const ZED_SIDES = ["left", "right"] as const;\n')
210+
_write(
211+
tmp_path,
212+
"loopx/semantics/vocabulary_v0.json",
213+
json.dumps(
214+
{
215+
"vocabularies": {
216+
"kind": {
217+
"owners": {
218+
"python": "loopx/a.py::Kind",
219+
"typescript": "loopx/b.ts::KINDS",
220+
}
221+
},
222+
"mirror_stages": {
223+
"owners": {"python": "loopx/c.py::MIRROR_STAGES", "typescript": None}
224+
},
225+
}
226+
}
227+
),
228+
)
229+
subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
230+
subprocess.run(
231+
["git", "-C", str(tmp_path), "add", "loopx/a.py", "loopx/b.ts", "loopx/c.py", "loopx/d.ts"],
232+
check=True,
233+
)
234+
return tmp_path
235+
236+
237+
def _registry(repo: Path) -> dict:
238+
return json.loads((repo / "loopx/semantics/vocabulary_v0.json").read_text(encoding="utf-8"))
239+
240+
241+
def test_one_vocabulary_owning_two_spellings_explains_no_merge_candidate(
242+
merge_candidate_repo: Path,
243+
) -> None:
244+
"""A registered owner pair is a naming convention, not a merge candidate.
245+
246+
Without the registry the advisory list mixed each cross-runtime vocabulary's
247+
own two owner symbols in with the groups nobody has ruled on, so the list
248+
read as duplication it was not. Filtering only hides the settled pairs; it
249+
retires nothing and classifies none of the groups that stay.
250+
"""
251+
inventory = build_inventory(merge_candidate_repo)
252+
unfiltered = {tuple(group["names"]) for group in merge_candidate_groups(inventory)}
253+
assert ("KINDS", "Kind") in unfiltered, "the unfiltered audit still sees every value-set collision"
254+
filtered = merge_candidate_groups(inventory, _registry(merge_candidate_repo))
255+
assert {tuple(group["names"]) for group in filtered} == {
256+
("ALPHA_STAGES", "MIRROR_STAGES"),
257+
("OTHER_SIDES", "ZED_SIDES"),
258+
}, "an unregistered pair with the same value set is still reported"
259+
assert registered_owner_symbol_sets(_registry(merge_candidate_repo)) == {
260+
frozenset({"Kind", "KINDS"})
261+
}, "a vocabulary with one owner symbol explains nothing"
262+
263+
264+
def test_merge_candidates_mark_the_pairs_that_span_both_runtimes(
265+
merge_candidate_repo: Path,
266+
) -> None:
267+
"""Cross-runtime is the registry gap; Python-only is a human question."""
268+
groups = merge_candidate_groups(
269+
build_inventory(merge_candidate_repo), _registry(merge_candidate_repo)
270+
)
271+
assert {tuple(group["names"]): group["cross_runtime"] for group in groups} == {
272+
("ALPHA_STAGES", "MIRROR_STAGES"): False,
273+
("OTHER_SIDES", "ZED_SIDES"): True,
274+
}
275+
276+
277+
def test_report_prints_the_unexplained_merge_candidates_in_a_stable_order(
278+
merge_candidate_repo: Path, monkeypatch, capsys
279+
) -> None:
280+
"""``--report`` is where a reviewer sees the list, so it must not churn."""
281+
from scripts import generate_semantic_inventory as generator
282+
283+
monkeypatch.setattr(generator, "ROOT", merge_candidate_repo)
284+
monkeypatch.setattr(sys, "argv", ["generate_semantic_inventory", "--report"])
285+
assert generator.main() == 0
286+
first = capsys.readouterr().out
287+
assert generator.main() == 0
288+
assert capsys.readouterr().out == first, "the same tree must print the same advisory list"
289+
listing = first.split("merge candidates", 1)[1]
290+
assert "2 to review, 1 explained by a registered vocabulary's own owner symbols, 3 raw groups" in listing
291+
assert "Kind" not in listing, "the explained owner pair is not printed"
292+
assert " [cross-runtime] OTHER_SIDES, ZED_SIDES" in listing
293+
assert " values: left, right" in listing
294+
assert " modules: loopx/c.py, loopx/d.ts" in listing
295+
assert " [python-only] ALPHA_STAGES, MIRROR_STAGES" in listing
296+
assert listing.index("[cross-runtime]") < listing.index("[python-only]"), (
297+
"registry gaps sort ahead of the Python-only groups regardless of name order"
298+
)
299+
300+
183301
def test_render_is_deterministic_valid_json(repo: Path) -> None:
184302
inventory = build_inventory(repo)
185303
rendered = render_inventory(inventory)

0 commit comments

Comments
 (0)