Skip to content

feat(semantics): report consumer roles per site with the unknown stated (B5) - #4663

Merged
huangruiteng merged 4 commits into
loopx-project:mainfrom
songoow:codex/b5-consumer-evidence-report
Sep 18, 2026
Merged

huangruiteng merged 4 commits into
loopx-project:mainfrom
songoow:codex/b5-consumer-evidence-report

Conversation

@songoow

@songoow songoow commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

What this is

consumer_ranking() ranks modules that mention a symbol. The RFC is explicit that this "does not classify roles or prove data flow". B5 adds loopx/semantics/consumer_report.py, a bounded AST scan that classifies each consuming site as read, interpret, pass_through or unknown, carrying per row the location (module::symbol and line), the source SHA the scan ran against, the value domain, and the limitation that applies to that row.

Anchors are identities the registry already carries — which is why B2 was the precondition: the vocabulary's slot name (literal_scan.field, else the vocabulary id) and its registered owner class, bound through the same one-unrenamed-hop import discipline python_production.py uses. No module registers itself as a consumer, and the scan reach is code owned exactly as PRODUCER_ROOTS is, so registry data cannot widen it.

What it proves

A row says: this location performs a recognized read of this vocabulary's slot, and the syntax around that read branches on the value, forwards it, or does neither. That is syntactic use.

What it does not prove

It is not data flow. It does not prove the value observed at that site came from a registered producer, that a branch is reachable, or — most importantly — that a vocabulary with no rows has no reader. Following a local is one hop; a value moving through two aliases is unknown, not traced. TypeScript is counted but not parsed. The reach is two roots, not the tree.

Measured on representative paths

440b002fb, over loopx/control_plane and loopx/cli_commands (484 of 1207 tracked sources):

rows
read 3
interpret 141
pass_through 50
unknown 727
total 921

Unknown share: 78.9% overall; 39.2% across the 319 rows that belong to a single vocabulary. The unknown is most of the measurement, not a residue:

  • dynamic_key 602 — a mapping read under a computed key, unresolved for every vocabulary at once. This is exactly why "zero readers" can never be read as "dead".
  • mention_without_recognized_anchor 76 — the slot token is in the module and the grammar recognized nothing. Reported, because otherwise the per-vocabulary tables would read as complete.
  • typescript_not_walked 46
  • unstable_local 2, unclassified_context 1

Every unknown is a row with a location and a recorded reason, following the pattern B2 set for unresolved producer sites. Computed subscripts are deliberately excluded: rows[index] and payload[key] are the same syntax, and counting sequence indexing would inflate the unknown until it stopped carrying information.

Advisory, not a gate

This is F3's advisory lane. There is no blanket consumer registration and no global merge gate — the tracking issue forbids both, and a 78.9% unknown share could not gate anything honestly. It is exposed as an opt-in section of the existing report surface, scripts/generate_semantic_inventory.py --report --consumer-evidence.

Runtime cost: the per-site scan is 3.35–3.65s over three isolated runs on the full tree, against roughly 217s for the ranking --report already prints. The drift smoke does not call it, and the only pull-request path reaching --report is a three-file pytest fixture repository, so the per-PR cost is unchanged (drift smoke 32.8s under premerge, same as before).

Relation to #4651

#4651 (loopx/semantics/field_use.py, not merged) classifies payload field names into reader/writer/binding/unresolved/mention to make the retirement budgets mean something. This module classifies registered vocabulary values into the RFC's consumer subroles. They share the AST technique and the discipline of reporting unresolved sites rather than dropping them; they answer different questions, do not import each other, and touch no common file except the RFC pair and scripts/generate_semantic_inventory.py.

Validation

  • python3 examples/semantic-vocabulary-drift-smoke.py — ok, every budget unchanged at its limit
  • python3 examples/docs-governance-smoke.py — ok
  • pytest tests/architecture/ -q — 368 passed (33 new, in a new file; test_semantic_vocabulary_drift.py untouched)
  • loopx canary premerge --from-git-diffmerge_gate_passed: true, 11 selected, 0 failures

Refs #4447 (B5)

🤖 Generated with Claude Code

consumer_ranking() counts modules that mention a symbol; the RFC already
says that number classifies no role and proves no data flow. B5 adds a
bounded AST scan that classifies each consuming site as read, interpret,
pass_through or unknown, carrying per row the location, the source SHA
the scan ran against, the value domain and the limitation for that row.

Anchors come from identities the registry already holds, which is why B2
was the precondition: the vocabulary's slot name and its registered owner
class, bound through the same one-unrenamed-hop import discipline the
producer scanner uses. No module registers itself as a consumer and the
scan reach is code owned, so registry data cannot widen it.

What the report establishes is syntactic use, not data flow. Anything
dynamic, aliased or external is unknown with a recorded reason rather
than dropped, so the per-vocabulary tables cannot read as complete: on
440b002 over loopx/control_plane and loopx/cli_commands, 921 rows are
3 read, 141 interpret, 50 pass_through and 727 unknown, a 78.9% unknown
share, or 39.2% across the 319 rows that belong to one vocabulary.

Exposed as an opt-in section of the existing --report surface. It is
advisory evidence in F3's lane; it is not a gate and must not become one.

Refs loopx-project#4447 (B5)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: song <22676124+songoow@users.noreply.github.com>
huangruiteng
huangruiteng previously approved these changes Sep 17, 2026

@huangruiteng huangruiteng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head: 63fed66b9f6d458ba7b297221166bf70b795a508 (base main @ 440b002fb; 该 head 与 2026-09-17T14:39:07Z 相比未变)。

动机

这是 #4447 的 B5 切片,审阅框架是仓库自己的 RFC docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md(第 5 节与 F3 所在的 advisory 层)。

RFC 第 5 节已经写明 consumer_ranking 只统计"提到过某个符号的模块",它"不分类角色、也不证明数据流"。可是退役计划正是按这个数字排序的:一句 prompt 文本、一条注释、一张分发表在这个指标里权重相同。于是有一类问题无法回答——哪些位点在这个值、哪些只是转发、哪些在分支解释它、哪些根本无法用按名字索引的扫描判定。

