Skip to content

Commit 7bd7979

Browse files
tzhouamclaude
andcommitted
Only conflicts involving the changed rules fail the consistency check
The consistency judge reviews a whole owner directory and so reports every conflict in it. The gate failed the change for any of them, including conflicts between rules the change never touched. Refining cannot fix those, so every change in such a directory was blocked for good and spent two paid refine rounds on it. The first live run hit exactly this. On main (48e8b4a), CONF-1a..4a are duplicated across two configuration pages, and DIFF-2s/2t and SERV-4o/4r5 already conflict. Both intake batches ended refine_exhausted. - check_consistency(changed=...) now takes the rule IDs the change adds, edits, retires or supersedes. - A conflict naming one of them is the change's. - A conflict between untouched rules is kept as "preexisting" and does not fail the change; the directory then counts as consistent. - The changed IDs include the ### rules nested inside a changed rule. - A conflict that names no rule, has null or non-string IDs, or is malformed still counts against the change (fail closed). - The judge's prompt now carries changed_rule_ids. - Calibration passes no `changed`, so it keeps the strict whole-directory verdict. Tests: a conflict already on main passes; one involving the new rule fails; an unnamed or null-ID conflict fails; a conflict naming a nested rule of the new rule fails; the judge is told the changed IDs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 48e8b4a commit 7bd7979

3 files changed

Lines changed: 91 additions & 4 deletions

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,3 +275,10 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝
275275
(幂等 upsert)记入退役账本,下个发版的巡检据此 purge。
276276
- `kb status` 新增 `unknown_commits`:未处置的未知提交及其接受请求的状态。
277277
- 测试:`test_kb_accept_unknown.py`。
278+
279+
## 2026-09-29 一致性冲突只算本次改动的
280+
一致性评审看整个 owner 目录,但只有**涉及本次改动的规则**(新增、编辑、退役、被替代的规则 ID)的冲突才使变更失败;
281+
两条都未被改动的规则之间的冲突已经在 main 上,记入该目录的 `preexisting`,不计入变更(精炼也无法修复它,否则该目录
282+
的任何变更都会被永久挡住并白白消耗精炼轮次)。没有写出规则 ID 的冲突仍算本次改动的(失败即关闭)。评审提示中带
283+
`changed_rule_ids`。校准不传 `changed`,保持整目录严格判定。首个线上运行即遇到此情况:configuration 目录中 CONF-1a..4a 在
284+
两页重复、DIFF-2s/2t、SERV-4o/4r5 在 main 上已冲突,导致两批 intake 全部 `refine_exhausted`。

‎src/infermatrix_copilot/kb_service/gate.py‎

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,12 @@ def validate(data: dict) -> None:
169169

170170

171171
def check_consistency(head: dict[str, str], owner_dirs: list[str], *, gateway: ModelGateway,
172-
judge: ModelRole) -> list[dict]:
172+
judge: ModelRole, changed: set[str] | None = None) -> list[dict]:
173+
"""``changed``: the rule IDs the change adds, edits, retires or supersedes.
174+
Only a conflict naming one of them is the change's; a conflict between
175+
rules it does not touch was already on main, is kept as ``preexisting``,
176+
and never fails the change (refining could not fix it). A conflict that
177+
names no rule counts against the change. None: every conflict counts."""
173178
from ..knowledge_service.l1 import _sha as sha256_text
174179

