fix(llm): gate the reasoning-JSON winner on sanitized content, not raw value (#2882)

A reasoning sketch that lists ids as bare strings (`{"nodes": ["A", "B"]}`) has truthy
arrays but no node/edge objects. The winner gate tested the raw parsed value, so the
sketch won and then sanitized to empty, shadowing the real fragment that followed and
re-triggering the #2880 hollow-response bisection. Sanitize before the emptiness gate so
such a sketch is demoted to the empty-fragment tier. Also document the keyed-candidate
cap crowd-out as a known gap.
This commit is contained in:
safishamsi
2026-08-20 15:44:56 +01:00
parent bce2c9b3d3
commit e8bef863f0
2 changed files with 44 additions and 3 deletions
+17 -3
View File
@@ -1036,6 +1036,12 @@ def _json_object_candidates(text: str) -> list[int]:
model that narrates before answering — "Here's a thinking process: 1.
**Analyze User Input:** …" with braces in the narration — does not have its
real answer masked by the first brace in the text (#2882).
Known limit: each bucket is capped at ``_MAX_OBJECT_CANDIDATES`` from the
front, so a reply with more than that many *keyed* braces before the real
answer (a very verbose model that emits a ``{"nodes": …}`` sketch per file)
could drop the true answer's brace. This needs an implausibly chatty
preamble and is left as a known gap rather than complicating the scan.
"""
preferred: list[int] = []
rest: list[int] = []
@@ -1130,10 +1136,18 @@ def _parse_llm_json(raw: str) -> dict:
if not isinstance(parsed, dict):
continue
if any(k in parsed for k in _FRAGMENT_KEYS):
if any(parsed.get(k) for k in _FRAGMENT_KEYS):
return _sanitize_fragment(parsed)
# Gate on the SANITIZED content, not the raw value. A reasoning
# sketch commonly lists ids as bare strings — `{"nodes": ["A", "B"]}`
# — whose arrays are truthy but hold no edge/node objects. Testing
# the raw value would let that sketch win and then sanitize down to
# empty, shadowing the real answer that follows and re-triggering the
# #2880 hollow-response bisection. Sanitizing first demotes it to the
# empty-fragment tier so the genuine fragment below still wins.
cand = _sanitize_fragment(parsed)
if any(cand.get(k) for k in _FRAGMENT_KEYS):
return cand
if empty_fragment is None:
empty_fragment = parsed
empty_fragment = cand
elif fallback is None:
fallback = parsed
+27
View File
@@ -136,3 +136,30 @@ def test_empty_fragment_outranks_a_non_fragment_object():
result = llm._parse_llm_json(raw)
assert "description" not in result
assert result["nodes"] == []
def test_string_list_sketch_does_not_shadow_the_real_answer():
"""A reasoning sketch that lists ids as bare strings (`{"nodes": ["A","B"]}`)
has truthy arrays but no node/edge objects. Gating on the raw value would let
it win and then sanitize to empty, shadowing the real fragment that follows
and re-triggering the #2880 hollow-response bisection. The sanitized-content
gate must demote the sketch and return the real answer."""
raw = (
"Planning the graph. First a rough sketch of what I will emit:\n"
'{"nodes": ["FileA", "FileB"], "edges": ["FileA->FileB"]}\n\n'
"Now the actual fragment:\n"
'{"nodes": [{"id": "a", "label": "A"}], '
'"edges": [{"source": "a", "target": "b"}], "hyperedges": []}'
)
result = llm._parse_llm_json(raw)
assert result["nodes"] == [{"id": "a", "label": "A"}], result
assert result["edges"] == [{"source": "a", "target": "b"}]
def test_string_list_sketch_alone_sanitizes_to_empty():
"""With no real answer following, a string-list sketch must not masquerade as
content: its non-dict entries are stripped, leaving an empty fragment (which
then reads as hollow downstream)."""
result = llm._parse_llm_json('{"nodes": ["FileA", "FileB"], "edges": ["x"]}')
assert result["nodes"] == []
assert result["edges"] == []