B5 补的就是这层证据,而且它的做法与仓库对"未解析"的一贯处理一致:不是把看不出来的位点丢掉,而是让它们带着原因留下来。

改动思路

作者没有让任何模块"把自己注册成消费者",也没有新增注册表字段。锚点全部来自注册表已经声明过的身份:词表的 slot 名(literal_scan.field,否则取词表 id)与已注册的 owner 类(通过生产者扫描器同一套"一次未改名 import"纪律绑定)。这正是 B2 作为前置条件的理由——消费者证据站在生产者绑定之上,而不是另立一套。

第二个决定是扫描范围由代码拥有CONSUMER_SCAN_ROOTSPRODUCER_ROOTS 同构,注册表数据无法为了"买到覆盖率"而放宽它。

第三个决定是把 unknown 当成测量的一部分。模块打印两个 unknown 占比:整体占比包含"按计算键读取"的位点(它们同时属于所有词表,天然主导计数),归因占比只统计已经落到某个词表的行。只打印一个会因口径不同而美化或夸大结果。每行还带自己的 limitation,读者不必猜这条 unknown 属于哪一种缺口。

最后,作者把"这不是门"写进了代码、CLI 帮助和 RFC 三处,并要求任何提升都先经过 RFC 决策。

具体改动

loopx/semantics/consumer_report.py(新增 616 行)ConsumerSite/ConsumerEvidence 两个 frozen dataclass 承载行与整次扫描;_slot_anchors/_membership_anchors/_owner_anchors/_dynamic_anchors 负责找锚点;_classify_use 做有限次(12 层)父节点上溯,把比较、match subject、if/while/assert 条件、条件表达式、下标键判为 interpret,把 return、dict value、调用实参、f-string、写入下标/属性判为 pass_through,裸表达式语句判为 read_classify_anchor 在此基础上最多花一次局部变量跳转,单赋值、未被遮蔽、未逃逸到嵌套作用域才允许,否则记 unstable_local/nested_scope_escape/alias_chainscan_dynamic_reads 把"计算键的映射读取"记为对 * 的 unknown,并刻意排除 rows[index] 这类序列下标,避免把索引误算成 unresolved 读取。

scripts/generate_semantic_inventory.py(+20):在既有 --report 之后追加可选分支,--consumer-evidence 单独使用会被 parser.error 拒绝;默认关闭时输出与改动前一致。

tests/architecture/test_semantic_consumer_report.py(新增 333 行):33 条测试,正向固定每个角色一条,负向固定三类"扫描不得声称"的情形:同前缀标识符是另一个 slot、裸提及不是读取、序列下标不是 unresolved 映射读取。

两份 RFC 镜像(+49 / +40):第 9 节新增验收行,附录 A 新增 B5 条目并记录实测数字与四类缺口,附录 B 记录被拒绝的三个替代方案及理由。

对主干的风险

这个 PR 没有任何运行时行为改动,所以我把重点放在"文档/证据是否会失真"上,并且逐项复算:

  1. 实测数字复算(在审阅 worktree 内直接调用模块 API,走真实 registry 与真实源码树):921 行,read=3interpret=141pass_through=50unknown=727,整体 unknown 占比 78.9%;归因行 319 条、其中 unknown 125、占比 39.2%;缺口原因 dynamic_key=602mention_without_recognized_anchor=76typescript_not_walked=46unstable_local=2unclassified_context=1;扫描 484 个 Python 源文件、source_sha=63fed66b9f6ddirty=false与 RFC 账本逐项一致。
  2. 负向边界独立复现payload.get(name) → 不归属任何词表,只产生一条 * 的 unknown(dynamic_key);payload["probe_slot_extra"] → 对 slot probe_slot 零行;rows[index] → 零条动态读取。同一进程内重复三次收集,计数完全相同(确定性)。
  3. 不扰动既有度量:drift smoke 在 head 打印 ok,且 ratchet 行与基线逐字相同(same_runtime_forks=18/18conflicting_values=16/16twins_raw=44unresolved_producer_sites=41),唯一变化是 producer_scan_reach12071208 个文件——因为新模块本身是一个源文件。tests/architecture 全量 368 passed,新测试文件 33 passed
  4. "不是门"可核查:全仓库检索 consumer_report / collect_consumer_evidence / --consumer-evidence,除新模块、新测试与文档外只有那个脚本;.github/workflowsscripts/ci 中没有任何对 generate_semantic_inventory 的调用,drift smoke 也不引用它。

非阻塞发现两条(均已在正文/账本中部分披露,不构成阻断):

  • P3:模块头部注释用"模块级 NAME = (...) 字符串字面量本身就是 inventory carrier"来解释私有命名,但实际机制是赋值形态ANY_VOCABULARY = '*' 是模块级字符串常量,确实被 collect_string_constants 收进 inventory(单一定义、不构成同运行时 fork,smoke ratchet 未变,故今天无影响)。将来有人按注释的"私有命名"规则行事会误判。
  • P3:RFC 账本称"唯一走到 --report 的 PR 路径是一个三文件 pytest fixture 仓库"。实际该 fixture 写入并 git-add 了 loopx/a.pyb.tsc.pyd.ts 四个文件(外加一个未跟踪的 registry JSON)。结论(PR 路径成本未变)成立,但数字应为四个。

我的整体评价

APPROVE。这是一个把"我们量不出来"变成可审计产物、同时严格限制自身主张的切片:证据来自注册表已有身份、范围由代码拥有、每行自带限制、未解析项全部留痕并按原因计数,而且在模块、CLI 帮助与 RFC 三处明确声明它不是门。我复算了账本里的全部数字,抽检了三条负向边界,并确认它没有改变任何既有检查的结论或既有度量。

