Skip to content

Commit aa5523a

Browse files
tzhouamclaude
andcommitted
kb init: judge coverage by the most specific owner; never absorb a module with an ancestor prefix
The afd-plugin pilot (dry run) made the coverage metric meaningless. The skeleton wrote an aggregate owner "components" listing its components' prefixes; the modules stage absorbed the package root by appending afd_plugin/ to it, a catch-all that matches every file; deepen gave the hottest module to that owner (it reached as many files as the component owner and came first in routing order) and wrote 12 rules to components/rules.md; and pr_weighted_coverage counted a file rule-bearing when ANY matching owner had rules, so coverage jumped 10% -> 100% and deepen stopped with 8 hot modules untouched. - init_coverage.most_specific: the owners with the longest matching prefix, then the deepest page (a component's own page beats an aggregate entry page listing the same area); rule-bearing now uses only those. shadowing() lists directory prefixes that are strict ancestors of another owner's prefix. - init_modules: absorption picks the owner that owns most of the module's files most specifically, and cover_prefixes adds only prefixes for the module's own unrouted files: the module directory when no other module or OTHER owner's prefix lies under it, else the highest such directory per file, else the file. A module needing more than 20 prefixes gets a map card instead; card owners use the same cover. - init_deepen: a module's owner is its most specific owner; "already bears rules" and the uncovered-hot list use the same rule. - skeleton: a checklist note names every owner prefix that shadows other owners' prefixes, so the owner can narrow it. Replayed offline on the pilot's pin: the root absorbs 4 files instead of afd_plugin/, the hottest module goes to execution-platforms, and rules on the aggregate page now count for 4.5%, not 100%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 81b8a48 commit aa5523a

6 files changed

