Skip to content

Commit 0143de8

Browse files
committed
Fix post-reduction review actionability and final summary consistency
1 parent 8a07586 commit 0143de8

5 files changed

Lines changed: 145 additions & 16 deletions

File tree

‎src/infermatrix_copilot/engine/agent_runtime/ensemble.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,15 @@ def _ok(obj) -> bool:
603603
kept[i]["severity"] = str(v["severity"])
604604
merged["summary"] = str(reduced.get("summary")
605605
or merged.get("summary") or "")
606+
if merge_key == "review_comments":
607+
# Later coverage passes need the selection decisions too. Raw
608+
# findings alone can otherwise resurrect a concern the reducer
609+
# already established was harmless or outside this change.
610+
merged["_rejected_review_comments"] = [
611+
{"candidate": candidates[i], "reason": str(v.get("why") or "")}
612+
for i, v in by_i.items()
613+
if str(v.get("action", "")).lower() == "drop"
614+
]
606615
else:
607616
merged["summary"] = (f"ensemble of {len(samples)} samples "
608617
"(merge reduction failed; unverified union)")

‎src/infermatrix_copilot/engine/steps/review/refinement.py‎

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ async def _promote_uncovered(ctx: StepContext, output: dict,
6666
"'covered by weekly only') is a FINDING wearing a validation "
6767
"stamp — promote it as the pointed question or ask it implies. "
6868
"Rules: "
69+
"A true code fact is not automatically a defect. Require a concrete "
70+
"current trigger and adverse effect or an unmet requirement. Do not "
71+
"promote harmless unused configuration or hypothetical future code "
72+
"changes. Respect REJECTED COMMENTS and their reasons; reopen one "
73+
"only with new evidence that defeats the rejection. "
6974
"(1) ONLY promote what the raw lines already state — no new claims, "
7075
"no re-investigation; quote the source line in `evidence`. (2) A "
7176
"promoted line KEEPS its directive force: a concern naming a "
@@ -84,6 +89,9 @@ async def _promote_uncovered(ctx: StepContext, output: dict,
8489
"comment")}
8590
for c in comments], ensure_ascii=False, indent=1)
8691
+ "\n\n## RAW FINDINGS\n" + "\n".join(f"- {x}" for x in findings)
92+
+ "\n\n## REJECTED COMMENTS\n"
93+
+ json.dumps(output.get("_rejected_review_comments") or [],
94+
ensure_ascii=False)
8795
+ ("\n\n## BLOCKERS\n" + "\n".join(f"- {x}" for x in blockers)
8896
if blockers else "")
8997
+ ("\n\n## ASSUMPTIONS\n" + "\n".join(f"- {x}" for x in assumptions)
@@ -229,6 +237,11 @@ async def _second_round(ctx: StepContext, output: dict, common: dict,
229237
"You are the SECOND-ROUND reviewer. The first round produced the "
230238
"KEPT COMMENTS in your evidence; your job is ONLY its coverage "
231239
"holes — do not re-litigate or duplicate what is already covered.\n"
240+
"Respect rejected_comments and their reasons. Reopen a rejected "
241+
"concern only with new evidence defeating that reason. A true code "
242+
"fact needs a current trigger and adverse effect or an unmet "
243+
"requirement to become a finding; harmless unused values and "
244+
"hypothetical future changes are not defects.\n"
232245
+ (("These diff hunks (file:start-line) have NO comment and NO "
233246
"recorded verification near them. Review each AT MAINTAINER "
234247
"INLINE GRANULARITY: page to the hunk in situ, and either raise "
@@ -271,7 +284,10 @@ async def _second_round(ctx: StepContext, output: dict, common: dict,
271284
"evidence": {**common["evidence"],
272285
"uncovered_hunk_diffs": hunk_evidence,
273286
"kept_comments": json.dumps(
274-
kept, ensure_ascii=False)}}
287+
kept, ensure_ascii=False),
288+
"rejected_comments": json.dumps(
289+
output.get("_rejected_review_comments")
290+
or [], ensure_ascii=False)}}
275291
result, extra = await run_agent_step(
276292
ctx, **round_kwargs,
277293
max_iters=ctx.settings.review_second_round_max_iters, **routing)
@@ -333,13 +349,20 @@ async def _second_round(ctx: StepContext, output: dict, common: dict,
333349
2. Claim: is the asserted problem true of the code you just read? If the
334350
comment asserts consumers/callers/tests elsewhere, grep for them and read
335351
the one that decides the claim.
336-
3. Severity: major requires a real defect in the changed code or a required
352+
3. Actionability: establish a concrete trigger on this PR head and its
353+
adverse effect, or a specific unmet requirement. A code fact being true
354+
does not establish a defect. Unused optional configuration without an
355+
affected consumer, and a problem contingent on hypothetical future code
356+
changes, are not actionable. Check rejected_comments before reopening
357+
a concern; identify new evidence that defeats its rejection.
358+
4. Severity: major requires a real defect in the changed code or a required
337359
update the diff lacks; "consider adding X" polish is minor at most.
338360
339361
Verdicts:
340362
- confirmed: you READ the code that makes the claim true. You MUST return
341363
`evidence` as SELF-CONTAINED PROOF a reader with no repo access can check:
342-
the decisive code line(s) QUOTED VERBATIM with their file:line, e.g.
364+
the current trigger and adverse effect (or unmet requirement), together
365+
with the decisive code line(s) QUOTED VERBATIM with their file:line, e.g.
343366
'serving_speech.py:3711 `extra_args["tts_local_seed"] = seed` — set for
344367
every model, no qwen3_tts gate'. A narrative like "read the file, claim
345368
holds" is NOT proof and scores as speculation downstream. When the
@@ -352,6 +375,9 @@ async def _second_round(ctx: StepContext, output: dict, common: dict,
352375
the substance, never soften a confirmed defect.
353376
- refuted: the code CONTRADICTS the claim (misread, already handled, wrong
354377
file). Refuted is an evidence conclusion, never a budget one.
378+
- not_actionable: the cited facts may be true, but no current defect or
379+
unmet requirement is demonstrated. Explain why the proposed ask is not
380+
needed on this head. This verdict drops the comment.
355381
- unverifiable: you could not decide within budget.
356382
357383
Confirming from plausibility alone is the one failure this pass exists to
@@ -363,7 +389,7 @@ async def _verify_comments(ctx: StepContext, output: dict,
363389
"""Per-comment agentic verification (val-gate lesson: with recall at
364390
parity the arm lost on per-comment grounding). Each draft comment gets
365391
one small tool-loop that must anchor and re-derive the claim on the
366-
PR-time tree: refuted comments drop, unverifiable ones demote one
392+
PR-time tree: refuted and non-actionable comments drop, unverifiable ones demote one
367393
severity step, confirmed ones may be tightened in wording/position. A
368394
verification-step failure keeps the comment unchanged — this pass may
369395
only improve precision, never silently delete recall."""
@@ -385,19 +411,23 @@ async def _one(i: int, c: dict):
385411
purpose="Verify one draft review comment against the "
386412
"PR-time tree.",
387413
guidance=_VERIFY_GUIDANCE,
388-
expected="verdict confirmed|refuted|unverifiable, with "
414+
expected="verdict confirmed|refuted|not_actionable|unverifiable, with "
389415
"optional comment/line/severity corrections",
390416
evidence={"pr_diff": diff_ev,
391417
"draft_comment": json.dumps(
392-
probe, ensure_ascii=False)},
418+
probe, ensure_ascii=False),
419+
"rejected_comments": json.dumps(
420+
output.get("_rejected_review_comments") or [],
421+
ensure_ascii=False)},
393422
output_extension={
394-
"verdict": "confirmed|refuted|unverifiable",
423+
"verdict": "confirmed|refuted|not_actionable|unverifiable",
395424
"comment": "optional tightened rewrite",
396425
"line": "optional corrected line number",
397426
"severity": "optional corrected severity",
398427
"evidence": "confirmed only: verbatim-quoted decisive "
399-
"code line(s) with file:line — proof a "
400-
"repo-less reader can check"},
428+
"code line(s) with file:line, current trigger "
429+
"and adverse effect or unmet requirement — "
430+
"proof a repo-less reader can check"},
401431
extra_tools=common.get("extra_tools"),
402432
max_iters=ctx.settings.review_verify_max_iters)
403433
return i, result, (out or {})
@@ -411,10 +441,10 @@ async def _one(i: int, c: dict):
411441
c = dict(comments[i])
412442
verdict = str(out.get("verdict") or "").lower()
413443
if not result.ok or verdict not in ("confirmed", "refuted",
414-
"unverifiable"):
444+
"not_actionable", "unverifiable"):
415445
kept.append(c) # fail-open: never delete on pass failure
416446
continue
417-
if verdict == "refuted":
447+
if verdict in ("refuted", "not_actionable"):
418448
n_drop += 1
419449
continue
420450
if verdict == "confirmed":