它唯一的"弱"是被自己诚实地写出来的:78.9% 是 unknown。但这份报告同时给出归因后的 39.2% 与缺口构成,读者可以据此判断证据能用在哪、不能用在哪儿——这正是 consumer_ranking 之前做不到的。

同一作者在本轮还有 #4662#4664 两个同链切片;这是 #4447 声明链上的独立部分,不是同形状重复提交。

English verdict: APPROVE - PR #4663 at head 63fed66; the advisory per-site consumer-role report reproduces the RFC's numbers exactly at this head (921 rows, 3 read / 141 interpret / 50 pass_through / 727 unknown, 39.2% unknown among attributed rows), states every unknown with a reason and a limitation, and is opt-in and non-gating (verified: 33 new tests pass, tests/architecture 368 passed, drift-smoke ratchets unchanged, no CI reference to the new module).

@huangruiteng

Copy link
Copy Markdown
Collaborator

补充(合并前置事实,独立于上面的代码评审):本 head 目前与 main内容冲突,位置只有两份 RFC 镜像,原因是 main 在 15:20Z 合入了 #4661——同一文档的同一个附录 A 区域。

git merge-tree --write-tree origin/main origin/pr-4663
→ CONFLICT (content): docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md
→ CONFLICT (content): docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md

代码文件(loopx/semantics/consumer_report.py、脚本改动、新增测试)不冲突。更新分支时请把 #4661#4663 两条账本记录都保留并保持两份镜像同步;由于 dismiss_stale_reviews_on_push: true,更新后需要对新 head 重新评审(本 head 上的 APPROVE 只覆盖 63fed66b9f6d)。

@songoow songoow left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes conclusion (author-owned PR; GitHub blocks formal self-review)

Request changes conclusion (author-owned PR; GitHub blocks formal self-review)

动机

结论:REQUEST_CHANGES。审查对象为 #4663 的完整 head 63fed66b9f6d458ba7b297221166bf70b795a508,不是合并批准。 五个需修正的问题是:把实际解释者认成透传者、把被遮蔽的符号认成注册 owner、SHA 未覆盖实际输入、CLI 隐藏未知位点及行身份,以及把 vocabulary id 猜成已绑定槽位。它们影响这个报告本身的主要用途;并非要求 advisory 扫描器实现全程序证明。

#4447 的目标是减少共享控制流与协议词表的静默漂移。B5 的独立验收是:有具体 producer/slot 身份后,给维护者一个有位置、源码版本、值域和限制说明的消费者角色报告,不引入全量消费者登记或合并闸门。原来的 consumer_ranking() 只统计符号提及。为人工调查提供有界语法线索有价值,但当前实现还不能稳定支持“这里需要迁移解释逻辑,那里只搬运值”的区分,故 problem_context=not_yet_proven

B5 不是 M1 前置条件,也不完成 M3。M1 仍需要围绕真实字段路径、owner 和 producer/consumer 完成类型化及兼容验证;M3 仍需要 B3 和逐字段的 writer、reader、外部消费者、持久化格式证据。本 head 的 registry 中没有 M3 的六个退休字段,B5 也没有直接测量它们。相关 #4651 的读取/写入/未知分类属于 B3,本审查只检查其边界重叠,不重复该 PR 的完整审查或全量成本测量。

改动思路

入口是现有 scripts/generate_semantic_inventory.py --report --consumer-evidence。脚本先照旧建立 inventory、打印提及排名和候选组,再读取 registry 与 tracked working-tree 源码,调用 collect_consumer_evidence()。扫描范围固定为 loopx/control_planeloopx/cli_commands;Python 用 AST,TypeScript 只留下带槽位 token 的 module-level unknown。结果是临时派生诊断,不写回 registry、预算、Todo 或运行时决策。

正向路径已验证:return payload["probe"] == "alpha" 经槽位锚点、父节点分类和 renderer 输出 interpretreturn payload["probe"] 输出 pass_through。负向输入 --consumer-evidence 缺少 --report 时在 argparse 处退出 2,尚未扫描或写文件。计算键会生成 unknown 数据行,但公开报告随后把这些行丢掉,见 F4。

复用方面,PR 使用已有 load_sources()SourceFileenum_members()_qualified_bindings(),没有新增一份词表权威;放在现有 semantics 诊断 owner 下是合理的,也无需新 capability/provider。问题在于复用了模块导入绑定,却重新实现 scope/parent/local-use 分析,没有带上 python_production.scan_scope 已有的 shadow 排除规则。两个扫描器开始对“这个名字是不是 owner”给出不同答案。适当的后续重构是共享小范围的解析、词法作用域和身份解析原语;producer 的输出判断与 consumer 的使用判断仍可保持独立,不能因表面相似强行合成一个参数繁多的分析器。

具体改动

完整 diff 为 5 个文件:

  • loopx/semantics/consumer_report.py:新增诊断数据模型、来源版本读取、Python AST/scope 缓存、槽位/owner/计算键锚点、局部一跳分类、汇总与文本渲染。它不参与生产决策;唯一非测试调用者是 inventory 开发脚本。
  • scripts/generate_semantic_inventory.py:新增 import、帮助文字、可选参数及报告分支,保留原有 inventory/候选组入口。
  • tests/architecture/test_semantic_consumer_report.py:33 个新测试,覆盖直接读取、转发、分支、局部 reassignment/escape、动态键、扫描范围、确定性和源码树 dirty。缺少下面五组反例,尤其没有实际 --consumer-evidence 输出的可追溯性断言。
  • 英文与中文 semantic-vocabulary-convergence-v0 RFC:各增加验证表行、B5 执行账本与决策记录,披露 opt-in、两根目录、未知占比和语法边界。仓库已有 scripts/semantic_production_scan.mjs 的 TypeScript compiler AST;“没有 TypeScript AST here”只应理解为 B5 路径未接入,不是全仓没有解析能力。没有修改 F3 的 enforcement lane;其中“每个 unknown 都有可检查位置”和来源版本的陈述目前强于 CLI 的实际结果。

