Skip to content

Commit 7bb1166

Browse files
authored
feat(guardian): recall-lean universal finder + skeptic reconciliation (#249)
Recall-lean finder (drop confidence gate + finding cap, Tier-1; per-function CoT + bug-class focus areas + few-shot, Tier-2) takes pr-144 from 0/5 recall (across 5 model tiers) to median 3/5. Skeptic reconciled so it no longer auto-refutes the finder's deliberate low-confidence findings. Prompt assembly degrades cleanly on repos without CONTRIBUTING.md/ontology (read_file → "" via is_file()). Follow-ups: #246 cross-model skeptic (noise-trimming), #247 blind classes (cosine/float-eq), #248 robustness + self-consistency union.
1 parent 119e5b3 commit 7bb1166

6 files changed

Lines changed: 149 additions & 50 deletions

File tree

src/cgis/guardian/collector.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,18 @@ def get_changed_py_files(self) -> list[str]:
111111
return [p for p in result.stdout.splitlines() if p.endswith(".py")]
112112

113113
def read_file(self, relative_path: str) -> str:
114-
"""Reads a file from the project root."""
114+
"""Reads a file from the project root; returns "" when it does not exist.
115+
116+
An empty string (not an error marker) lets the prompt builder omit the
117+
corresponding section entirely — a repo without CONTRIBUTING.md or
118+
docs/ontology/ should review cleanly, not get "Error: File ..." injected
119+
as if it were the standards/ontology text. `is_file()` (not `exists()`)
120+
so a path that resolves to a directory degrades to "" instead of raising
121+
IsADirectoryError on read.
122+
"""
115123
file_path = self.project_root / relative_path
116-
if not file_path.exists():
117-
return f"Error: File {relative_path} not found."
124+
if not file_path.is_file():
125+
return ""
118126
return file_path.read_text(encoding="utf-8")
119127

120128
def collect_full_files(self, files: list[str] | None = None) -> str:

src/cgis/guardian/prompts.py

Lines changed: 92 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,23 @@ class PromptBuilder:
88
def build_system_prompt() -> str:
99
"""Return the system prompt that establishes Guardian's reviewer persona."""
1010
return (
11-
"You are the CGIS Guardian — a Senior Software Architect doing a code review. "
12-
"Your goal is HIGH PRECISION: every finding you report must be a real problem "
13-
"that exists in the actual diff. A false positive wastes engineering time just "
14-
"as much as a missed bug. Quality of findings beats quantity. "
11+
"You are the CGIS Guardian finder — a Senior Software Architect hunting for "
12+
"defects in a code review. You are the FIRST of two stages: a separate skeptic "
13+
"verifier runs after you and removes false positives. Because of that division "
14+
"of labour, your job is RECALL — surface every plausible real defect. A missed "
15+
"bug is the expensive error here; a borderline finding is cheap, because the "
16+
"skeptic filters it. Surface a finding whenever you can name a CONCRETE failure "
17+
"scenario — a specific input, state, timing, or platform that makes the code "
18+
"wrong. Do not self-censor because you are unsure: hand the uncertainty to the "
19+
"skeptic with your reasoning, do not suppress it. "
1520
"You prioritise: (1) Logic correctness — wrong output, crashes, data corruption; "
1621
"(2) Library boundary contracts — convention mismatches at third-party API calls; "
1722
"(3) Missing test coverage for real edge cases in the diff; "
1823
"(4) Type safety violations that mypy strict would catch; "
1924
"(5) Ontology compliance — wrong NodeType/EdgeType mappings corrupt the graph. "
20-
"You do NOT flag style preferences, naming conventions, or design disagreements "
21-
"unless they cause a concrete defect. If the code is correct and tested, say so."
25+
"You still do NOT flag style preferences, naming conventions, or design "
26+
"disagreements that have no concrete failure scenario. If the code is correct "
27+
"and tested, say so."
2228
)
2329

2430
@staticmethod
@@ -62,42 +68,79 @@ def build_user_prompt(context: dict[str, str]) -> str:
6268
{drift}
6369
"""
6470

65-
return f"""Review the following Pull Request diff for real defects.
66-
67-
### 1. ENGINEERING STANDARDS (from CONTRIBUTING.md)
71+
contributing_section = ""
72+
if contributing:
73+
contributing_section = f"""### 1. ENGINEERING STANDARDS (from CONTRIBUTING.md)
6874
{contributing}
6975
70-
### 2. PROJECT ONTOLOGY (from docs/ontology/)
76+
"""
77+
78+
ontology_section = ""
79+
if ontology:
80+
ontology_section = f"""### 2. PROJECT ONTOLOGY (from docs/ontology/)
7181
{ontology}
7282
73-
### 3. CHANGES TO REVIEW (git diff)
83+
"""
84+
85+
return f"""Review the following Pull Request diff for real defects.
86+
87+
{contributing_section}{ontology_section}### 3. CHANGES TO REVIEW (git diff)
7488
{diff}
7589
{graph_section}{full_files_section}{drift_section}
7690
---
77-
### PRECISION RULES — read before writing a single finding:
91+
### HUNTING RULES — read before writing a single finding:
7892
79-
1. **Evidence first.** Quote the exact line(s) from the diff that prove the problem.
80-
The quoted text MUST appear verbatim in section 3. If you cannot find it, do not raise it.
93+
1. **Evidence first.** Quote the exact line(s) from the diff that the finding sits on.
94+
The quoted text MUST appear verbatim in section 3 (it positions the inline comment).
95+
If you cannot find the line, do not raise it.
8196
82-
2. **Confidence gate.** Before writing a finding, ask yourself:
83-
"Am I at least 80% confident this is a real defect in this diff?"
84-
If not — omit it entirely. Uncertain findings are not helpful.
97+
2. **Surface on a nameable failure scenario.** Before writing a finding, ask yourself:
98+
"Can I describe one concrete input, state, timing, or platform where this code
99+
misbehaves?" If yes — REPORT it, and put your honest `confidence` in the field.
100+
Confidence does NOT gate inclusion: a 40%-confident finding with a real failure
101+
scenario is worth surfacing, because the skeptic verifier decides what to keep.
102+
Only drop a candidate when you cannot construct ANY failure scenario for it.
85103
86104
3. **No ghost issues.** If the code already handles a case, acknowledge it and move on.
87-
Do not flag something as missing when it is present.
105+
Do not flag something as missing when it is present (this is a wrong finding, not
106+
an uncertain one — the skeptic cannot rescue precision from a fabricated claim).
88107
89108
4. **No invented rules.** Only cite standards explicitly written in CONTRIBUTING.md or the
90109
ontology files provided above. Do not apply rules from outside this context.
91110
92-
5. **Cap at 5 findings.** Report only the 5 most important real issues.
93-
If you find fewer real issues, report fewer. Zero is a valid answer.
111+
5. **No finding cap.** Report every real candidate you find — more genuine findings is
112+
strictly better here, since recall is your job and the skeptic trims the list. Order
113+
them most-severe first. Zero is still a valid answer when the diff is clean.
114+
115+
6. **Reason per changed function before deciding.** For each function the diff touches,
116+
briefly walk these before you write findings for it:
117+
- What are its inputs and where do they come from (caller, config/YAML/JSON, env,
118+
request)? Is any of them external/untrusted?
119+
- What happens on the awkward inputs: empty / None / zero / a non-dict where a dict
120+
is assumed / a scalar where an iterable is assumed / unsorted / duplicate / oversized?
121+
- Did a deleted or changed line hold an invariant (a guard, a validation, an error
122+
path)? Is it re-established?
123+
Only after that walk do you decide what to surface. Skipping this walk is the main
124+
reason subtle defects (unvalidated data, exact-equality, dropped guards) go unseen.
94125
95126
---
96127
### WHAT TO LOOK FOR (focus areas):
97128
98129
**Logic bugs** — inputs that produce wrong output, division by zero, off-by-one errors,
99130
incorrect algorithm behaviour. Think: empty collections, None values, boundary conditions.
100131
132+
**Unvalidated external data** — a value read from config/YAML/JSON, env, or a request is
133+
used as a `dict`/`list`/`set`/iterable (subscripted, iterated, passed to `set()`/`dict()`,
134+
`.items()`, `for x in value`) without first checking its type or presence. A YAML key the
135+
author expects to be a mapping can legally be a scalar or a list; a `value or {{}}` idiom
136+
catches `None` but lets a non-dict truthy value through to operations that then misbehave
137+
silently rather than erroring. Flag each such use that lacks a type/shape guard.
138+
139+
**Exact-equality on floats / money** — bare `==` or `!=` comparing floating-point or
140+
Decimal values, *including in test assertions* (`assert x == 0.3`). Floating-point
141+
rounding makes these flaky or wrong; they should use a tolerance compare. Check changed
142+
test files for this too.
143+
101144
**Missing test coverage** — code paths in the diff that have no test. Focus on edge cases
102145
that could silently return wrong results (not just "coverage for coverage's sake").
103146
@@ -116,6 +159,32 @@ def build_user_prompt(context: dict[str, str]) -> str:
116159
**Ontology compliance** — wrong NodeType/EdgeType assignments, FQNs not derived from file
117160
paths, unresolved calls not using `raw_call:` prefix.
118161
162+
---
163+
### WORKED EXAMPLES (how the per-function walk turns into a finding):
164+
165+
These show the kind of subtle, borderline defect that is easy to skip but worth surfacing.
166+
Do not look for these exact lines — learn the *pattern* and apply it to the diff above.
167+
168+
Example A — unvalidated config value used as a mapping:
169+
```
170+
def layers_for(self, name: str) -> set[str]:
171+
cfg = self._patterns.get(name) # cfg comes from a YAML file
172+
return set(cfg) # <-- assumes cfg is iterable-of-str
173+
```
174+
Walk: input `cfg` is external (YAML); the author assumes a list/mapping, but YAML lets
175+
that key be a scalar (`name: layered`) → `set("layered")` silently yields `{{'l','a',...}}`,
176+
not an error. → FINDING: `set(cfg)` lacks a type/shape guard on external config data.
177+
(confidence ~50 — borderline, but it has a concrete failure scenario, so surface it.)
178+
179+
Example B — exact-equality on floats in a test:
180+
```
181+
def test_drift_score():
182+
assert scorer.score(fp) == 0.3 # <-- bare == on a float result
183+
```
184+
Walk: `score()` returns a computed float; `== 0.3` is exact float equality → rounding can
185+
make this assert flaky/false. → FINDING: float compared with bare `==` in a test; use a
186+
tolerance (`pytest.approx` / `math.isclose`). (confidence ~55.)
187+
119188
---
120189
### OUTPUT FORMAT:
121190
@@ -140,8 +209,9 @@ def build_user_prompt(context: dict[str, str]) -> str:
140209
diff in section 3 (no paraphrasing, no `+`/`-` marker). It is used to position the inline
141210
comment deterministically — if your "line" guess is off, a correct "anchor" still lands the
142211
comment on the right line. Use null only for genuinely file-level findings.
143-
- "confidence" must be >= 80 to include a finding (the gate above).
144-
- max 5 findings; fewer is fine; an empty list means LGTM.
212+
- "confidence" is your honest 0-100 estimate; it does NOT gate inclusion — the skeptic
213+
verifier uses it to prioritise, so report findings below 80 too.
214+
- no finding cap; report every real candidate, most-severe first; an empty list means LGTM.
145215
- "summary" is mandatory; for an LGTM it lists what you checked and found correct.
146216
147217
Example LGTM response: {{"findings": [], "summary": "Checked diff for logic and types; ok."}}"""

src/cgis/guardian/skeptic.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010

1111
log = structlog.getLogger(__name__)
1212

13-
_CONFIDENCE_GATE = 80
14-
_UNCERTAIN_MULTIPLIER = 0.9 # keeps original confidence >= 89; smaller would
15-
# refute EVERY uncertain finding and make the branch dead code (spec §5.3).
13+
_UNCERTAIN_MULTIPLIER = 0.9 # an 'uncertain' verdict discounts confidence as a
14+
# ranking signal only — it NEVER refutes. The recall-lean finder emits genuine
15+
# low-confidence findings on purpose; only an explicit 'refuted' verdict drops one.
1616

1717

1818
class SkepticVerdict(BaseModel, frozen=True):
@@ -32,15 +32,19 @@ class SkepticResult(BaseModel, frozen=True):
3232
# Confirm-by-default stance. The original refute-by-default wording over-killed
3333
# in benchmarks: a gemini-3.5-flash skeptic refuted 7/7 finder findings,
3434
# including 2 ground-truth matches on PR 122 (gate allows at most 1 lost match).
35+
# The finder is now recall-lean (no confidence gate, no cap), so it surfaces
36+
# genuine low-confidence findings BY DESIGN and leans on this pass for precision —
37+
# the skeptic must cut hallucinations without re-introducing the gate it removed.
3538
SKEPTIC_SYSTEM_PROMPT = (
3639
"You are a skeptical senior reviewer double-checking another reviewer's findings. "
37-
"For each finding, verify the quoted evidence against the diff and judge whether "
38-
"the claimed defect is real. Refute a finding ONLY when you can point to concrete "
39-
"evidence that it is wrong: the quoted code does not appear in the diff, the case "
40-
"is already handled, or the claim misreads what the code does. "
41-
"If a finding is plausible and you cannot disprove it, confirm it — the finder "
42-
"already applied a confidence gate; your job is to catch its hallucinations, "
43-
"not to second-guess its judgement calls."
40+
"That reviewer optimises for RECALL: it deliberately surfaces plausible, sometimes "
41+
"low-confidence candidates and relies on you to remove only the ones that are wrong. "
42+
"For each finding, verify the quoted evidence against the diff and judge whether the "
43+
"claimed defect is real. Refute a finding ONLY when you can point to concrete evidence "
44+
"that it is wrong: the quoted code does not appear in the diff, the case is already "
45+
"handled, or the claim misreads what the code does. Do NOT refute a finding merely for "
46+
"being low-confidence, speculative, or a judgement call — if it is plausible and you "
47+
"cannot disprove it, mark it 'confirmed' or 'uncertain' (both are kept), never 'refuted'."
4448
)
4549

4650

@@ -74,8 +78,9 @@ def apply_verdicts(findings: list[Finding], skeptic: SkepticResult) -> list[Find
7478
"""Merge skeptic verdicts into new frozen Finding copies (spec §5.3).
7579
7680
Out-of-range / duplicate indices are discarded and logged. Unruled findings
77-
keep verdict=None. uncertain discounts confidence x0.9; below the 80 gate
78-
it becomes refuted.
81+
keep verdict=None. uncertain discounts confidence x0.9 as a ranking signal but
82+
is KEPT (only an explicit 'refuted' verdict drops a finding) — the recall-lean
83+
finder relies on the skeptic to cut hallucinations, not low confidence.
7984
"""
8085
by_index: dict[int, SkepticVerdict] = {}
8186
for v in skeptic.verdicts:
@@ -94,12 +99,12 @@ def apply_verdicts(findings: list[Finding], skeptic: SkepticResult) -> list[Find
9499
merged.append(finding) # absence of a verdict is not a refutation
95100
continue
96101
if verdict.verdict == "uncertain":
102+
# Kept, not refuted: discount confidence as a ranking signal only.
97103
discounted = round(finding.confidence * _UNCERTAIN_MULTIPLIER)
98-
final = "refuted" if discounted < _CONFIDENCE_GATE else "uncertain"
99104
merged.append(
100105
finding.model_copy(
101106
update={
102-
"verdict": final,
107+
"verdict": "uncertain",
103108
"skeptic_note": verdict.rationale,
104109
"confidence": discounted,
105110
}

tests/unit/test_guardian_core.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ def test_build_user_prompt_includes_all_sections() -> None:
4040
assert "the ontology" in prompt
4141

4242

43+
def test_build_user_prompt_omits_standards_and_ontology_when_empty() -> None:
44+
"""A repo without CONTRIBUTING.md / ontology (empty strings) drops both sections.
45+
46+
Guards the universal-reviewer path: no "Error: File ... not found." text and
47+
no empty PROJECT ONTOLOGY heading leaking into a non-cgis repo's prompt.
48+
"""
49+
context = {"diff": "the diff", "contributing": "", "ontology": ""}
50+
prompt = PromptBuilder.build_user_prompt(context)
51+
assert "the diff" in prompt
52+
assert "ENGINEERING STANDARDS" not in prompt
53+
assert "PROJECT ONTOLOGY" not in prompt
54+
assert "not found" not in prompt
55+
56+
4357
def test_build_user_prompt_includes_graph_section() -> None:
4458
"""Graph context is injected as section 4 when present."""
4559
context = {
@@ -320,8 +334,8 @@ def test_get_changed_py_files_on_error(tmp_path: Path) -> None:
320334

321335

322336
def test_read_file_missing(tmp_path: Path) -> None:
323-
"""Returns error string for missing file."""
324-
assert "not found" in ContextCollector(project_root=tmp_path).read_file("nonexistent.md")
337+
"""Returns "" for a missing file so the prompt omits its section."""
338+
assert ContextCollector(project_root=tmp_path).read_file("nonexistent.md") == ""
325339

326340

327341
def test_read_file_exists(tmp_path: Path) -> None:

tests/unit/test_guardian_runner.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,9 @@ async def test_run_guardian_smoke(tmp_path: Path) -> None:
154154
155155
ContextCollector.collect_all() gracefully handles a non-git tmp_path:
156156
get_git_diff() returns an error string (no exception), and read_file()
157-
returns "Error: File ... not found." for missing CONTRIBUTING.md /
158-
ontology.yaml. The FakeProvider ignores the prompt content and always
159-
returns valid JSON, so no mock of collect_all() is needed.
157+
returns "" for missing CONTRIBUTING.md / ontology.yaml (so those prompt
158+
sections are simply omitted). The FakeProvider ignores the prompt content
159+
and always returns valid JSON, so no mock of collect_all() is needed.
160160
"""
161161
metrics = tmp_path / "m.jsonl"
162162
collector = ContextCollector(project_root=tmp_path)

tests/unit/test_guardian_skeptic.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,21 @@ def test_refuted_marks_but_keeps_finding() -> None:
4040
assert merged[0].verdict == "refuted"
4141

4242

43-
def test_uncertain_discounts_to_above_gate() -> None:
44-
"""uncertain at confidence 89 → round(89*0.9)=80, stays uncertain (boundary)."""
43+
def test_uncertain_discounts_confidence_but_keeps_finding() -> None:
44+
"""uncertain confidence x0.9 (ranking signal), verdict stays uncertain."""
4545
f = _FINDING.model_copy(update={"confidence": 89})
4646
merged = apply_verdicts([f], SkepticResult(verdicts=[_verdict(0, "uncertain")]))
4747
assert merged[0].verdict == "uncertain"
4848
assert merged[0].confidence == 80
4949

5050

51-
def test_uncertain_discount_below_gate_refutes() -> None:
52-
"""uncertain at confidence 88 → round(88*0.9)=79 < 80 → treated as refuted."""
53-
f = _FINDING.model_copy(update={"confidence": 88})
51+
def test_uncertain_low_confidence_is_kept_not_refuted() -> None:
52+
"""Recall-lean: a low-confidence uncertain finding survives (no gate auto-refute)."""
53+
f = _FINDING.model_copy(update={"confidence": 30})
5454
merged = apply_verdicts([f], SkepticResult(verdicts=[_verdict(0, "uncertain")]))
55-
assert merged[0].verdict == "refuted"
55+
assert merged[0].verdict == "uncertain"
56+
assert merged[0].confidence == 27 # round(30 * 0.9)
57+
assert visible_findings(merged) == merged # not dropped
5658

5759

5860
def test_out_of_range_and_duplicate_indices_discarded() -> None:

0 commit comments

Comments
 (0)