Lines changed: 321 additions & 34 deletions

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -329,10 +329,15 @@ knowledge_root)` 是运行时检查:种子必须是知识树中已存在的文
329329
`scope_prefixes` 前缀匹配、保持路由顺序;`module_coverage(modules, owners)` —— 模块内**每个**源文件都被某个 owner 前缀
330330
覆盖才算覆盖;`make_include(source_roots, exclude, suffixes=())` 构造变更路径过滤器(测试/文档等一律由调用方的
331331
`exclude` glob 表达,不内置任何仓库字面量);`pr_weighted_coverage(prs, owners, include=, rule_pages=)` —— 每个 PR
332-
改动的每个文件计一次,`routed` = 被某 owner 覆盖,`rule_bearing` = 覆盖它的 owner 页在 `rule_pages`(含有效规则的页)
333-
中,`uncovered_hot` 按次数降序、路径升序;`churn_by_module(prs, modules)` 按最长模块前缀归属(已折叠目录归到折入的
332+
改动的每个文件计一次,`routed` = 被某 owner 覆盖,`rule_bearing` = 它的**最具体** owner(`most_specific`)的页在
333+
`rule_pages`(含有效规则的页)中,`uncovered_hot` 按次数降序、路径升序;`churn_by_module(prs, modules)` 按最长模块前缀归属(已折叠目录归到折入的
334334
祖先),降序。空集合的比例记为 1.0(没有可漏的路由)。`ROOT_MODULE` 与 `profiles.establish.ROOT_MODULE` 相同,
335335
由测试钉住(本包不依赖 `profiles`)。源根与变更路径按与 `profiles.establish.normalize_root` 相同的规则规范化(`.`、`./` 表示仓库根,接纳一切路径)。测试:`test_kb_init_coverage.py`。
336+
`most_specific(path, owners)`:匹配前缀最长的 owner;前缀一样长时取页面在知识树里最深的(组件自己的页,而不是列出同一
337+
区域的聚合入口页);两者都相同才并列返回。`shadowing(owners)`:列出每个是其他 owner 前缀的**严格祖先**的目录前缀
338+
(文件前缀不算祖先)。为什么:前缀会嵌套,兜底前缀(`pkg/`)能到达组件前缀(`pkg/sub/`)到达的每个文件;若任一匹配 owner
339+
的规则都算,兜底页上的一条规则就让整个包都“承载规则”,指标失去意义(kb init 试点 2026-09-30:一个模块的规则让承载率
340+
10% → 100%)。测试:`test_kb_init_owner_specificity.py`。
336341

337342

338343
## 2026-09-30 单次调用花费阈值(kb init 预算硬上限的一块积木)
@@ -377,7 +382,8 @@ x 必须为正。因阈值停下的调用(`stop_reason="max_budget"`)→ `Mo
377382
仓库未登记时登记到 `repos/_index.md`(`INIT_PATHS` 允许这一个共享文件);`_routes.yaml` 只在不存在时生成:先由
378383
已有子目录入口页**确定性**地得出 owner(页中反引号里、在钉点存在、落在 `source_roots` 内的代码路径;`pkg/**` 即目录
379384
`pkg/`),再补模型提出的 owner(scope 前缀须在钉点存在)→ 已有页面的问题进清单(钉点下不成立的声明、与文档重复的行、
380-
没有路由到达的页、表格索引的样式)→ 检查 → dry run 或 PR。钉点缺省取上游镜像的 `HEAD`(远端默认分支,不假设
385+
没有路由到达的页、表格索引的样式;某 owner 的目录前缀是其他 owner 前缀的祖先时提示收窄——这些文件只算给更具体的
386+
owner)→ 检查 → dry run 或 PR。钉点缺省取上游镜像的 `HEAD`(远端默认分支,不假设
381387
`main`;每次同步都按 `ls-remote --symref` 刷新镜像的 `HEAD`,默认分支改名或删除后也跟得上)。没有可用 owner 时的
382388
兜底路由用规范化后的 `source_roots`(`./pkg/` → `pkg/`),源根是仓库本身或未设置时用钉点下的全部顶层目录。发布时 `KB_INIT_GIT_AUTHOR` 缺失在任何模型调用之前拒绝。推送之前先把这次发布(基点、分支、文件、标题、
383389
正文、作者、时间)写成 `<stage>-publish.json`(`save_prepared`),记录状态为 `publishing`;推送/开 PR 失败记为
@@ -400,8 +406,12 @@ x 必须为正。因阈值停下的调用(`stop_reason="max_budget"`)→ `Mo
400406
仓库基点读取(`adapters/<adapter 目录名>/manifest.yaml`);`repo.language` 决定模块扫描的语言。
401407
- `init_modules`(阶段 2):在钉点树上 `scan_modules_at_depth`(`source_roots`、`module_depth`、`min_module_loc`、
402408
`exclude`;adapter 未声明语言或语言未知 → 不扫描,进清单)。按当前 `_routes.yaml` 算模块覆盖率。未覆盖的模块由深到浅:
403-
已被某个 owner 部分覆盖的,**吸收**进覆盖其文件最多的 owner(只在该 owner 的 `scope_prefixes` 末尾追加模块目录;仓库
404-
根模块因前缀不能表达,逐文件追加);完全无人覆盖的,每个模块一次有界的生成调用(文件名、符号签名、文件开头的 docstring
409+
已被某个 owner 部分覆盖的,**吸收**进以**最具体**方式覆盖其文件最多的 owner(路由顺序打平):只在该 owner 的
410+
`scope_prefixes` 末尾追加覆盖本模块**尚无路由的自有文件**的前缀(`cover_prefixes`)——安全时用模块目录,否则每个文件取
411+
模块与它之间最高的安全目录,再不行用文件本身;目录**安全**指其下每个已扫描文件都属于本模块、其下没有别的模块、也没有
412+
**其他** owner 的前缀,所以新前缀永远不会是吞掉别的模块或 owner 的祖先(有子模块的模块,如包根目录或仓库根,从不以自身
413+
目录路由)。需要超过 `MAX_ABSORB_PREFIXES`(20)个前缀的模块不吸收,改写地图卡片;卡片的新 owner 前缀同样由
414+
`cover_prefixes` 得出;完全无人覆盖的,每个模块一次有界的生成调用(文件名、符号签名、文件开头的 docstring
405415
或注释块,字节上限;从不给函数体:签名正则匹配整行,`declaration` 截掉函数体——
406416
花括号语言(JavaScript、Go、Rust)在整行**最早**的 `{`/`=>`/`;`/单独的 `=` 处截断,不去词法分析字符串、注释或正则字面量
407417
(函数体不可能出现在它们之前,默认值可能被截短,这是失败即关闭的取舍);Python 在括号、字符串与行尾 `#` 注释之外的
@@ -420,14 +430,15 @@ x 必须为正。因阈值停下的调用(`stop_reason="max_budget"`)→ `Mo
420430
引号转义);合并/squash 式默认分支上每个 first-parent 提交就是一个合入的 PR。这就是设计 §7.3 的 PR 窗口,从镜像离线
421431
读取(不走 GitHub API,没有速率限制)。
422432
- `init_deepen`(阶段 3):按窗口内源文件改动数(`make_include`:源根、`exclude`、语言后缀)给模块排序,从高到低:没有路由
423-
的模块跳过(记入 notes);路由 owner 已承载规则的模块跳过(对指标无增益)——owner **承载规则**指 owner 页本身有生效规则,
433+
的模块跳过(记入 notes);模块的 owner 是以**最具体**方式(`most_specific`)拥有其文件最多的 owner——同时也覆盖它的更宽
434+
owner 从不拿走组件的规则;该 owner 已承载规则的模块跳过(对指标无增益)——owner **承载规则**指 owner 页本身有生效规则,
424435
或(仅当 owner 是入口页/prose 页时)**同一目录**下有含生效规则的页(不看子目录,否则任何组件有了规则仓库入口就算承载;
425436
owner 本身是规则页却没有生效规则时不承载,不看兄弟页)。其余模块每个
426437
一次生成调用:模块代码带行号、字节上限(这是深度阶段,读代码本身),产出带行范围证据的规则 → 证据必须在本模块文件内 →
427438
共用筛查(D5、钉点证据与声明、落位、咨询性判定)→ 经 `ops.apply_operations` 追加到 owner 的规则页(owner 页本身是规则页
428439
时用它,否则用旁边的 `rules.md`;已有规则只追加、不修改,满页转兄弟页)。每写完一个模块重算承载规则的 PR 加权覆盖率,
429440
达到 `coverage_target`、预算用完或热模块用尽即停。报告:路由/承载规则两种覆盖率的 before/after、窗口 PR 数、仍无规则的热路径(`uncovered_hot`:没有承载规则的 owner 到达的
430-
源文件,包括已路由但 owner 无规则的;`unrouted_hot` 另列完全无路由的)。
441+
源文件(按最具体 owner 判断),包括已路由但 owner 无规则的;`unrouted_hot` 另列完全无路由的)。
431442
- 同一 PR 打开 shadow:`flip_to_shadow` 对 adapter manifest 做**文本**编辑,只改 `knowledge_lifecycle` 块里同缩进的
432443
`enabled`(→ `true`,缺失则插在块头下)与 `mode`(→ `shadow`)两行,注释、顺序与其他行逐字保留;没有该块 → 在任何模型调用
433444
之前 `blocked`。`_check_flip` 校验:只能改这一个 manifest,`check_flip_to_shadow`(除这两个键外解析值不变、head 开启且为

‎src/infermatrix_copilot/kb_service/init_coverage.py‎

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88
``scope_prefixes`` in ``_routes.yaml``;
99
* **PR-weighted coverage** — over a window of merged PRs, the share of changed
1010
source files (counted once per PR that changed them) that route to an owner
11-
(*routed*), and to an owner whose page carries rules (*rule-bearing*).
11+
(*routed*), and whose MOST SPECIFIC owner's page carries rules
12+
(*rule-bearing*; see ``most_specific``).
13+
14+
Specificity matters because prefixes nest: a catch-all owner (``pkg/``) reaches
15+
every file a component owner (``pkg/sub/``) reaches. If any matching owner's
16+
rules counted, one rule on the catch-all page would make the whole package
17+
rule-bearing and the metric would stop measuring anything (kb init pilot,
18+
2026-09-30: 10% -> 100% from one module's rules).
1219
1320
An empty population counts as fully covered (ratio 1.0): there is nothing a
1421
route could miss.
@@ -80,6 +87,49 @@ def routes_file(path: str, owners: Sequence[Owner]) -> list[Owner]:
8087
return [o for o in owners if any(path.startswith(p) for p in o.prefixes)]
8188

8289

90+
def _page_depth(owner: Owner) -> int:
91+
return len(PurePosixPath(owner.path).parts)
92+
93+
94+
def most_specific(path: str, owners: Sequence[Owner]) -> list[Owner]:
95+
"""The owners that own ``path`` most specifically, in routing order: those
96+
whose longest matching prefix is the longest of all matches; among those,
97+
the ones whose page lies deepest in the knowledge tree (a component's own
98+
page, not an aggregate entry page that lists the same area). Ties on both
99+
return every tied owner; no match returns []."""
100+
best: tuple[int, int] | None = None
101+
out: list[Owner] = []
102+
for owner in owners:
103+
length = max((len(p) for p in owner.prefixes if path.startswith(p)), default=-1)
104+
if length < 0:
105+
continue
106+
key = (length, _page_depth(owner))
107+
if best is None or key > best:
108+
best, out = key, [owner]
109+
elif key == best:
110+
out.append(owner)
111+
return out
112+
113+
114+
def shadowing(owners: Sequence[Owner]) -> list[tuple[str, str, str, str]]:
115+
"""``(owner, prefix, other owner, other prefix)`` for every prefix that is a
116+
strict ancestor of another owner's prefix: the broader owner still routes
117+
those files, but only the more specific one counts for them (and gets
118+
their rules)."""
119+
out = []
120+
for owner in owners:
121+
for prefix in owner.prefixes:
122+
if not prefix.endswith("/"):
123+
continue # a file prefix names one file, never an ancestor
124+
for other in owners:
125+
if other is owner:
126+
continue
127+
for inner in other.prefixes:
128+
if inner != prefix and inner.startswith(prefix):
129+
out.append((owner.owner, prefix, other.owner, inner))
130+
return out
131+
132+
83133
def module_coverage(modules: Mapping[str, Mapping], owners: Sequence[Owner]) -> Coverage:
84134
covered, uncovered = [], []
85135
for key in sorted(modules):
@@ -121,9 +171,12 @@ def include(path: str) -> bool:
121171
def pr_weighted_coverage(prs: Sequence[Sequence[str]], owners: Sequence[Owner], *,
122172
include: Callable[[str], bool],
123173
rule_pages: set[str] | None = None) -> PrCoverage:
124-
"""Each file a PR changes counts once for that PR. ``rule_pages`` are the
125-
knowledge paths (as ``_routes.yaml`` names them) holding at least one
126-
active rule; without them nothing is rule-bearing."""
174+
"""Each file a PR changes counts once for that PR. A file is *routed* when
175+
any owner reaches it, and *rule-bearing* when one of its ``most_specific``
176+
owners has its path in ``rule_pages`` (the knowledge paths, as
177+
``_routes.yaml`` names them, holding at least one active rule; without
178+
them nothing is rule-bearing). A broader owner's rules never count for a
179+
file a more specific owner reaches."""
127180
pages = rule_pages or set()
128181
total = routed = rule_bearing = 0
129182
missed: Counter[str] = Counter()
@@ -135,7 +188,7 @@ def pr_weighted_coverage(prs: Sequence[Sequence[str]], owners: Sequence[Owner],
135188
hits = routes_file(path, owners)
136189
if hits:
137190
routed += 1
138-
if any(o.path in pages for o in hits):
191+
if any(o.path in pages for o in most_specific(path, hits)):
139192
rule_bearing += 1
140193
else:
141194
missed[path] += 1

‎src/infermatrix_copilot/kb_service/init_deepen.py‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
Depth pass. The PR window is the pin's own first-parent history (bounded by
55
``init.pr_window_count`` / ``pr_window_max_age_days``, measured from the
66
pin's commit time). Modules are taken by churn in that window, highest first;
7-
a module whose route owner already bears rules is skipped (it adds nothing to
8-
the metric). For each other module one generator call reads its code at the
7+
a module's owner is the one that owns most of its files MOST SPECIFICALLY
8+
(``init_coverage.most_specific``: a catch-all prefix never wins over a
9+
component's), and a module whose owner already bears rules is skipped (it adds
10+
nothing to the metric). For each other module one generator call reads its code at the
911
pin (byte-bounded, with line numbers) and proposes rules with line-ranged
1012
evidence; the rules go through the shared screening — the docs redundancy
1113
filter (D5), evidence inside the module, pinned claims, placement, the
@@ -32,7 +34,7 @@
3234

3335
from ..knowledge_service.lifecycle import LifecycleError, Page
3436
from .init_budget import BudgetExhausted
35-
from .init_coverage import Owner, load_owners, make_include, pr_weighted_coverage, routes_file
37+
from .init_coverage import Owner, load_owners, make_include, most_specific, pr_weighted_coverage
3638
from .init_modules import scan_modules
3739
from .init_stages import ROUTES_NAME, _Candidate, _fence, _numbered, _one_line, _Stage, _title_of
3840
from .init_support import InitError, InitRecord, generate
@@ -174,20 +176,25 @@ def _churn(prs, modules, include) -> list[tuple[str, int]]:
174176
return [(k, n) for k, n in churn_by_module(filtered, modules) if n > 0]
175177

176178
def _without_rules(self, prs, owners: list[Owner], include) -> list[tuple[str, int]]:
177-
"""Changed source files of the window no rule-bearing owner reaches
178-
(unrouted, or routed to owners without rules), by PR count."""
179+
"""Changed source files of the window whose most specific owners bear
180+
no rules (unrouted, or routed to owners without rules), by PR count."""
179181
bearing = self._rule_pages(owners)
180182
missed: Counter[str] = Counter()
181183
for files in prs:
182184
for path in sorted({PurePosixPath(f.strip().strip("/")).as_posix() for f in files}):
183-
if include(path) and not any(o.path in bearing for o in routes_file(path, owners)):
185+
if include(path) and not any(o.path in bearing for o in most_specific(path, owners)):
184186
missed[path] += 1
185187
return sorted(missed.items(), key=lambda item: (-item[1], item[0]))
186188

187189
@staticmethod
188190
def _owner(files: list[str], owners: list[Owner]) -> Owner | None:
189-
"""The owner reaching most of a module's files (routing order breaks ties)."""
190-
counts = {o.owner: sum(1 for f in files if o in routes_file(f, owners)) for o in owners}
191+
"""The owner that owns most of a module's files most specifically
192+
(routing order breaks ties): a broader owner that merely also reaches
193+
them never takes a component's rules."""
194+
counts = {o.owner: 0 for o in owners}
195+
for path in files:
196+
for owner in most_specific(path, owners):
197+
counts[owner.owner] += 1
191198
return max((o for o in owners if counts[o.owner]), key=lambda o: counts[o.owner], default=None)
192199

193200
# -- rule pages ------------------------------------------------------------------

0 commit comments

Comments
 (0)