175180
results = []
@@ -187,7 +192,8 @@ def check_consistency(head: dict[str, str], owner_dirs: list[str], *, gateway: M
187192
rules.append({"page": path, "rule_id": section.rule_id,
188193
"supersedes": footer.supersedes,
189194
"text": section.body_without_footer})
190-
prompt = "<untrusted_data>\n" + json.dumps({"directory": directory, "rules": rules},
195+
prompt = "<untrusted_data>\n" + json.dumps({"directory": directory, "rules": rules,
196+
**({"changed_rule_ids": sorted(changed)} if changed is not None else {})},
191197
ensure_ascii=False, indent=1).replace("<", "\\u003c") \
192198
+ "\n</untrusted_data>\n"
193199

@@ -202,13 +208,47 @@ def validate(data: dict) -> None:
202208
verdict, conflicts = reply.data["verdict"], reply.data.get("conflicts") or []
203209
except ModelUnavailable as exc:
204210
verdict, conflicts = "unsure", [["", "", str(exc)]]
211+
preexisting: list = []
212+
if verdict == "conflict" and changed is not None and conflicts:
213+
ours = [c for c in conflicts if _names_changed(c, changed)]
214+
preexisting = [c for c in conflicts if not _names_changed(c, changed)]
215+
conflicts = ours
216+
if not ours:
217+
verdict = "consistent"
205218
results.append({
206-
"owner_dir": directory, "verdict": verdict, "conflicts": conflicts,
219+
"owner_dir": directory, "verdict": verdict, "conflicts": conflicts, "preexisting": preexisting,
207220
"pages": {p: sha256_text(t) for p, t in pages.items()},
208221
})
209222
return results
210223

211224

225+
def _names_changed(conflict, changed: set[str]) -> bool:
226+
"""Whether a reported conflict involves a changed rule. Anything malformed,
227+
or naming no rule (empty, null, not a string), counts as the change's."""
228+
if not isinstance(conflict, (list, tuple)) or len(conflict) < 2:
229+
return True
230+
ids = conflict[:2]
231+
if not all(isinstance(i, str) and i.strip() for i in ids):
232+
return True
233+
return any(i.strip() in changed for i in ids)
234+
235+
236+
def _changed_rule_ids(blocks, base: dict[str, str], head: dict[str, str]) -> set[str]:
237+
"""The rule IDs of every changed rule block, with the ``###`` rules nested
238+
inside them (on either side of the change)."""
239+
out: set[str] = set()
240+
for block in blocks:
241+
if block.kind != "rule":
242+
continue
243+
out.add(block.rule_id)
244+
for files in (base, head):
245+
try:
246+
out.update(Page.parse(files[block.path]).rule(block.rule_id).nested_rule_ids)
247+
except (KeyError, LifecycleError):
248+
continue
249+
return out
250+
251+
212252
def run_gate(*, base: dict[str, str], head: dict[str, str], changes: list[Change],
213253
external_texts: dict[str, str], evidence: list[dict], gateway: ModelGateway,
214254
judge: ModelRole, release: str, repo_dir: str,
@@ -255,7 +295,8 @@ def run_gate(*, base: dict[str, str], head: dict[str, str], changes: list[Change
255295
for b in blocks if b.verdict == "fail"], l1, blocks,
256296
upstream=upstream, facts=signed_facts)
257297
owner_dirs = sorted({_owner_dir(b.path) for b in l1.blocks})
258-
consistency = check_consistency(head, owner_dirs, gateway=gateway, judge=judge)
298+
consistency = check_consistency(head, owner_dirs, gateway=gateway, judge=judge,
299+
changed=_changed_rule_ids(l1.blocks, base, head))
259300
if any(item["verdict"] == "conflict" for item in consistency):
260301
return GateDecision("fail", reasons + ["change set is inconsistent"], l1, blocks, consistency,
261302
upstream, signed_facts)

‎test/test_kb_intake_gate.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,3 +559,42 @@ def answer(role, prompt):
559559
assert run_intake(rt, lifecycle) is None
560560
drafted = rt.ledger.events("demo", "drafted")
561561
assert len(drafted) == 1 and drafted[0]["detail"] == "demo-intake-usurped"
562+
563+
564+
def _conflicting(pairs, seen=None):
565+
def answer(role, prompt):
566+
if '"directory"' in prompt:
567+
if seen is not None:
568+
seen.append(prompt)
569+
return {"verdict": "conflict", "conflicts": [[a, b, "they disagree"] for a, b in pairs]}
570+
return _judge_all("yes")(role, prompt)
571+
return answer
572+
573+
574+
ADD = [Op("add", PAGE, "DEMO-2a", _rule("DEMO-2a", "PR #11"))]
575+
576+
577+
def test_a_conflict_already_on_main_does_not_fail_the_change():
578+
decision = _gate(ADD, _conflicting([("DEMO-1a", "OLD-9z")])) # neither rule is the change's
579+
assert decision.status == "pass", decision.reasons
580+
(item,) = decision.consistency
581+
assert item["verdict"] == "consistent" and item["conflicts"] == []
582+
assert item["preexisting"] == [["DEMO-1a", "OLD-9z", "they disagree"]]
583+
decision = _gate(ADD, _conflicting([("DEMO-1a", "OLD-9z"), ("DEMO-2a", "DEMO-1a")]))
584+
assert decision.status == "fail" # the new rule is in it: ours
585+
assert decision.consistency[0]["conflicts"] == [["DEMO-2a", "DEMO-1a", "they disagree"]]
586+
assert _gate(ADD, _conflicting([("", "")])).status == "fail" # names no rule: counted against it
587+
assert _gate(ADD, _conflicting([(None, None)])).status == "fail" # null IDs: likewise
588+
589+
590+
def test_a_rule_nested_in_a_changed_rule_is_part_of_the_change():
591+
text = _rule("DEMO-2a", "PR #11") + "\n### DEMO-2a1 — a nested case\n\n- 强制:嵌套规则。 ^[PR #11]\n"
592+
nested = [Op("add", PAGE, "DEMO-2a", text)]
593+
assert _gate(nested, _conflicting([("DEMO-2a1", "DEMO-1a")])).status == "fail"
594+
595+
596+
def test_the_consistency_judge_is_told_which_rules_changed():
597+
seen: list[str] = []
598+
_gate(ADD, _conflicting([], seen))
599+
data = json.loads(seen[0].split("<untrusted_data>\n", 1)[1].rsplit("\n</untrusted_data>", 1)[0])
600+
assert data["changed_rule_ids"] == ["DEMO-2a"]

0 commit comments

Comments
 (0)