Skip to content

Commit afeea0d

Browse files
rysweetCopilot
andcommitted
fix(goal_curation): progress-evidence gate fails closed on semantic verdict parse-miss (#2569)
The progress-evidence gate previously FAILED OPEN (Accept) whenever the LLM/recipe reviewer produced no recognizable accept/reject verdict — even on a successful, non-empty run — letting hallucinated "0%->100% with no verdict" bumps land as false "done" states. Adopt the merge judge's infra-vs-semantic split across both live tiers (RecipeProgressChecker primary + LlmReviewerProgressChecker direct-LLM fallback): - INFRA failure (transport error / spawn failure / non-zero exit / output that strips to empty) -> keep fail-OPEN, so goals aren't blocked on infra hiccups. - SEMANTIC parse-miss (successful, non-empty response with no verdict, or an unknown verdict string) -> fail-CLOSED (Reject). Reject only keeps the prior percent + logs a hallucination alert; it does not stall the goal. Also parse the structured {"verdict": ...} JSON first in the recipe tier (was a naive substring scan that wrongly Rejected an `accept` whose rationale mentioned "reject", and could Accept "unacceptable"), reusing the direct-LLM tier's tolerant parse_reviewer_response — matching the merge judge. The merge path reported in #2569 was already fail-closed on main (#2486/#2490/#2504, merged before #2569 was filed against released v0.22.0); this adds a regression test pinning the reporter's exact SUCCESS-with-no-verdict banners (30s AND 102s) -> Verdict::Unclear, and fixes the analogous fail-open in the progress path. Docs (progress-evidence-api, text-parsing-wire-formats §2a, progress-evidence-gating, text-based-brain-protocol) updated to the new policy and corrected to reflect that progress_reviewer.rs is the live direct-LLM fallback tier (not deleted). Adds a gadugi outside-in scenario plus parser/decision regression tests. Closes #2569 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e5764c6 commit afeea0d

10 files changed

Lines changed: 581 additions & 98 deletions

docs/concepts/progress-evidence-gating.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,15 @@ The reviewer **rejects** when the claimed percent looks hallucinated:
7272
- A 100% claim with no shipped artifact in the plan or WIP.
7373
- A claim that contradicts the plan (e.g. "blocked on review" but claims 90%).
7474

75-
On **LLM infrastructure failure** (transport error, parse error, empty
76-
response), the reviewer **accepts** with a diagnostic rationale. The gate's
75+
On a **genuine infrastructure gap** (LLM transport error, or an *empty*
76+
response), the reviewer **accepts** with a diagnostic rationale — the gate's
7777
purpose is to catch hallucinated jumps, not to block goals on LLM
78-
availability.
78+
availability. But a **semantic parse-miss** — a *successful, non-empty*
79+
response that carries no recognizable `accept`/`reject` verdict (unparseable
80+
output, or an unknown verdict string) — is treated as evidence-absence and
81+
**rejected**, so a hallucinated "no verdict" bump cannot land. (`Reject`
82+
keeps the prior percent and logs a hallucination alert; it does not stall the
83+
goal.)
7984

8085
> **History:** Prior to PR #2007/#2011, evidence was gathered via `git log`
8186
> and `gh pr list` shellouts (the `DefaultProgressEvidenceChecker`). Per user
@@ -91,9 +96,11 @@ time the brain makes a progress claim. An LLM reviewer can assess whether a
9196
claimed delta is *proportional to the described plan* — a judgment call that
9297
a fixed-rule state machine cannot make.
9398

94-
The trade-off is that the reviewer depends on LLM availability. The fail-open
95-
design means an LLM outage degrades to pre-#1967 behavior (all claims
96-
accepted) rather than blocking all goals.
99+
The trade-off is that the reviewer depends on LLM availability. The
100+
fail-open-on-**infra** design means an LLM outage degrades to pre-#1967
101+
behavior (claims accepted) rather than blocking all goals — while a
102+
successful run that produces no parseable verdict still fails **closed**, so
103+
the outage tolerance never becomes a blanket "accept everything".
97104

98105
### What does *not* count as evidence
99106