关键代码讲解

  1. source_revision(),146–162 行:读取 HEAD 和两个扫描目录的 Git 状态。失败退为 unknown, dirty=True,但忽略提供 domain/slot/owner 的 registry,也忽略扫描目录外的 owner/re-export 输入。
  2. _classify_use() / _classify_anchor(),285–397 行:向父节点最多走 12 层、跟随一跳局部变量,分支优先于转发。当前调用节点先赋予 pass_through,后续未知上下文仍保留该结论;BoolOp/UnaryOp 又被当成透明容器,导致 F1。
  3. scan_python_consumers(),466–499 行:从 registry owner 绑定模块名字,再逐 scope 生成位置、角色、domain 和 observed 值。模块级 bound 原封不动用于每个 scope,导致 F2。角色是本地诊断字符串,不是新运行时状态机。
  4. collect_consumer_evidence(),528–573 行:为选定词表扫描两根目录;slot 名来自 literal_scan.field,否则直接用 vocabulary id。名字扫描会错归同名字段的限制已披露;但没有 field 时把 vocabulary id 当作已绑定槽位,仍不满足 B5 的身份前提,见 F5。无锚点的 token 提及记 module unknown,计算键另记 *
  5. render_consumer_evidence(),576–616 行:输出两种未知占比和词表计数,但明细只取非 * 行,且没有 vocabulary/domain 列;--top 不能恢复被过滤掉的未知证据。

对主干的风险

下列均为 P2 正确性缺陷,本轮阻止批准,不是可延后的润色建议。影响面是 opt-in 开发报告的证据准确性,不是 scheduler/权限/数据写入回归。

F1 — 不要把值转换或未跟完的解释路径标成透传

位置:consumer_report.py:342,关联 349–351、375–397 行。

可复现调用:

from loopx.semantics.consumer_report import scan_python_consumers
from loopx.semantics.inventory import SourceFile

def roles(text):
    return [(r.role, r.blocker) for r in scan_python_consumers(
        SourceFile("loopx/control_plane/probe.py", ".py", text),
        vocabulary="probe", slot="probe_slot", values=("alpha", "beta"))]

print(roles('def f(p):\n    return not p["probe_slot"]\n'))
print(roles('def f(p):\n    return p["probe_slot"] and "replacement"\n'))

两者均输出 [('pass_through', None)],但返回值已经转换/按输入分支选择,不符合 RFC §5 的“without changing its meaning”。这不是只在玩具输入出现:全源码报告把 trajectory_hygiene.py::compact_history_event_channel:44::_material_transition:54str(...).strip() in MATERIAL_DELIVERY_OUTCOMES 也标为透传。真实函数对 primary_goal_outcome 返回 outcome/True,对 surface_only 返回 task_event/False,明确在解释该值。

最小修正: 将有语义的布尔/一元运算分类为解释或 unknown;遇到未经证明透明的调用、方法访问或后续用法时,不能把早期“传参”升级成整个使用路径的透传结论。保留直接转发正例,并用上述真实生产表达式做回归。无需增加通用跨函数求值器;不能证明的部分诚实记 unknown 即可。

F2 — owner 绑定必须尊重当前 scope 的遮蔽

位置:consumer_report.py:477,关联 483、497 行。

在给扫描器传入注册 owner.py::ProbeKind、成员 ALPHA: "alpha" 后扫描:

from loopx.control_plane.owner import ProbeKind

def choose(x, ProbeKind):
    return x is ProbeKind.ALPHA

结果是 interpret / owner_member / observed=('alpha',),没有 blocker。把参数遮蔽换成函数内 ProbeKind = other 也一样。运行时引用的是调用者参数/局部对象,不能作为注册 owner 的证据。

最小修正: 在 owner anchor 前应用 scope-aware 绑定过滤,至少覆盖 parameter、assignment、local import、局部定义及其他 Python binding forms;复用或提取 python_production.py 中现有的 shadow 规则,保留未解析原因。补参数和赋值负例,以及真正未遮蔽 import 的正例;不要复制第三套名字身份规则。

F3 — 相同 SHA + clean 状态可以描述不同 domain

位置:consumer_report.py:159

在一次性 Git fixture 中提交 consumer 和 loopx/semantics/vocabulary_v0.json,收集报告;只把 registry 的 values['alpha','beta'] 改为 ['changed'],重新读取 registry 并收集。两个结果的 source_sha 相同、worktree_dirty=False,但 row domain 已不同;Git 自身正确显示 registry 修改。collect_consumer_evidence() 读取的权威输入超过两个扫描根,现有 dirty 检查没有覆盖它们。

最小修正: 来源版本涵盖 registry、被读取的 owner/re-export 文件及报告实现的来源;可保守检查更大的 tracked 输入范围,或记录同一输入快照的 digest。无需逐行重复 SHA,但不能把工作树输入伪装成可由该 commit 重现。将 registry-only 修改及根目录外 owner 修改加入来源回归。

F4 — CLI 必须让维护者定位 unknown,并保留行的词表身份和值域

位置:consumer_report.py:608,核心过滤在 609 行。

输入同时包含直接 consumer 和 def unresolved(payload, key): return payload.get(key)。调用 render_consumer_evidence(evidence, top=1_000_000),输出有 dynamic_key=1,但完全没有 ::unresolved 的位置;domain 也不在明细中。实际 CLI fixture 得到同样结果。真实树的 602 个计算键未知位点因此都无法从唯一公开入口定位;最后“raise --top to print more”的提示无法补救。非 * 明细也没有 vocabulary 列,只能从汇总顺序猜归属。

最小修正: 文本按 vocabulary 分组或显式打印 vocabulary/domain,并提供含 * 位点的可遍历分页/导出方式;大结果可以限量,但必须说明截断并能取全。补 CLI 级测试,验证计算键位置、词表身份、domain 和增大 --top 后的读回,不能只断言汇总包含 unknown_share

