Skip to content

fix(review): collapse duplicate verdict ids the same way in both readers - #739

Merged
devops-thiago merged 2 commits into
mainfrom
fix/735-duplicate-verdict-coverage
Aug 16, 2026
Merged

fix(review): collapse duplicate verdict ids the same way in both readers#739
devops-thiago merged 2 commits into
mainfrom
fix/735-duplicate-verdict-coverage

Conversation

@devops-thiago

@devops-thiago devops-thiago commented Aug 16, 2026

Copy link
Copy Markdown
Owner

What type of PR is this?

  • 🐛 Bug fix
  • ✨ Feature
  • 📝 Documentation
  • 🔧 Refactor
  • 🚀 Performance
  • ✅ Test
  • 🔒 Security
  • 📦 Dependency update
  • 🏗️ CI/CD

Description

#719 routed apply() and the coverage count through one decision normalizer so that "what the review does with a verdict and what it claims it verified cannot drift". The normalizer was shared; the duplicate-id resolution was not.

  • apply() collapsed duplicates firstputIfAbsent, so the first element for an id wins — and read the label off the survivor.
  • candidatesCovered filtered by label first and de-duplicated the surviving ids afterwards.

For an id carrying an undecidable element followed by a decidable one the two therefore read different verdicts for the same id. apply() lands in its fail-open default and the finding posts exactly as the reviewer raised it; the count calls that candidate screened. With every other candidate decided that is VerificationCoverage(N, N)FULLdisclosed() == false: no banner, no coverage clause, no check-run brief. And because nothing was rejected or downgraded on that path, apply() short-circuits before the N kept, M downgraded, K rejected line, so the operator log says nothing either. A published set containing a finding the audit never ruled on, reported as fully screened — the harm #623 exists to prevent and #710 was filed on, reached by a narrower route and on a complete, well-formed body, not only a cut one.

Two properties worth stating:

  • The drift is one-directional. The reverse ordering (decidable element first) already agreed with apply(). So this only ever over-counted — exclusively toward the dangerous side.
  • Duplicates are not a hypothetical input shape. The putIfAbsent collapse predates this fix precisely because the model emits them; the whole salvage/fail-open stack exists because this body is unreliable model output.

Both readers now resolve duplicates through one collapse (byCandidateId), so the verdict the count reads for an id is exactly the one the audit acted on. Nothing else about the count changes: the id-range filter and ACTED_ON_DECISIONS are untouched, and the fail-open contract is untouched — coverage changes what the review says, never which findings it keeps.

Also in this PR: decisionOf now strips. Its siblings strictRisk/strictConfidence both do value.strip().toLowerCase(...), and the javadoc on ACTED_ON_DECISIONS claims the strictness matches them — it was stricter, so "rejected " was unreadable as a decision while "high " was a readable rating. Both readers take that same value, so this never drove a drift; it cost an over-cautious keep plus an under-count. The asymmetry was unintended, so it is closed here.

Related Issues

Fixes #735

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing

Three tests added to FindingVerificationServiceTest:

  • doesNotCountADuplicateIdWhoseFirstVerdictTheAuditCannotRead — the defect itself: a complete, parseable body with two verdicts for id 1, the first undecidable. Asserts the finding is kept and the coverage is (2, 1) / PARTIAL / disclosed.
  • readsADecisionLabelPaddedWithWhitespace"rejected\n" is acted on and " CONFIRMED " is counted.
  • countsADuplicateIdWhoseFirstVerdictTheAuditActedOn — the control, the same duplicate in the other order. It passes both before and after the change (that is its point: the collapse must not turn the pre-existing agreement into an under-count), so it is not offered as red/green proof.

Red/green proof

The two behavioural tests run against the unfixed FindingVerificationService, verbatim:

[ERROR] Tests run: 3, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 3.107 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest
[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.doesNotCountADuplicateIdWhoseFirstVerdictTheAuditCannotRead -- Time elapsed: 2.984 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <[VerificationCoverage[candidates=2, verified=1]]> but was: <[VerificationCoverage[candidates=2, verified=2]]>
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1199)
	at dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.doesNotCountADuplicateIdWhoseFirstVerdictTheAuditCannotRead(FindingVerificationServiceTest.java:802)

[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.readsADecisionLabelPaddedWithWhitespace -- Time elapsed: 0.012 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <1> but was: <2>
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:569)
	at dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.readsADecisionLabelPaddedWithWhitespace(FindingVerificationServiceTest.java:851)

The first failure is the drift exactly as claimed: the coverage counted 2 of 2 while apply() had ruled on 1. The second is the unstripped label: the padded rejected was not acted on, so both findings survived. With the fix, FindingVerificationServiceTest runs 109 tests, 0 failures.

Gates

Gate Result
spotless:applyclean compile spotbugs:check spotless:check BUILD SUCCESS, BugInstance size is 0
clean test Tests run: 3285, Failures: 0, Errors: 0, Skipped: 0
jacoco ∩ git diff -U0 aa3c556...HEAD (main) every changed executable line mi=0, mb=0zero uncovered lines, zero uncovered branches

Checklist

  • My code follows the project's coding standards
  • I have performed a self-review of my own code
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have updated the documentation accordingly
  • My changes generate no new warnings or errors