docs/concepts/text-based-brain-protocol.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ sites in the codebase parsed JSON from LLM or recipe output:
4141
| `decide.rs` | `DecideJudgment` from LLM response | `find('{')..rfind('}')` extraction — boundary attacks, partial objects |
4242
| `orient.rs` | `OrientJudgment` from LLM response | Same extraction pattern |
4343
| `rustyclawd.rs` | `EngineerLifecycleDecision` from LLM response | JSON fallback path after DECISION marker — two parsers, double failure surface |
44-
| `progress_reviewer.rs` | `ReviewerResponse` from LLM response | Dead code — replaced by recipe_progress_checker |
44+
| `progress_reviewer.rs` | `ReviewerResponse` from LLM response | Retained as the live direct-LLM fallback tier (used when `recipe-runner-rs` is unavailable); its tolerant `parse_reviewer_response` is now also reused by `recipe_progress_checker.rs` for JSON-first verdict extraction |
4545
| `merge_judge.rs` | `JudgeOutcome` from LLM response | Dead code — replaced by recipe_merge_judge |
4646

4747
Every one of these was brittle and unnecessary:
@@ -227,9 +227,12 @@ Several modules were deleted or cleaned up as part of the text-migration changes
227227
`parse_orient_from_text`, `parse_lifecycle_from_text`) rewritten as trivial
228228
first-word/first-float extractors.
229229

230-
The daemon wiring in `operator_commands_ooda/daemon/mod.rs` was updated to
231-
match: the `LlmReviewerProgressChecker` fallback arm was removed. The chain
232-
is now `RecipeProgressChecker``NoopProgressEvidenceChecker`.
230+
The daemon wiring in `operator_commands_ooda/daemon/mod.rs` resolves the
231+
progress-evidence gate in three tiers: `RecipeProgressChecker` (recipe-runner
232+
backed, primary) → `LlmReviewerProgressChecker` (direct-LLM fallback, in
233+
`progress_reviewer.rs`) → `NoopProgressEvidenceChecker`. Both live LLM tiers
234+
share the same infra-fail-open / semantic-parse-miss-fail-closed verdict policy
235+
(see [Progress-evidence API](../reference/progress-evidence-api.md)).
233236

234237
## Why this matters for engineer spawning
235238

docs/reference/progress-evidence-api.md

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ sibling `simard::goal_curation::operations` module.
1313
> **History:** Prior to PR #2007, the production implementation was
1414
> `DefaultProgressEvidenceChecker`, which shelled out to `git log` and
1515
> `gh pr list`. That struct and its helper traits (`GitRunner`, `GhRunner`)
16-
> were removed in PR #2011. The gate then delegated to `LlmReviewerProgressChecker`
17-
> (JSON-based LLM response parsing), which was replaced by
18-
> `RecipeProgressChecker` with text-based keyword verdict parsing in #1980.
19-
> The `LlmReviewerProgressChecker` and its module (`progress_reviewer.rs`)
20-
> have been deleted — they were dead code once the recipe-based checker
21-
> became the primary tier.
16+
> were removed in PR #2011. `LlmReviewerProgressChecker`
17+
> (`progress_reviewer.rs`, JSON-based LLM response parsing) was superseded as
18+
> the *primary* tier by `RecipeProgressChecker` (text-based keyword verdict
19+
> parsing) in #1980, but is **retained as the direct-LLM fallback tier**: at
20+
> daemon boot the gate resolves `RecipeProgressChecker` (recipe-runner-rs
21+
> backed) first, then `LlmReviewerProgressChecker` (direct LLM), then
22+
> `NoopProgressEvidenceChecker`. Both live tiers share the same
23+
> infra-fail-open / semantic-parse-miss-fail-closed policy.
2224
2325
All public symbols below are re-exported from `simard::goal_curation`.
2426

@@ -67,9 +69,13 @@ can be installed on `OodaBridges` and shared across all OODA actions.
6769
a few times per OODA cycle, only on progress-increase attempts.
6870
- `check` MUST return `Accept` when evidence supports the claim and
6971
`Reject` otherwise. The production `RecipeProgressChecker`
70-
accepts on infrastructure failure (recipe not found, runner not
71-
installed, non-zero exit) — the gate's purpose is to catch
72-
hallucinated jumps, not to block goals on recipe availability. See
72+
splits **infrastructure failure** from a **semantic parse-miss**:
73+
it **accepts** on infra failure (recipe not found, runner not
74+
installed, non-zero exit, or *empty* output) — the gate's purpose is
75+
to catch hallucinated jumps, not to block goals on recipe availability
76+
— but it **rejects** on a semantic parse-miss (a *successful,
77+
non-empty* run whose output carries no `accept`/`reject` verdict), so
78+
a hallucinated "no verdict" bump cannot land. See
7379
[`SIMARD_PROGRESS_EVIDENCE`](../operations/progress-evidence-kill-switch.md)
7480
for the operator escape hatch.
7581
- The `since` argument is provided by the caller; the trait does not
@@ -88,18 +94,22 @@ can be installed on `OodaBridges` and shared across all OODA actions.
8894