F5 — 缺少字段绑定时不能把 vocabulary id 当作槽位身份

位置:consumer_report.py:165,以及 542、549–555 行。

这是实际 registry 的问题,而非假设所有动态路径都必须解析:26 个词表只有 effective_action 提供 literal_scan.fieldturn_result_kind 没有该配置,registry 的 return_paths 已明确列出 _task_validation_receiptrecovery_kind;真实 loopx/cli_commands/turn.py:453–454 读取并按 result_kind 选择 typed writeback。B5 对这处没有任何 turn_result_kind 行,连 missing-identity unknown 都没有。

使用真实 registry、names=['turn_result_kind'],依次收集 return receipt["result_kind"]return receipt["recovery_kind"]return receipt["turn_result_kind"] 的合成 SourceFile 输入(直接调用真实 collector,未修改 checkout),前两者为空,第三个却得到 slot_read/pass_through 和完整的 12 值 domain。第三种键并没有因名字恰好等于 id 就获得 producer/slot 身份。现有 production.collect_production() 在缺少 literal_scan.field 时保留 field=None,用已声明 return/call producer 关系建立证据,并不进行这个推断。

校准与最小修正: 已披露的同名误归、有限 alias、缺少数据流、computed-key 和 TS 未解析本身不列为新增缺陷。这里要求的是不虚构缺失的身份前提:没有显式 field/path 绑定时,仅保留真正绑定的 owner-member 证据,或把 ID-name 搜索明确作为未绑定候选、记录 missing-slot-identity;对已知 producer 路径复用其受限上下文,不能直接把最后一个字段名推广为全局槽位。补实际 registry 的 turn_result_kind/turn_route 用例,并验证不存在已绑定 slot 时不会生成无 blocker 的 slot-read 证据。无需新增全仓 consumer 登记。

语义与 CI 对齐

这里复用已有 consumer/interpreter/pass-through 词汇,没有新增 F3 合并义务,也未把未证明项改成已证明。问题是实现违反了既有角色定义和 B5 证据输出要求;词表“包含合法值”的检查不能替代 owner 身份或角色正确性。修复应落在现有诊断边界及聚焦测试,不应扩展 registry 强制消费者注册,也不应靠改名或提高预算消除反例。

验证记录与限制:

  • 在指定 head、对应 checkout 根目录运行 python -m pytest -p no:cacheprovider tests/architecture/test_semantic_consumer_report.py tests/architecture/test_semantic_inventory.py tests/architecture/test_semantic_python_production.py -q127 passed
  • python examples/docs-governance-smoke.py:通过。
  • 直接运行真实 collector:484 个 Python 源文件,tracked source 总数 1208;921 行,其中 read 3、interpret 141、pass-through 50、unknown 727。数量可重现,不代表角色正确性已通过
  • 在同一 Git fixture 上调用 baseline 7006b62a5 与 head 的真实 generator:默认 JSON 和 --report stdout 逐字相同;已有错误参数保持退出 2。启用 B5 保留旧报告前缀并增加消费者段,缺少 --report 的新参数退出 2。这里验证的是入口隔离;没有借此声称真实全仓排名运行成本或所有边界已经证明。
  • 远端复核 head 仍为上述 SHA;rollup 23 SUCCESS、4 SKIPPEDSign-offmerge-gate 成功;跳过的是 deploy、upload-release、publish-pypi、presentation,不记为通过。当前源码 CI 政策要求代码改动走完整核心验证;本地未重复远端全量 suite/premerge。
  • frontend/Lark/packaged UI 无需配套修改:调用者搜索只发现开发脚本及测试,没有产品设置或用户交互入口变化。报告无新权限、持久化或执行副作用。尚未在更新后的 main 集成树验证;发布前已再次核对远端 head;尚未把该分支与后续主干集成后复验。

我的整体评价

这是有合理问题背景、但尚未证明交付质量的诊断增量。不能因为默认关闭、测试全绿,便忽略它向维护者提供错误迁移线索;也不应因新文件大小或未知占比高而否定所有有界证据。

适合当前项目规模的方向是控制重复语义知识:保留一个来源读取/词法身份边界,用保守规则给出可追溯候选,把不能证明的角色降为 unknown。consumer_ranking 没有角色语义,单独保留 collector 有理由;而模块/scope 身份、AST 缓存和字段访问发现若在 producer、B3、B5 三处各自演进,会增加每次 Python 语法、alias、TS 支持变更的修复面。先修 F1–F5 并以真实调用点说明报告怎样减少一次调查工作,暂不扩展扫描根、跨函数分析或全 TypeScript consumer 分析。较大的公共扫描框架没有被当前需求证明。

成本上,新增角色扫描确实位于可选分支,但命令仍强制先完成原有排名,且模块 import、新源码解析和新增测试并非“整个 PR 成本绝对不变”。文档中的 3.35–3.65 秒及约 217 秒是作者历史测量,本轮没有重新计量;本轮不把作者的历史耗时作为独立实测结果。若使用频率值得保留,应让指定 vocabulary 的证据能够独立获取,避免每次调查都付全量排名成本,并衡量一次真实迁移调查的有效位点、误报和人工耗时,而不只记录扫描秒数或 test/PR/LOC 数。

M1 的工程投入应优先验证 effective_action 的实际 producer→字段路径→解释者及兼容读回;M3 则复用 B3,并选择有真实兼容证据的一个字段完成迁移。B5 的扩展不应成为这两条交付线的额外门槛。本轮 bounded-refactor pass 建议在相同原因内共享 scope identity 原语;不建议新建语义注册层或第二套通用静态分析产品。

再次评审所需证据是:五组反例修复、原有正例和真实 production 表达式回归通过、CLI 能读回完整证据来源与 unknown,以及修复后 exact head 的全 PR 判断。当前保持 REQUEST_CHANGES