‎src/infermatrix_copilot/engine/steps/review/steps.py‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,10 @@ async def _review_diff(ctx: StepContext) -> StepResult:
295295
f"{result.summary}",
296296
outputs=result.outputs,
297297
changed_files=result.changed_files)
298+
draft_selection = [
299+
(c.get("file"), c.get("line"), c.get("comment"), c.get("severity"))
300+
for c in output.get("review_comments") or []
301+
]
298302
if plan is not None and result.ok:
299303
output = await refine_review(
300304
ctx, output, common=common, diff=str(diff), spec=spec,
@@ -320,10 +324,6 @@ async def _review_diff(ctx: StepContext) -> StepResult:
320324
published, withheld = finalize_review_dispositions(output)
321325
# at most three untested-function comments; the rest are named, not lost
322326
published, ut_dropped = cap_gap_comments(published, ut_report)
323-
if ut_dropped:
324-
output["summary"] = (str(output.get("summary") or "").rstrip()
325-
+ "\n\nAlso without a unit test: "
326-
+ ", ".join(f"`{n}`" for n in ut_dropped) + ".").lstrip()
327327
output["review_comments"] = published
328328
output["_withheld_findings"] = withheld
329329
comments = sorted(output.get("review_comments") or [],
@@ -333,6 +333,19 @@ async def _review_diff(ctx: StepContext) -> StepResult:
333333
for c in comments:
334334
c.pop("corroborated_by", None)
335335
output["review_comments"] = comments[:8]
336+
final_selection = [
337+
(c.get("file"), c.get("line"), c.get("comment"), c.get("severity"))
338+
for c in output["review_comments"]
339+
]
340+
if sorted(draft_selection, key=repr) != sorted(final_selection, key=repr):
341+
# The reducer's prose describes its draft selection. After promotion,
342+
# verification or a budget cut it may contradict the published set.
343+
# Let the renderer derive a truthful summary from the final comments.
344+
output["summary"] = ""
345+
if ut_dropped:
346+
output["summary"] = (str(output.get("summary") or "").rstrip()
347+
+ "\n\nAlso without a unit test: "
348+
+ ", ".join(f"`{n}`" for n in ut_dropped) + ".").lstrip()
336349
# Recorded AFTER the cut: a candidate the review chose to publish but the
337350
# budget dropped is `over_budget`, never `excluded`. A consumer checking
338351
# that no withheld finding was published must not trip over a healthy

‎src/infermatrix_copilot/engine/steps/review/utils.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,8 @@ def _render_review_md(output: dict, pr_state: str = "") -> str:
636636
entry += f"\n\nProposed change (not a ready patch): {suggestion}"
637637
lines.append(entry)
638638
parts = _review_summary_parts(output)
639-
parts.append("\n\n".join(lines) if lines else output.get("summary", "No findings."))
639+
parts.append("\n\n".join(lines) if lines else
640+
(output.get("summary") or "No actionable findings."))
640641
overflow = output.get("_review_overflow") or []
641642
if overflow:
642643
# findings the comment budget cut — one line each, so a real (often

‎test/test_agent_ensemble.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,82 @@ def test_coverage_promotion_converts_findings_to_comments(settings, trace,
438438
assert len(kept) == 2
439439
assert "remains unfixed" in kept[1]["comment"]
440440
assert next(trace.events("review_coverage_promoted"))["added"] == 1
441+
assert "2 actionable finding(s)." in result.outputs["review_summary"]
442+
assert "merged" not in result.outputs["review_summary"]
443+
assert not llm._replies
444+
445+
446+
def test_rejected_facts_stay_available_and_non_actionable_additions_drop(
447+
settings, trace, tmp_path, git_repo):
448+
"""A true unused-key observation must not become a published defect
449+
merely because coverage revisits it after the reducer rejected it."""
450+
settings.review_ensemble = True
451+
settings.review_depth = "full"
452+
settings.review_second_round = True
453+
settings.review_verify_comments = True
454+
settings.review_verify_concurrency = 1
455+
harmless = {"file": "config.py", "line": 10, "severity": "minor",
456+
"comment": "optional key also reaches an unused consumer",
457+
"evidence": "config.py:10 `allowed |= {'model_config'}`"}
458+
future = {"file": "config.py", "line": 40, "severity": "minor",
459+
"comment": "validator may reject this if its fields change later",
460+
"evidence": "config.py:40 `fields = current_fields()`"}
461+
reason = "No current affected consumer; forwarding is harmless"
462+
llm = ScriptedLLM([
463+
contract(review_comments=[harmless],
464+
findings=["[validated] optional key is forwarded"]),
465+
*[contract(review_comments=[])] * (len(_REVIEW_LENSES) - 1),
466+
verdicts_reply({"i": 0, "action": "drop", "why": reason},
467+
summary="No actionable findings."),
468+
Reply(blocks=[Block(type="text", text=json.dumps(
469+
{"additions": [harmless]}))]),
470+
contract(review_comments=[future]),
471+
contract(verdict="not_actionable", evidence=reason),
472+
contract(verdict="not_actionable",
473+
evidence="current_fields excludes the key on this head"),
474+
])
475+
state = {"diff_text": "diff --git a/config.py b/config.py\n"
476+
"+++ b/config.py\n@@ -1,0 +1,1 @@\n+allowed = set()",
477+
"task_spec": {"pr": 9}, "repo_path": str(git_repo)}
478+
result = asyncio.run(register_builtin_steps(StepRegistry()).get(
479+
"agent.review_diff").handler(
480+
_ctx(settings, trace, tmp_path, state, llm=llm)))
481+
assert result.ok
482+
assert result.outputs["review_comments"] == []
483+
assert result.outputs["review_summary"].endswith("**Verdict:** APPROVE")
484+
assert "optional key also reaches" not in result.outputs["review_text"]
485+
assert next(trace.events("review_comments_verified"))["dropped"] == 2
486+
# The decision survives every boundary, including the second round and
487+
# independent verifiers, instead of only living in a reducer archive.
488+
for call in llm.calls[-4:]:
489+
assert reason in json.dumps(call["messages"])
490+
assert not llm._replies
491+
492+
493+
def test_verification_removal_clears_stale_reducer_summary(
494+
settings, trace, tmp_path, git_repo):
495+
settings.review_ensemble = True
496+
settings.review_depth = "full"
497+
settings.review_verify_comments = True
498+
llm = ScriptedLLM([
499+
contract(review_comments=[{"file": "config.py", "line": 1,
500+
"severity": "major", "comment": "broken",
501+
"evidence": "config.py:1"}]),
502+
*[contract(review_comments=[])] * (len(_REVIEW_LENSES) - 1),
503+
verdicts_reply({"i": 0, "action": "keep"},
504+
summary="One blocking defect remains."),
505+
contract(verdict="refuted"),
506+
])
507+
state = {"diff_text": "diff --git a/config.py b/config.py\n+x = 1",
508+
"task_spec": {"pr": 9}, "repo_path": str(git_repo)}
509+
result = asyncio.run(register_builtin_steps(StepRegistry()).get(
510+
"agent.review_diff").handler(
511+
_ctx(settings, trace, tmp_path, state, llm=llm)))
512+
assert result.ok
513+
assert result.outputs["review_comments"] == []
514+
for field in ("review_text", "review_summary"):
515+
assert "blocking defect remains" not in result.outputs[field]
516+
assert "No actionable findings." in result.outputs[field]
441517
assert not llm._replies
442518

443519

0 commit comments

Comments
 (0)