fix(hunt): lines read through a tool count as shown; an unparseable answer is an empty answer

The answer check counted only the rendered function text as shown, so 7 of
8 baseline suspicions citing lines the model had read with read_file were
marked unknown (baseline 1/12 against the pilot hunter's 6/12). Tools now
record the lines they return. A DSPy parse failure scores as zero findings
with a flag instead of a broken run. The per-run response cache now takes
effect through dspy.configure_cache.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-13 13:10:40 +02:00
co-authored by Claude Opus 5.5
parent c5bf52f47d
commit 602ec23e03
8 changed files with 174 additions and 62 deletions
+6 -2
View File
@@ -77,8 +77,12 @@ class UsageLedger:
def add(self, call_row: Mapping[str, Any]) -> None: def add(self, call_row: Mapping[str, Any]) -> None:
replay_hits = call_row.get("replay_hits", int(bool(call_row.get("replay_hit", False)))) replay_hits = call_row.get("replay_hits", int(bool(call_row.get("replay_hit", False))))
values = {"usd": call_row.get("usd", 0), "calls": call_row.get("calls", 0 if replay_hits else 1), values = {
"replay_hits": replay_hits, "wall_seconds": call_row.get("wall_seconds", 0)} "usd": call_row.get("usd", 0),
"calls": call_row.get("calls", 0 if replay_hits else 1),
"replay_hits": replay_hits,
"wall_seconds": call_row.get("wall_seconds", 0),
}
if any(not isinstance(v, int | float) or not math.isfinite(v) or v < 0 for v in values.values()): if any(not isinstance(v, int | float) or not math.isfinite(v) or v < 0 for v in values.values()):
raise ValueError("usage values must be finite and non-negative") raise ValueError("usage values must be finite and non-negative")
if any(int(values[k]) != values[k] for k in ("calls", "replay_hits")): if any(int(values[k]) != values[k] for k in ("calls", "replay_hits")):
+8 -4
View File
@@ -21,13 +21,17 @@ def label_rows(rows: Sequence[Mapping[str, Any]], change: Mapping[str, Any]) ->
data = deepcopy(dict(row)) data = deepcopy(dict(row))
span = change.get("function_lines") span = change.get("function_lines")
function = row.get("function", row.get("feature.source.function")) function = row.get("function", row.get("feature.source.function"))
site = kind == "vulnerability" and row.get("path") == change["path"] and ( site = (
function == change["function"] or (span[0] <= row.get("line", 0) <= span[1]) kind == "vulnerability"
and row.get("path") == change["path"]
and (function == change["function"] or (span[0] <= row.get("line", 0) <= span[1]))
) )
data.update(label=1 if site else 0 if kind == "ordinary" else None, data.update(
label=1 if site else 0 if kind == "ordinary" else None,
family=change["family"] if site else None, family=change["family"] if site else None,
fix_span=list(span) if site else None, fix_span=list(span) if site else None,
label_ref=change["case_id"] if site or kind == "ordinary" else None, label_ref=change["case_id"] if site or kind == "ordinary" else None,
verification=None) verification=None,
)
result.append(data) result.append(data)
return result return result
+9 -7
View File
@@ -34,9 +34,11 @@ def _location_match(change: Mapping[str, Any], finding: Mapping[str, Any], toler
def _on_diff(change: Mapping[str, Any], finding: Mapping[str, Any], tolerance: int) -> bool: def _on_diff(change: Mapping[str, Any], finding: Mapping[str, Any], tolerance: int) -> bool:
return any(finding.get("path") == file["path"] and return any(
any(start - tolerance <= (finding.get("line", 0) or 0) <= end + tolerance finding.get("path") == file["path"]
for start, end in file.get("head_lines", [])) for file in change.get("files", [])) and any(start - tolerance <= (finding.get("line", 0) or 0) <= end + tolerance for start, end in file.get("head_lines", []))
for file in change.get("files", [])
)
def search_outcome(change: Mapping[str, Any], findings: Sequence[Mapping[str, Any]], *, tolerance: int = 10) -> Outcome: def search_outcome(change: Mapping[str, Any], findings: Sequence[Mapping[str, Any]], *, tolerance: int = 10) -> Outcome:
@@ -51,11 +53,11 @@ def search_outcome(change: Mapping[str, Any], findings: Sequence[Mapping[str, An
on_diff = sum(_on_diff(change, finding, tolerance) for finding in findings) on_diff = sum(_on_diff(change, finding, tolerance) for finding in findings)
feedback = [] feedback = []
if change["kind"] == "vulnerability" and not hit: if change["kind"] == "vulnerability" and not hit:
span = change.get("function_lines") or [next((start for f in change.get("files", []) span = change.get("function_lines") or [next((start for f in change.get("files", []) for start, _ in f.get("head_lines", [])), 0)]
for start, _ in f.get("head_lines", [])), 0)]
feedback.append(f"missed {change.get('path', '')}:{span[0]} {change.get('function', '')} family {change.get('family', '')}") feedback.append(f"missed {change.get('path', '')}:{span[0]} {change.get('function', '')} family {change.get('family', '')}")
off_diff = [f"{f.get('path', '')}:{f.get('line', 0)} family {f.get('family', '')}" off_diff = [
for f in findings if not _on_diff(change, f, tolerance)] f"{f.get('path', '')}:{f.get('line', 0)} family {f.get('family', '')}" for f in findings if not _on_diff(change, f, tolerance)
]
if off_diff: if off_diff:
feedback.append("off-diff suspicions: " + ", ".join(off_diff)) feedback.append("off-diff suspicions: " + ", ".join(off_diff))
return Outcome(hit, len(findings), on_diff, "; ".join(feedback)) return Outcome(hit, len(findings), on_diff, "; ".join(feedback))
+83 -30
View File
@@ -98,9 +98,18 @@ class Context:
def hunks(self) -> dict[str, list[list[int]]]: def hunks(self) -> dict[str, list[list[int]]]:
self.verify_head() self.verify_head()
# Exclude private files before git opens the patch; only hunk coordinates become features. # Exclude private files before git opens the patch; only hunk coordinates become features.
patch = _git(self.root, "diff", "--no-ext-diff", "--no-textconv", "-U0", patch = _git(
f"{self.change['base']}..{self.change['head']}", "--", ".", self.root,
":(glob,exclude)**/.env*", ":(exclude).env*") "diff",
"--no-ext-diff",
"--no-textconv",
"-U0",
f"{self.change['base']}..{self.change['head']}",
"--",
".",
":(glob,exclude)**/.env*",
":(exclude).env*",
)
self.diff_bytes = len(patch.encode("utf-8")) self.diff_bytes = len(patch.encode("utf-8"))
result: dict[str, list[list[int]]] = {} result: dict[str, list[list[int]]] = {}
path = None path = None
@@ -154,8 +163,14 @@ def _producer(name: str, compute: Compute) -> Producer:
bytes_read = ctx.diff_bytes bytes_read = ctx.diff_bytes
elif name == "taxonomy" and reason is None: elif name == "taxonomy" and reason is None:
bytes_read = len(DEFAULT_FAMILIES_PATH.read_bytes()) bytes_read = len(DEFAULT_FAMILIES_PATH.read_bytes())
result.update({f"provenance.{name}.ok": reason is None, f"provenance.{name}.reason": reason, result.update(
f"provenance.{name}.version": "1", f"provenance.{name}.bytes_read": bytes_read}) {
f"provenance.{name}.ok": reason is None,
f"provenance.{name}.reason": reason,
f"provenance.{name}.version": "1",
f"provenance.{name}.bytes_read": bytes_read,
}
)
return result return result
return Producer(name, "1", columns + provenance, guarded) return Producer(name, "1", columns + provenance, guarded)
@@ -175,10 +190,13 @@ def _source(root: Path, change: Change, path: str, line: int, ctx: Context) -> d
# Select the occurrence at this line when several methods share a name. # Select the occurrence at this line when several methods share a name.
spans = [r for r in ctx.ranges(path) if r.name == facts.function and r.start <= line <= r.end] spans = [r for r in ctx.ranges(path) if r.name == facts.function and r.start <= line <= r.end]
span = min(spans, key=lambda r: r.end - r.start) if spans else None span = min(spans, key=lambda r: r.end - r.start) if spans else None
return {"function": facts.function, "span": [span.start, span.end] if span else None, return {
"function": facts.function,
"span": [span.start, span.end] if span else None,
"callers": [list(caller) for caller in facts.callers], "callers": [list(caller) for caller in facts.callers],
"entry_points": [{"path": entry.path, "line": entry.line, "kind": entry.kind} for entry in facts.entry_points], "entry_points": [{"path": entry.path, "line": entry.line, "kind": entry.kind} for entry in facts.entry_points],
"language": facts.language} "language": facts.language,
}
def _roles(function: Any, facts: Any) -> dict[str, str]: def _roles(function: Any, facts: Any) -> dict[str, str]:
@@ -192,8 +210,11 @@ def _roles(function: Any, facts: Any) -> dict[str, str]:
for arguments in call.arg_names: for arguments in call.arg_names:
names.update(arguments) names.update(arguments)
roles = {} roles = {}
for role, entries, attribute in (("source", facts.sources, "patterns"), ("sink", facts.sinks, "calls"), for role, entries, attribute in (
("sanitizer", facts.sanitizers, "calls")): ("source", facts.sources, "patterns"),
("sink", facts.sinks, "calls"),
("sanitizer", facts.sanitizers, "calls"),
):
for fact in entries: for fact in entries:
for pattern in getattr(fact, attribute): for pattern in getattr(fact, attribute):
token = pattern.rstrip("(") token = pattern.rstrip("(")
@@ -211,13 +232,29 @@ def _obligations(path: str, ctx: Context) -> list[dict[str, object]]:
if not scoped.operations and not scoped.dischargers: if not scoped.operations and not scoped.dischargers:
raise _Absent("obligations_language_unsupported") raise _Absent("obligations_language_unsupported")
dominance = OrderDominance({path: text}) dominance = OrderDominance({path: text})
result = check_obligations(irs={path: (ir, text)}, entries=ctx.entry_points, facts=facts, flow_facts=flow, result = check_obligations(
policy=None, paths=(), dominance=dominance, store_shapes=(), min_siblings=3) irs={path: (ir, text)},
entries=ctx.entry_points,
facts=facts,
flow_facts=flow,
policy=None,
paths=(),
dominance=dominance,
store_shapes=(),
min_siblings=3,
)
# No finding does not imply discharge: the checker also gates on entry-point reachability. # No finding does not imply discharge: the checker also gates on entry-point reachability.
return [{"operation": operation.kind, "line": operation.line, return [
"discharged": any(valid_witness(witness) and covers(witness, operation) and {
dominance.dominates(witness, operation, None) for witness in result.discharges)} "operation": operation.kind,
for operation in find_operations(ir, scoped, text=text)] "line": operation.line,
"discharged": any(
valid_witness(witness) and covers(witness, operation) and dominance.dominates(witness, operation, None)
for witness in result.discharges
),
}
for operation in find_operations(ir, scoped, text=text)
]
def _semantic(root: Path, change: Change, path: str, line: int, ctx: Context) -> dict[str, object]: def _semantic(root: Path, change: Change, path: str, line: int, ctx: Context) -> dict[str, object]:
@@ -228,20 +265,21 @@ def _semantic(root: Path, change: Change, path: str, line: int, ctx: Context) ->
if not functions: if not functions:
raise _Absent("function_unavailable") raise _Absent("function_unavailable")
function = min(functions, key=lambda fn: fn.end_line - fn.start_line) function = min(functions, key=lambda fn: fn.end_line - fn.start_line)
return {"roles": _roles(function, load_facts().for_language(ir.language)), return {
"def_use": {"binds": [{"line": b.line, "name": b.name, "names": list(b.names), "call_name": b.call_name} "roles": _roles(function, load_facts().for_language(ir.language)),
for b in function.binds], "def_use": {
"calls": [{"line": c.line, "name": c.name, "names": [list(names) for names in c.arg_names]} "binds": [{"line": b.line, "name": b.name, "names": list(b.names), "call_name": b.call_name} for b in function.binds],
for c in function.calls]}, "calls": [{"line": c.line, "name": c.name, "names": [list(names) for names in c.arg_names]} for c in function.calls],
"obligations": _obligations(path, ctx)} },
"obligations": _obligations(path, ctx),
}
def _taxonomy(root: Path, change: Change, path: str, line: int, ctx: Context) -> dict[str, object]: def _taxonomy(root: Path, change: Change, path: str, line: int, ctx: Context) -> dict[str, object]:
return {"families": [{"id": f.id, "definition": " ".join(f.description.split())} for f in load_families().families]} return {"families": [{"id": f.id, "definition": " ".join(f.description.split())} for f in load_families().families]}
PRODUCERS = (_producer("diff", _diff), _producer("source", _source), PRODUCERS = (_producer("diff", _diff), _producer("source", _source), _producer("semantic", _semantic), _producer("taxonomy", _taxonomy))
_producer("semantic", _semantic), _producer("taxonomy", _taxonomy))
def _candidates(root: Path, change: Change, ctx: Context) -> list[dict[str, Any]]: def _candidates(root: Path, change: Change, ctx: Context) -> list[dict[str, Any]]:
@@ -259,20 +297,35 @@ def _candidates(root: Path, change: Change, ctx: Context) -> list[dict[str, Any]
line = next((start for start, _ in file.get("head_lines", [])), 1) line = next((start for start, _ in file.get("head_lines", [])), 1)
selected.add((path, max(1, line))) selected.add((path, max(1, line)))
continue continue
touched = [r for r in ranges if r.kind == "function" and touched = [
any(r.start <= end and start <= r.end for start, end in file.get("head_lines", []))] r
for r in ranges
if r.kind == "function" and any(r.start <= end and start <= r.end for start, end in file.get("head_lines", []))
]
selected.update((path, r.start) for r in touched) selected.update((path, r.start) for r in touched)
if not touched and file.get("head_lines") and ranges: if not touched and file.get("head_lines") and ranges:
selected.add((path, max(1, file["head_lines"][0][0]))) selected.add((path, max(1, file["head_lines"][0][0])))
if change.get("kind") == "vulnerability" and path == change["path"]: if change.get("kind") == "vulnerability" and path == change["path"]:
labelled = [r for r in ranges if r.name == change["function"] and labelled = [
r.start <= change["function_lines"][1] and change["function_lines"][0] <= r.end] r
for r in ranges
if r.name == change["function"] and r.start <= change["function_lines"][1] and change["function_lines"][0] <= r.end
]
selected.update((path, r.start) for r in labelled) selected.update((path, r.start) for r in labelled)
if not labelled: if not labelled:
selected.add((path, change["function_lines"][0])) selected.add((path, change["function_lines"][0]))
return [{"change_id": change.get("id", change.get("case_id")), "repo": change.get("repo", change.get("repository")), return [
"head": change["head"], "path": path, "line": line, "kind": change["kind"], "fold": change.get("fold")} {
for path, line in sorted(selected)] "change_id": change.get("id", change.get("case_id")),
"repo": change.get("repo", change.get("repository")),
"head": change["head"],
"path": path,
"line": line,
"kind": change["kind"],
"fold": change.get("fold"),
}
for path, line in sorted(selected)
]
def candidate_rows(root: Path, change: Change) -> list[dict[str, Any]]: def candidate_rows(root: Path, change: Change) -> list[dict[str, Any]]:
+38 -7
View File
@@ -117,10 +117,14 @@ def shown_lines(inputs: Mapping[str, str]) -> dict[str, set[int]]:
return shown return shown
def check(findings: Iterable[Finding | Mapping[str, Any]], inputs: Mapping[str, str]) -> list[dict[str, Any]]: def check(
findings: Iterable[Finding | Mapping[str, Any]], inputs: Mapping[str, str], seen: Mapping[str, set[int]] | None = None
) -> list[dict[str, Any]]:
"""Apply the answer rules: a family outside the closed list, or a cited line the model was not shown, scores """Apply the answer rules: a family outside the closed list, or a cited line the model was not shown, scores
against the program by becoming `unknown` with a reason. Nothing is dropped, so the metric sees it.""" against the program by becoming `unknown` with a reason. Nothing is dropped, so the metric sees it."""
shown = shown_lines(inputs) shown = shown_lines(inputs)
for path, lines in (seen or {}).items():
shown.setdefault(path, set()).update(lines)
allowed = {family for family in FAMILY_IDS if family != "unknown"} allowed = {family for family in FAMILY_IDS if family != "unknown"}
out = [] out = []
for item in findings: for item in findings:
@@ -139,22 +143,35 @@ def check(findings: Iterable[Finding | Mapping[str, Any]], inputs: Mapping[str,
return out return out
def repository_tools(root: Path, *, max_chars: int = 4000, max_matches: int = 50) -> list[dspy.Tool]: def repository_tools(
"""The three tools the shipped hunter has, bound to one checkout and clamped to it.""" root: Path, *, max_chars: int = 4000, max_matches: int = 50, seen: dict[str, set[int]] | None = None
) -> list[dspy.Tool]:
"""The three tools the shipped hunter has, bound to one checkout and clamped to it. Every line a tool returns
is recorded in `seen` (path -> line numbers), because a line the model read through a tool was shown to it."""
root = Path(root) root = Path(root)
seen = seen if seen is not None else {}
def note(path: str, lines: Iterable[int]) -> None:
seen.setdefault(path, set()).update(int(line) for line in lines)
def read_file(path: str) -> str: def read_file(path: str) -> str:
"""Read a file of the repository by its relative path, numbered lines.""" """Read a file of the repository by its relative path, numbered lines."""
return hunter_tools.read_file(root, path, max_chars=max_chars) text = hunter_tools.read_file(root, path, max_chars=max_chars)
note(path, range(1, text.count("\n") + 2))
return text
def grep_repo(pattern: str) -> str: def grep_repo(pattern: str) -> str:
"""Search the repository for a regular expression; returns path:line: text, one per line.""" """Search the repository for a regular expression; returns path:line: text, one per line."""
rows = hunter_tools.grep_repo(root, pattern, max_matches=max_matches) rows = hunter_tools.grep_repo(root, pattern, max_matches=max_matches)
for row in rows:
note(str(row.get("path")), [int(row.get("line") or 0)])
return "\n".join(f"{row.get('path')}:{row.get('line')}: {row.get('text', '')}" for row in rows) or "(no matches)" return "\n".join(f"{row.get('path')}:{row.get('line')}: {row.get('text', '')}" for row in rows) or "(no matches)"
def find_refs(symbol: str) -> str: def find_refs(symbol: str) -> str:
"""Find where a symbol is referenced in the repository; returns path:line per reference.""" """Find where a symbol is referenced in the repository; returns path:line per reference."""
rows = hunter_tools.find_refs(root, symbol) rows = hunter_tools.find_refs(root, symbol)
for row in rows:
note(str(row.get("path")), [int(row.get("line") or 0)])
return "\n".join(f"{row.get('path')}:{row.get('line')}" for row in rows) or "(no references)" return "\n".join(f"{row.get('path')}:{row.get('line')}" for row in rows) or "(no references)"
return [dspy.Tool(read_file), dspy.Tool(grep_repo), dspy.Tool(find_refs)] return [dspy.Tool(read_file), dspy.Tool(grep_repo), dspy.Tool(find_refs)]
@@ -166,13 +183,25 @@ class HuntProgram(dspy.Module):
def __init__(self, *, mask: Iterable[str] = (), tools: Sequence[dspy.Tool] = (), max_iters: int = 8) -> None: def __init__(self, *, mask: Iterable[str] = (), tools: Sequence[dspy.Tool] = (), max_iters: int = 8) -> None:
super().__init__() super().__init__()
self.mask = tuple(sorted(set(mask))) self.mask = tuple(sorted(set(mask)))
self.seen: dict[str, set[int]] = {}
signature = signature_for(self.mask) signature = signature_for(self.mask)
self.hunt = dspy.ReAct(signature, tools=list(tools), max_iters=max_iters) if tools else dspy.Predict(signature) self.hunt = dspy.ReAct(signature, tools=list(tools), max_iters=max_iters) if tools else dspy.Predict(signature)
def bind(self, root: Path) -> None:
"""Point the tools at one checkout and start a fresh record of what they show."""
self.seen = {}
if isinstance(self.hunt, dspy.ReAct):
for tool in repository_tools(root, seen=self.seen):
self.hunt.tools[tool.name] = tool
def forward(self, **inputs: str) -> dspy.Prediction: def forward(self, **inputs: str) -> dspy.Prediction:
try:
prediction = self.hunt(**inputs) prediction = self.hunt(**inputs)
findings = check(getattr(prediction, "findings", []) or [], inputs) except dspy.utils.exceptions.AdapterParseError:
return dspy.Prediction(findings=findings, raw=getattr(prediction, "findings", [])) # An answer the program could not shape is an empty answer that scores against it, not a broken run.
return dspy.Prediction(findings=[], raw=[], parse_failed=True)
findings = check(getattr(prediction, "findings", []) or [], inputs, self.seen)
return dspy.Prediction(findings=findings, raw=getattr(prediction, "findings", []), parse_failed=False)
def program_id(program: dspy.Module, *, mask: Iterable[str] = ()) -> str: def program_id(program: dspy.Module, *, mask: Iterable[str] = ()) -> str:
@@ -193,7 +222,9 @@ def build_lm(
if not key: if not key:
raise RuntimeError("DEEPSEEK_API_KEY is not set") raise RuntimeError("DEEPSEEK_API_KEY is not set")
if cache_dir is not None: if cache_dir is not None:
os.environ["DSPY_CACHEDIR"] = str(cache_dir) # A run's own response cache, kept with its records so the run can be replayed at no cost.
Path(cache_dir).mkdir(parents=True, exist_ok=True)
dspy.configure_cache(enable_disk_cache=True, enable_memory_cache=True, disk_cache_dir=str(cache_dir))
try: try:
import litellm import litellm
+3 -4
View File
@@ -138,11 +138,10 @@ def hunt_change(
record["rows"] = len(rows) record["rows"] = len(rows)
inputs = render(change, rows, texts, mask=mask) inputs = render(change, rows, texts, mask=mask)
record["inputs_present"] = sorted(inputs) record["inputs_present"] = sorted(inputs)
if tools_for is not None: if tools_for is not None and hasattr(program, "bind"):
program.hunt.tools = ( program.bind(root)
{tool.name: tool for tool in tools_for(root)} if hasattr(program.hunt, "tools") else program.hunt.tools
)
prediction = program(**inputs) prediction = program(**inputs)
record["parse_failed"] = bool(getattr(prediction, "parse_failed", False))
for finding in prediction.findings: for finding in prediction.findings:
if finding.get("path") in texts and not finding.get("function") and (span := _function_at(root, finding)): if finding.get("path") in texts and not finding.get("function") and (span := _function_at(root, finding)):
finding["function"] = span finding["function"] = span
+1 -2
View File
@@ -87,8 +87,7 @@ class FeatureTable:
if unknown: if unknown:
raise ValueError("mask names an unknown producer or feature column") raise ValueError("mask names an unknown producer or feature column")
columns = {c for c in FEATURE_COLUMNS if c in names or c.split(".")[1] in names} columns = {c for c in FEATURE_COLUMNS if c in names or c.split(".")[1] in names}
return FeatureTable(({k: v for k, v in row.items() if k not in columns} for row in self.rows), return FeatureTable(({k: v for k, v in row.items() if k not in columns} for row in self.rows), masked=(*self.masked, *names))
masked=(*self.masked, *names))
def feature_columns(self) -> list[str]: def feature_columns(self) -> list[str]:
return sorted({column for row in self.rows for column in row if column.startswith("feature.")}) return sorted({column for row in self.rows for column in row if column.startswith("feature.")})
+20
View File
@@ -95,3 +95,23 @@ def test_build_lm_needs_a_key_from_the_environment(monkeypatch):
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
with pytest.raises(RuntimeError, match="DEEPSEEK_API_KEY"): with pytest.raises(RuntimeError, match="DEEPSEEK_API_KEY"):
hp.build_lm() hp.build_lm()
def test_lines_read_through_a_tool_count_as_shown(tmp_path):
(tmp_path / "app.py").write_text("a = 1\nb = 2\nc = eval(input())\n")
seen: dict[str, set[int]] = {}
tools = {tool.name: tool for tool in hp.repository_tools(tmp_path, seen=seen)}
tools["read_file"](path="app.py")
assert seen["app.py"] >= {1, 2, 3}
inputs = hp.render_rows(CHANGE, ROWS, TEXT)
finding = {"path": "app.py", "line": 3, "family": "injection", "confidence": 0.9, "rationale": "eval", "cited_lines": [3]}
assert hp.check([finding], inputs)[0]["family"] == "unknown" # line 3 is outside the rendered span 5-7
assert hp.check([finding], inputs, seen)[0]["family"] == "injection" # but the model read it through a tool
def test_an_unparseable_answer_is_an_empty_flagged_answer():
lm = DummyLM([{"findings": "not json at all"}] * 4)
dspy.configure(lm=lm, adapter=dspy.ChatAdapter())
program = hp.HuntProgram()
prediction = program(**hp.render_rows(CHANGE, ROWS, TEXT))
assert prediction.findings == [] and prediction.parse_failed is True