English verdict: REQUEST_CHANGES for #4663 at 63fed66b9f6d458ba7b297221166bf70b795a508. The advisory report mislabels real interpreters as pass-throughs, treats shadowed names as registered owners, omits registry changes from source provenance, hides dynamic-key locations and row identity/domain in its CLI output, and invents slot identity from vocabulary IDs when no field binding exists. Focused validation passed 127 tests, docs governance, and paired baseline/head CLI checks; those results do not refute the reproduced counterexamples. Keep this a conservative investigation aid, reuse existing scope/identity logic, and prioritize concrete M1/M3 migration evidence over expanding another analyzer. No merge approval is given.

@huangruiteng

Copy link
Copy Markdown
Collaborator

Rebase guidance (mechanical conflict, no content disagreement):

Against current main, git merge-tree conflicts in the same two files — docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md and its .zh-CN.md mirror. I reproduced the merge locally; this PR has two conflicted regions in each mirror:

  1. the head of ## Appendix A: Execution ledger (~line 1127 EN) — both sides add a new ledger entry under the same anchor;
  2. the ## Appendix B decision table (~line 1424 EN) — both sides add a 2026-09-17 row at the same position.

Resolution for both: keep both sides and delete only the markers. Main contributes the B0 ledger entry and its 2026-09-17 table row; this PR contributes the B5 entry and its row. All four are append-only, non-normative, and no check reads their order. Mirror the same two edits in the zh file.

That is the only readiness blocker on 63fed66b9f6d458ba7b297221166bf70b795a508 (merge_state_requires_update); the review conclusion there is valid.

…dence-report

Both sides appended to the semantic RFC's Appendix A execution ledger and its
Appendix B decision log. Kept each side's entry in both mirrors; no content
from either side was dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: song <22676124+songoow@users.noreply.github.com>
B5 is optional in loopx-project#4447 and gated on concrete slot identity. The scan fell back
to the vocabulary id when a registry entry declared no `literal_scan.field`,
which analysed 25 of 26 vocabularies against a field name nobody had claimed
exists; every row downstream inherited that guess. The fallback is gone. A
vocabulary with no declared slot is reported by name under
`missing_slot_identity`, counted in the header, and not analysed at all --
coverage is now the 1 of 26 the registry actually declares, matching the
`literal_scan_fields:1/1` the drift smoke already reports.

Three further claims the evidence did not establish:

* A call argument was reported `pass_through`. `Kind(value)`, `int(value)` and
  `sink(value)` are the same syntax, and none of them show whether the value
  comes out unchanged; an f-string and a further attribute are conversions the
  same way, and `.value` was read as an enum unwrap on an object the scan had
  not established was an enum member. All of these now end the climb as
  `unknown` with the construct named. `pass_through` means the AST shows the
  value itself relocated -- returned, stored, placed in a structure -- with
  nothing applied to it. An observed branch still wins over an unresolved
  sibling use, because interpreting is the top of the rank.
* An owner-member comparison resolved against module-level imports only, so a
  parameter, a local assignment, a local import or an `except` target that had
  taken the owner's name over was still credited to the registered owner. Scope
  shadows now accumulate outwards-in, as `scan_python_production` already did,
  and a shadowed operand is `unknown`, not the owner it resembles.
* The printed row listing dropped every computed-key and unattributable site,
  so the table read as a complete census of the slot's readers. Both
  populations are printed now, in their own blocks under the same `--top`
  budget, since a single location-ordered list would bury 111 classified rows
  under 602 unattributed ones -- the same concealment spelled differently.

Measured on the merged tree: 713 rows over 485 of 1213 tracked sources -- 0
read, 61 interpret, 9 pass_through, 643 unknown, a 90.2% unknown share; of the
111 rows attributed to `effective_action`, 41 are unknown (36.9%). The guessing
implementation reported 921 rows at 78.9%. Smaller and less confident is the
direction the evidence supports.

The cross-vocabulary parse and scope caches go with the narrowing: they existed
so 26 vocabularies shared one parse, and one covered vocabulary does not need an
unbounded module-level cache keyed on a text hash. Removing them costs nothing
measurable (5.9-6.7s over three runs, unchanged).

Still advisory: reachable only via `--report --consumer-evidence`, still
refusing to run without `--report`, still not referenced by the drift smoke.

Refs loopx-project#4447

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: song <22676124+songoow@users.noreply.github.com>
@songoow
songoow force-pushed the codex/b5-consumer-evidence-report branch from b12e5d7 to 278c94b Compare September 18, 2026 01:59
huangruiteng
huangruiteng previously approved these changes Sep 18, 2026

@huangruiteng huangruiteng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

动机

B5 是 #4447 里的可选项,前提是"槽位身份具体可指"。它要回答的是:某个消费点对某个词表的槽位做了什么——read(认出读取且没有把值带下去)、interpret(在值上分支)、pass_through(AST 显示值被原样搬运)、unknown(说不清,并记录原因)。这个 head 把前一版"说得过满"的地方收回来。

三个被收回的主张:

  1. 没有声明槽位的词表不该被分析。 之前当注册项没有 literal_scan.field 时会退回用词表 id 当字段名,于是 26 个词表里 25 个是在"没人声明存在的字段名"上分析出来的,下游每一行都继承了这次猜测。现在没有回退:未声明槽位的词表按名字列在 missing_slot_identity 里、计数进表头、完全不分析——覆盖数变成注册表真正声明的 1/26,与 drift smoke 的 literal_scan_fields:1/1 一致。
  2. 调用实参不再算 pass_through Kind(value)int(value)sink(value) 语法相同,都无法说明值是否原样出来;f-string、再取一层属性同理;.value 曾被当作枚举解包,但扫描并未确立那个对象是枚举成员。这些都改为 unknown 并写明构造。pass_through 现在只表示"AST 显示值本身被搬运(返回、存储、放进结构),其上没有施加任何操作"。已观察到的分支仍优先于同点的未解析用法。
  3. owner 比较要认作用域。 之前只在模块级 import 里解析,于是参数、局部赋值、局部 import、except 目标一旦用了 owner 的名字,仍会被记到注册 owner 头上。现在按 scan_python_production 已有做法由内向外累积遮蔽,被遮蔽的操作数记为 unknown