Additional Notes

No configuration change, no behaviour change to which findings are published — only to what the review claims it screened, and in the safe direction: a candidate the audit could not rule on now reads as unverified instead of silently counting as screened. VerificationCoverage's compact constructor already clamped the over-count to candidates, so no invalid record was ever produced; what the over-count did was turn PARTIAL into FULL, or shrink a real gap.

The suggested patch on the issue rebuilt a second collapse inside candidatesCovered, including a null-element guard. Verified against the real code and adjusted: the collapse is factored into one private helper both readers call, rather than duplicated, and the null guard is deliberately omitted — VerificationResponse's compact constructor and TruncatedResponseSalvager.salvageArray both go through List.copyOf, which rejects null elements outright, so a null verdict cannot reach either reader and a guard would be dead, untestable code.

#719 routed apply() and the coverage count through one decision normalizer so
what the review does with a verdict and what it claims it verified cannot
drift. The normalizer was shared; the duplicate-id resolution was not.

apply() collapsed duplicates first — first element for an id wins — and read
the label from the survivor, while candidatesCovered filtered by label first
and de-duplicated the ids that were left. An id carrying an undecidable
element followed by a decidable one therefore landed in apply()'s fail-open
default, where the finding posts exactly as the reviewer raised it, while the
count called that candidate screened. Every other candidate decided, that is
FULL coverage on an unscreened finding: no banner, no coverage clause, no
check-run brief, and no "N kept, M downgraded, K rejected" line either when
nothing else was rejected or downgraded. That is the harm #623 exists to
prevent and #710 was filed on, reached by a narrower route — on a complete,
well-formed body, not only on a cut one. The drift is one-directional: the
reverse ordering already agreed, so it only ever over-counted, exclusively
toward the dangerous side.

Both readers now resolve duplicates through one collapse, so the verdict the
count reads for an id is exactly the one the audit acted on. Duplicates are
not a hypothetical input shape: the collapse predates this fix precisely
because the model emits them.

decisionOf now strips, so the strictness genuinely matches the strictRisk and
strictConfidence it documents itself against: "rejected " was unreadable as a
decision while "high " was a readable rating. Both readers take that same
value, so it never drove a drift — it cost an over-cautious keep and an
under-count — but the asymmetry was unintended.

Fixes #735
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@sonarqubecloud

Copy link
Copy Markdown

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot PR Summary

What this PR does

Fixes #735 by making apply() and the coverage count resolve duplicate verdict ids through the same first-wins collapse (byCandidateId), so an id whose first verdict is undecidable is no longer silently counted as screened — coverage drops from FULL to PARTIAL on that path. Also strips whitespace from decision labels in decisionOf so padded verdicts like "rejected\n" are read as decisions consistently with the strictness documented for ACTED_ON_DECISIONS, and adds three tests covering the defect, a reverse-order control, and the padding case.

Description vs. Implementation

No mismatch found between the PR description and the change.

Control-Flow Diagram

🔀 Show diagram
flowchart TD
    A["verification response"] --> B["byCandidateId(): collapse duplicate ids, first wins"]
    B --> C["apply(): act on first-wins survivor per finding id"]
    B --> D["candidatesCovered(): first-wins survivor per id"]
    C --> E["keep / reject / downgrade / fail-open"]
    D --> F{"id within 1..candidates?"}
    F -->|"yes"| G{"decisionOf(survivor) in ACTED_ON_DECISIONS?"}
    G -->|"yes"| H["count id as screened"]
    G -->|"no"| I["leave id unverified"]
    H --> J["VerificationCoverage(candidates, verified)"]
    I --> J
    J --> K["FULL hides disclosure; PARTIAL discloses (#735)"]
Loading

Changes Overview

  • Files changed: 2
  • Lines added: +120
  • Lines removed: -11

Changed Files

File Change Summary
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/FindingVerificationService.java Modified Extracts the duplicate-id first-wins collapse into byCandidateId, now shared by apply() and candidatesCovered(); candidatesCovered collapses before reading labels; decisionOf now strips whitespace.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ai/FindingVerificationServiceTest.java Modified Adds three tests: duplicate id whose first verdict is undecidable is kept but not counted (coverage 2,1 PARTIAL disclosed); reverse-order control stays at (2,2); whitespace-padded decision labels are acted on.

Risk Assessment

Risk Count
🔴 Critical 0
🟠 High 0
🟡 Medium 0
🔵 Low 0

No new issues found in this PR, but the review cannot be approved until required CI is confirmed green.

⚠️ Required CI Checks Status

Some required checks are still pending or have failed:

Check Type Status Detail
test check-run ⏳ Pending -

Automated review by ThrillhouseBot. Reply with /review to re-run.

@thrillhousebot thrillhousebot Bot added bug Something isn't working java Pull requests that update java code labels Aug 16, 2026
@devops-thiago
devops-thiago merged commit 52dcdd4 into main Aug 16, 2026
17 checks passed
@devops-thiago
devops-thiago deleted the fix/735-duplicate-verdict-coverage branch August 16, 2026 03:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working java Pull requests that update java code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Duplicate verdict ids drift between apply() and the coverage count, so an unverified finding is reported as fully screened

1 participant