8995
The production implementation (`src/goal_curation/recipe_progress_checker.rs`)
9096
invokes a recipe that runs an LLM agent to review goal progress. The recipe
91-
stdout is parsed using the keyword verdict protocol — the checker scans for
92-
`"accept"` or `"reject"` keywords in the text output. See
97+
stdout is parsed **structured JSON verdict first** (`{"verdict":
98+
"accept"|"reject", …}`, via the shared `parse_reviewer_response` extractor),
99+
falling back to an `"accept"`/`"reject"` keyword scan for prose. Parsing the
100+
JSON object first avoids substring false-positives (e.g. an `accept` verdict
101+
whose rationale mentions "reject"). See
93102
[text-parsing wire formats § progress checker](../reference/text-parsing-wire-formats.md#2a-progress-checker-recipe_progress_checkerrs)
94103
for the full grammar.
95104

96105
| Condition | Result | `reason` template |
97106
|---|---|---|
98-
| `new_percent <= old_percent` | `Accept` (auto, no recipe call) | `"progress-assessment-reviewer: downward / no-change (<old> -> <new>) auto-accepted"` |
99-
| Recipe stdout contains `"accept"` keyword | `Accept` | `"progress-assessment-reviewer: accept — <surrounding text as rationale>"` |
100-
| Recipe stdout contains `"reject"` keyword | `Reject` | `"progress-assessment-reviewer: reject — <surrounding text as rationale>"` |
101-
| No keyword found in recipe stdout | `Accept` (fail-open) | `"progress-assessment-reviewer: no verdict keyword found; accepting to avoid blocking goal"` |
102-
| Recipe invocation failure | `Accept` (fail-open) | `"progress-assessment-reviewer: recipe failed (<error>); accepting to avoid blocking goal"` |
107+
| `new_percent <= old_percent` | `Accept` (auto, no recipe call) | `"recipe-progress-checker: downward / no-change (<old> -> <new>) auto-accepted"` |
108+
| Recipe stdout contains `"accept"` keyword | `Accept` | `"recipe-progress-checker: accept — <surrounding text as rationale>"` |
109+
| Recipe stdout contains `"reject"` keyword | `Reject` | `"recipe-progress-checker: reject — <surrounding text as rationale>"` |
110+
| No keyword in **non-empty** recipe stdout | `Reject` (fail-closed: semantic parse-miss) | `"recipe-progress-checker: no verdict keyword in non-empty recipe output; rejecting unverified progress"` |
111+
| **Empty** recipe stdout on a successful run | `Accept` (fail-open: infra gap) | `"recipe-progress-checker: empty recipe output; accepting to avoid blocking goal on infra"` |
112+
| Recipe invocation failure (spawn / non-zero exit) | `Accept` (fail-open: infra) | `"recipe-progress-checker: recipe … accepting to avoid blocking goal …"` |
103113

104114
The recipe template lives at
105115
`prompt_assets/simard/recipes/progress-assessment.yaml`. The prompt within
@@ -123,18 +133,23 @@ impl RecipeProgressChecker {
123133
```
124134

125135
The production checker. Invokes the progress-assessment recipe via
126-
`recipe-runner-rs` and parses the verdict from the text output using keyword
127-
scanning. No JSON parsing. No intermediate `ReviewerResponse` type.
136+
`recipe-runner-rs` and resolves the verdict **structured JSON first**
137+
(`{"verdict": …}`, via the shared `parse_reviewer_response` extractor), with an
138+
`"accept"`/`"reject"` keyword scan as a prose fallback.
128139

129140
The checker:
130141

131142
1. Auto-accepts downward/no-change moves without a recipe call.
132143
2. Resolves the recipe YAML (hot-reload path, then in-tree).
133144
3. Invokes `recipe-runner-rs` with goal context as variables.
134-
4. Scans stdout for `"accept"` or `"reject"` (case-insensitive).
135-
5. Maps the keyword to `EvidenceDecision`, using surrounding text as rationale.
136-
6. Fails open on any infrastructure error (recipe not found, runner not
137-
installed, non-zero exit).
145+
4. Parses the structured `{"verdict": …}` JSON first; on a JSON miss, scans
146+
stdout for `"accept"` or `"reject"` (case-insensitive).
147+
5. Maps the verdict to `EvidenceDecision`, using the rationale/surrounding text.
148+
6. Fails **open** on an infrastructure error (recipe not found, runner not
149+
installed, non-zero exit, or empty output) — but fails **closed**
150+
(`Reject`) on a semantic parse-miss: a successful, non-empty run whose
151+
output carries no `accept`/`reject` verdict, so a hallucinated "no
152+
verdict" progress bump is not silently accepted.
138153

139154
---
140155

docs/reference/text-parsing-wire-formats.md

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -377,16 +377,35 @@ The parser scans the entire stdout for a verdict keyword. Everything else
377377

378378
### 2a. Progress checker (`recipe_progress_checker.rs`)
379379

380-
**Keywords:**
380+
**Resolution order (structured verdict first, then keyword prose):** the
381+
recipe's prompt contract emits a JSON object
382+
`{"verdict": "accept"|"reject", "rationale": "..."}`, so the parser tries the
383+
**structured JSON verdict first** (via the shared
384+
`progress_reviewer::parse_reviewer_response` extractor — as-is / fenced /
385+
brace-balanced / outermost), mirroring the merge judge's
386+
`parse_judge_response`-first path. Parsing the object first avoids substring
387+
false-positives — e.g. an `accept` verdict whose *rationale* mentions "reject"
388+
(a naive `contains("reject")` scan would wrongly flip it to Reject), or
389+
"unacceptable" containing the `accept` substring. A valid JSON object with an
390+
*unknown* verdict string is a semantic parse-miss → `Reject` (fail-closed).
391+
Only when no verdict JSON parses does it fall back to the plain keyword scan.
392+
393+
**Keywords (prose fallback):**
381394

382395
| Keyword | Maps to | Priority |
383396
|---------|---------|----------|
384397
| `reject` | `EvidenceDecision::Reject` | Checked first |
385398
| `accept` | `EvidenceDecision::Accept` | Checked second |
386399

387-
**Default (no keyword):** `EvidenceDecision::Accept` — fail-open. The gate's
388-
purpose is to catch hallucinated jumps, not to block goals on keyword-detection
389-
availability.
400+
**Default (no verdict):** splits on whether the run produced output. A
401+
**successful, non-empty** stdout with no `accept`/`reject` verdict is a
402+
*semantic parse-miss*`EvidenceDecision::Reject` (**fail-closed**): the
403+
reviewer ran but gave no verdict, so the unverified progress bump is refused
404+
rather than silently accepted. Output that strips to **empty** is treated as an
405+
*infra gap*`EvidenceDecision::Accept` (**fail-open**), so a lost-output
406+
hiccup does not block the goal. Genuine invocation failures (spawn / non-zero
407+
exit) likewise fail open. The gate's purpose is to catch hallucinated jumps
408+
without blocking goals on infrastructure availability.
390409

391410
**Example recipe stdout:**
392411

@@ -399,23 +418,18 @@ WIP summary references new test files in tests/integration/.
399418
400419
The 8-point increase is proportional to the described work.
401420
402-
accept
421+
{"verdict": "accept", "rationale": "8pt delta matches the described test work"}
403422
```
404423

405-
Parser result: `EvidenceDecision::Accept { reason: "After reviewing the plan and progress claims: ..." }`
406-
407-
**Changes from prior implementation:**
424+
Parser result: `EvidenceDecision::Accept { reason: "recipe-progress-checker: accept — …" }`
408425

409-
- `parse_reviewer_response` (which parsed JSON `ReviewerResponse`) is removed.
410-
- `RecipeProgressChecker::check()` now calls `parse_verdict_from_text()`
411-
directly and returns `EvidenceDecision` without the intermediate
412-
`ReviewerResponse` type.
413-
- The `progress_reviewer.rs` module (containing `LlmReviewerProgressChecker`)
414-
is deleted. It was dead code — the daemon wiring already used
415-
`RecipeProgressChecker` as the primary tier.
416-
- The daemon fallback chain is now: `RecipeProgressChecker`
417-
`NoopProgressEvidenceChecker` (was: `RecipeProgressChecker`
418-
`LlmReviewerProgressChecker``NoopProgressEvidenceChecker`).
426+
**Tier relationship:** `RecipeProgressChecker` is the primary tier;
427+
`progress_reviewer.rs`'s `LlmReviewerProgressChecker` is the live **direct-LLM
428+
fallback** tier (used when `recipe-runner-rs` is unavailable). The daemon
429+
resolution chain is `RecipeProgressChecker``LlmReviewerProgressChecker`
430+
`NoopProgressEvidenceChecker`. Both live tiers share the same
431+
infra-fail-open / semantic-parse-miss-fail-closed policy and reuse the same
432+
`parse_reviewer_response` verdict extractor.
419433

420434
---
421435

0 commit comments

Comments
 (0)