另外,打印的清单以前把计算键与不可归属的点全部丢掉,读起来像"该槽位读者的完整普查";现在两类分别在同一个 --top 预算下各自成块打印(单一按位置排序会把 111 行已被分类的行埋在 602 行不可归属之下——同一种隐瞒换个写法)。

改动思路

  • 锚点仍是注册表已有的身份:literal_scan.field + 注册 owner 类,按 B2 同样的一跳未改名 import 纪律绑定;**没有"模块自我登记为消费者"**这一步,扫描范围由代码拥有,注册表数据无法拓宽它。
  • 主张被限定为语法使用,不是数据流:不证明该点看到的值来自注册生产者、不证明分支可达、尤其不证明"没有行就等于没有读者"。局部跟随只有一跳,两个别名之外就是 unknown;TypeScript 计数但不解析;范围是两个根而非整棵树。
  • 收窄后顺带删掉为"26 个词表共享一次解析"而存在的跨词表解析/作用域缓存——只覆盖一个词表不需要以文本哈希为键的无界模块级缓存,实测耗时不变。

具体改动

关键代码讲解

  • loopx/semantics/consumer_report.py 的槽位身份解析:无 literal_scan.field 即不分析并在表头具名;有声明时才按一跳 import 绑 owner。
  • 角色判定链:调用实参 / f-string / 再取属性 / .valuepass_through 改为带构造成因的 unknown;只有"值本身被搬运"才留 pass_through
  • owner 成员比较:遮蔽由内向外累积,被遮蔽的操作数不是注册 owner。
  • scripts/generate_semantic_inventory.py--consumer-evidence 段:打印覆盖数与被拒绝分析的词表、总体与"已归属"两个 unknown 占比、原因分解,并把不可归属行单独成块。

对主干的风险

无阻塞发现。我按 exact head 复现了报告输出,与作者声称逐项吻合:

covered=1 of 26 registered vocabularies (only those declaring literal_scan.field): effective_action
missing_slot_identity=25 not analysed, no declared slot to anchor a read: turn_result_kind, ...
rows=713  read=0  interpret=61  pass_through=9  unknown=643  unknown_share=90.2%
attributed_rows=111  unknown=41  attributed_unknown_share=36.9%
unknown_reasons: call_target_untraced=13, dynamic_key=602, mention_without_recognized_anchor=18, typescript_not_walked=9, unstable_local=1

并且不可归属(*)行确实单独成块打印。测试:test_semantic_consumer_report.py 44 passedsemantic-vocabulary-drift-smoke.py ok(各预算仍在上限);docs-governance-smoke ok。

一条 P3(与本 PR 代码无关):这个 head 的必需检查 kernel-static-checks超时被取消的——job 105453216681 跑 15m04s,正好撞上 timeout-minutes: 15,而它最后一个步骤在取消前已经打印 cli-output-budget-regression-smoke ok;聚合任务因此因"上游 cancelled"报红。#4680 也在 15m16s 撞了同一个上限,而近期通过的运行是 11m20s–14m22s——这是 CI 容量/上限问题,不是代码缺陷,但会让所有 PR 的必需检查变得不可靠,值得单独修(提高上限或把 CLI 输出 smoke 拆成独立 job)。

我的整体评价

这是"把不确定如实抬高"的正例:覆盖从 26 降到它真正能锚定的 1,行数从 921 降到 713、unknown 占比从 78.9% 升到 90.2%,而且升高的方向与三条被收回的主张一一对应。它没有变成门禁(追踪 issue 明确禁止),也明确声明"没有行 ≠ 没有读者"。可以接受。

English verdict: APPROVE - head 278c94b restricts the advisory B5 consumer-role report to the 1 of 26 vocabularies that declare a slot (naming the other 25 under missing_slot_identity), stops calling call arguments and applied conversions pass_through, accumulates scope shadows before crediting an owner, and prints the unattributed population in its own block; the report reproduced exactly as claimed (713 rows, 90.2% unknown, attributed 111/41 = 36.9%), with 44 new tests, unchanged drift-smoke budgets and docs-governance ok, and one non-blocking P3 that this head's required kernel-static-checks job was cancelled at its 15-minute timeout after its last step printed ok.

…dence-report

Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>

@huangruiteng huangruiteng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head: fc4e1dd066782d0e2b658af94e19ac4d3e46f1a2 (codex/b5-consumer-evidence-report, re-review after the branch was merged with main ed1464e62; the earlier approval no longer covers this head).

动机

RFC 自己写明 consumer_ranking() 统计的是"提到这个符号的模块",并且"既不分类角色、也不证明数据流"。B5 就是把这条只有数量的证据变成有角色、有位置、有来源 revision、有局限的按位点证据,并且把"未知"当作主要输出而不是残余。

改动思路

锚点全部取自注册表已经携带的声明:词表的槽位(literal_scan.field,缺失则该词表不被分析)与已注册 owner 类,配合 python_production.py 那套一跳不改名的 import 纪律。没有任何模块把自己注册为消费者,扫描范围 CONSUMER_SCAN_ROOTS 由代码持有,与 PRODUCER_ROOTS 同理,注册表数据无法放宽它。报告是可选段,并且 CLI 规定 --consumer-evidence 必须与 --report 同时出现——它是参考证据,不是检查。

本 head 相对于上次评审的唯一变化是与 main 的合并:冲突只有两处,均为两份 RFC 镜像末尾的「附录 A 执行账本」——两侧各自追加一条带日期的条目。该账本按设计只追加,因此解法是两条并存、正文不动。上一轮同类解析的结论一致。

具体改动

5 个文件、+1223/-0(相对当前 main):

  • loopx/semantics/consumer_report.py(+673):consumer_ranking 之外的第二条消费者侧测量——按位点分类 read / interpret / pass_through / unknown,每行带 vocabularysitelineroleanchordomainobservedlimitationblocker
  • scripts/generate_semantic_inventory.py(+20):在既有报告表面加 --consumer-evidence,缺少 --report 时直接 parser error。
  • tests/architecture/test_semantic_consumer_report.py(+391):钉住语法分类、角色、limitation 名称与报告形状。
  • 两份 RFC 镜像(+80/+59):新增 B5 附录条目与证据行 E26,写明覆盖 1/26、未知占比、两跳别名、TypeScript 未解析与两根目录的范围限制。

关键代码讲解

  • loopx/semantics/consumer_report.py:87CONSUMER_SCAN_ROOTS_ROLES 一起把"扫哪里"和"分几类"钉在代码里;注册表只能通过声明槽位影响覆盖,不能扩大范围。
  • loopx/semantics/consumer_report.py:489scan_python_consumers / collect_consumer_evidence 是主语,它在两个根目录内解析每个源文件,先解析锚点(槽位、owner 类、成员、动态键),再给出角色。
  • loopx/semantics/consumer_report.py:525scan_dynamic_reads 把"用计算出来的键读映射"记为 dynamic_key,并以 * 行归到"对所有被覆盖词表同时未解析"。这正是"零读者"不能被读成"已死"的原因,也是这份报告最重要的诚实之处。
  • loopx/semantics/consumer_report.py:606render_consumer_evidence 先打印 reach、覆盖的词表数、missing_slot_identity 列表、角色总数、未知占比与未知原因,再打印有界的明细表。报告把自己不自信的部分放在最前面。
  • scripts/generate_semantic_inventory.py:81:把"这是参考证据而非检查"写成 CLI 约束,而不是文档里的君子协定。

我复核的关键点(都在这个 head 上自己跑过):

  • python3 scripts/generate_semantic_inventory.py --report --consumer-evidenceexit 0,打印 reach=486 scanned Python sources of 1215 trackedcovered=1 of 26 registered vocabulariesrows=714 read=0 interpret=61 pass_through=9 unknown=644 unknown_share=90.2%attributed_rows=111 unknown=41 attributed_unknown_share=36.9%,以及 dynamic_key=603 / mention_without_recognized_anchor=18 / call_target_untraced=13 / typescript_not_walked=9 / unstable_local=1
  • 我直接调用模块验证:所有行都落在两个代码持有的根目录内(无一行越界),source_sha 与 dirty 标记如实记录;--consumer-evidence 单独使用时报 parser error。
  • pytest -q tests/architecture454 passedsemantic-vocabulary-drift-smoke ok(各预算仍在原上限);docs-governance-smoke ok;loopx canary premerge --from-git-diff status passed、self_merge_allowed: truemanual_holds: 0
  • 与合并前 head(278c94b6)对比:713 行 → 714 行,角色分布完全一致,差异只来自 main 带来的树增长;因此本 head 的合并没有改变这套测量的语义。

遗留问题(非阻塞,P3)

PR 正文里的表用的是已删除实现的数据。 正文标题表写的是 921 行、78.9% 未知、read 3 / interpret 141 / pass_through 50,那是本分支先实现、后删除的「回退到词表 id」版本;在当前 head 上同一条命令报的是 714 行、90.2% 未知、read 0代码与文档是对的:RFC 附录与证据行 E26 已经改成 713 行 / 90.2%,并明确把 921/78.9% 标注为"被删除的回退实现当时报出的数字"。所以这只是一处正文口径过期,不阻塞合并;建议顺手把正文那张表替换或标注为对比值。

对主干的风险

最大风险不是崩溃,而是被误读成普查:有人拿"某词表没有行"当成"没有读者"去退休一个值。这一点在实现里是被正面处理的——未知占比 90.2% 是报告的第一行结论,* 行明确不参与单词表计数,25 个未声明槽位的词表列为 missing_slot_identity 而不是被猜测分析。其次的风险是成本:这次没有动 drift smoke 的载入路径,PR 路径不付这份代价,--consumer-evidence 只在人工调用时运行。回退成本一个 commit 组;本 head 相对上次评审只多了一个 main 合并及其两处纯追加的账本解析。

我的整体评价

结论 APPROVE。这是一份"承认自己不知道多少"的证据:角色来自注册表已有的锚点,范围由代码持有,未知必须带位置与原因,且它自己明确不是闸门。覆盖只有 1/26 这件事被写进了 RFC 而不是藏起来,作者也说明了价值要等第一次 M1/M3 迁移。唯一的瑕疵是正文数字过期(P3,非阻塞)。合并解析是纯追加。

English verdict: APPROVE - exact head fc4e1dd (re-review after the branch merged main ed1464e; the only conflicts were the append-only execution ledger in both RFC mirrors, resolved by keeping both dated entries, and the merge moves the measurement by one row without changing its distribution). B5 adds an advisory per-site consumer classification: anchors come from registry declarations (the vocabulary's literal_scan.field slot and its registered owner class), the scan reach is code-owned, every unclassified site becomes an unknown row with a named reason, and the CLI refuses the flag without --report so the evidence can never be mistaken for a check. I measured it at this head: reach 486 of 1215 tracked sources, coverage 1 of 26 vocabularies, 714 rows with 0 read / 61 interpret / 9 pass_through / 644 unknown (90.2%), 111 rows attributed to effective_action with 41 unknown, and reasons dynamic_key=603, mention_without_recognized_anchor=18, call_target_untraced=13, typescript_not_walked=9, unstable_local=1; all rows stay inside the two code-owned roots. tests/architecture is 454 passed, the drift smoke and docs-governance smoke are unchanged and ok, and canary premerge reports passed with self_merge_allowed true and manual_holds 0. One non-blocking P3: the PR body's headline table still shows the removed vocabulary-id fallback's 921 rows and 78.9% share, while the shipped measurement is 714 rows and 90.2% - the RFC entry and evidence row E26 carry the corrected numbers, so only the body text is stale.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants