⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.
Context
Both aggregators read agent_recommendation_outcomes and both expose positiveRate, with opposite treatment of the same two states.
src/services/outcome-calibration.ts:133-137 (doc at :65: "Positive (accepted/merged/improved) vs negative (rejected/closed) vs pending (stale/ignored) split") computes negative from rejected/closed only, pending from stale/ignored, resolved = positive + negative, and returns positiveRate: resolved > 0 ? round(positive / resolved) : null.
src/services/recommendation-quality-report.ts:75 and :160-169 declares NEGATIVE_STATES = ["closed", "rejected", "stale", "ignored"] and returns positiveRate: total > 0 ? roundRate(positive / total) : 0.
A ledger of 5 accepted + 5 stale yields positiveRate: 1.0 from buildRecommendationOutcomeCalibration and positiveRate: 0.5 from qualityTotals — two contradictory numbers for the same window, shown to the same operator. recommendation-quality-report.ts additionally lists stale/ignored under failureCategories, while outcome-calibration.ts:150 renders them as "still pending" in its signal string.
There is a second, independent divergence in the same pair: the zero-denominator arm. outcome-calibration.ts:137 returns null (typed number | null, correctly "no sample"); recommendation-quality-report.ts:169 returns 0, which is indistinguishable from a genuine 0% positive rate.
Requirements
- Define the state classification once, in
src/services/outcome-calibration.ts (the older module, and the one whose semantics the calibration signal strings already describe):
export const RECOMMENDATION_POSITIVE_STATES = ["accepted", "merged", "improved"]
export const RECOMMENDATION_NEGATIVE_STATES = ["rejected", "closed"]
export const RECOMMENDATION_PENDING_STATES = ["stale", "ignored"]
each typed readonly AgentRecommendationOutcomeState[].
buildRecommendationOutcomeCalibration uses those constants instead of its inline literals at :134-136.
src/services/recommendation-quality-report.ts deletes POSITIVE_STATES/NEGATIVE_STATES (:74-75) and imports the shared constants; qualityTotals counts negative from RECOMMENDATION_NEGATIVE_STATES only, so stale/ignored leave the positiveRate denominator.
failureCategoryRows must stop attributing stale/ignored as failures; they are reported as pending. Add a pending count to RecommendationQualityTotals mirroring RecommendationOutcomeCalibration.pending so the information is not lost.
qualityTotals.positiveRate becomes number | null and returns null (not 0) on a zero denominator, matching outcome-calibration.ts:137. Update RecommendationQualityTotals's type and every render/consumer of that field accordingly, including the OpenAPI schema if it is specced (regenerate with npm run ui:openapi if so).
- No change to
AgentRecommendationOutcomeState itself or to the ledger's write path.
⚠️ Required pattern: outcome-calibration.ts's three-way positive/negative/pending split, and its resolved > 0 ? ... : null zero-denominator arm at :137, are the semantics both modules must share. It does NOT satisfy this issue to change outcome-calibration.ts to match recommendation-quality-report.ts instead (its own signal string at :150 and its pending field already commit to the pending semantics); to keep two constant lists that happen to agree today; or to unify the states but leave positiveRate returning 0 for an empty ledger in one module and null in the other.
Deliverables
All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example unifying the state lists while leaving positiveRate returning 0 on an empty ledger — does not resolve this issue.
Test Coverage Requirements
99%+ Codecov patch coverage, branch-counted, on src/**; both files are inside coverage.include. Both arms of the changed zero-denominator conditional need a test in each module, and every state in all three constants must appear in at least one fixture so the classification change is fully exercised.
Expected Outcome
The recommendation-quality report and the outcome-calibration signal agree on what a positive rate means. A window of unactioned (stale/ignored) recommendations no longer reads as a 50% failure rate on one surface and a 100% success rate on another, and an empty ledger reports "no sample" rather than 0%.
Links & Resources
src/services/outcome-calibration.ts:64-70, :126-157; src/services/recommendation-quality-report.ts:60-76, :90-100, :160-175; src/openapi/schemas.ts.
Context
Both aggregators read
agent_recommendation_outcomesand both exposepositiveRate, with opposite treatment of the same two states.src/services/outcome-calibration.ts:133-137(doc at:65: "Positive (accepted/merged/improved) vs negative (rejected/closed) vs pending (stale/ignored) split") computesnegativefromrejected/closedonly,pendingfromstale/ignored,resolved = positive + negative, and returnspositiveRate: resolved > 0 ? round(positive / resolved) : null.src/services/recommendation-quality-report.ts:75and:160-169declaresNEGATIVE_STATES = ["closed", "rejected", "stale", "ignored"]and returnspositiveRate: total > 0 ? roundRate(positive / total) : 0.A ledger of 5
accepted+ 5staleyieldspositiveRate: 1.0frombuildRecommendationOutcomeCalibrationandpositiveRate: 0.5fromqualityTotals— two contradictory numbers for the same window, shown to the same operator.recommendation-quality-report.tsadditionally listsstale/ignoredunderfailureCategories, whileoutcome-calibration.ts:150renders them as "still pending" in its signal string.There is a second, independent divergence in the same pair: the zero-denominator arm.
outcome-calibration.ts:137returnsnull(typednumber | null, correctly "no sample");recommendation-quality-report.ts:169returns0, which is indistinguishable from a genuine 0% positive rate.Requirements
src/services/outcome-calibration.ts(the older module, and the one whose semantics the calibration signal strings already describe):export const RECOMMENDATION_POSITIVE_STATES=["accepted", "merged", "improved"]export const RECOMMENDATION_NEGATIVE_STATES=["rejected", "closed"]export const RECOMMENDATION_PENDING_STATES=["stale", "ignored"]each typed
readonly AgentRecommendationOutcomeState[].buildRecommendationOutcomeCalibrationuses those constants instead of its inline literals at:134-136.src/services/recommendation-quality-report.tsdeletesPOSITIVE_STATES/NEGATIVE_STATES(:74-75) and imports the shared constants;qualityTotalscountsnegativefromRECOMMENDATION_NEGATIVE_STATESonly, sostale/ignoredleave thepositiveRatedenominator.failureCategoryRowsmust stop attributingstale/ignoredas failures; they are reported as pending. Add apendingcount toRecommendationQualityTotalsmirroringRecommendationOutcomeCalibration.pendingso the information is not lost.qualityTotals.positiveRatebecomesnumber | nulland returnsnull(not0) on a zero denominator, matchingoutcome-calibration.ts:137. UpdateRecommendationQualityTotals's type and every render/consumer of that field accordingly, including the OpenAPI schema if it is specced (regenerate withnpm run ui:openapiif so).AgentRecommendationOutcomeStateitself or to the ledger's write path.Deliverables
RECOMMENDATION_*_STATESconstants are exported fromsrc/services/outcome-calibration.tsand are the only place those state literals are grouped (grep-verifiable — noNEGATIVE_STATESinrecommendation-quality-report.ts).accepted+ 5staleproduces the samepositiveRate(1.0) frombuildRecommendationOutcomeCalibrationand frombuildRecommendationQualityReportFromOutcomes— asserted in one test that calls both.RecommendationQualityTotals.pendingis5for that same fixture.qualityTotalsover an empty ledger returnspositiveRate: null, matchingbuildRecommendationOutcomeCalibration— asserted for both.failureCategoriesfor that fixture contains nostaleorignoredrow.RecommendationQualityTotalsis specced insrc/openapi/, the regenerated artifact is committed (npm run ui:openapi) and CI's drift check passes.All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example unifying the state lists while leaving
positiveRatereturning0on an empty ledger — does not resolve this issue.Test Coverage Requirements
99%+ Codecov patch coverage, branch-counted, on
src/**; both files are insidecoverage.include. Both arms of the changed zero-denominator conditional need a test in each module, and every state in all three constants must appear in at least one fixture so the classification change is fully exercised.Expected Outcome
The recommendation-quality report and the outcome-calibration signal agree on what a positive rate means. A window of unactioned (
stale/ignored) recommendations no longer reads as a 50% failure rate on one surface and a 100% success rate on another, and an empty ledger reports "no sample" rather than 0%.Links & Resources
src/services/outcome-calibration.ts:64-70,:126-157;src/services/recommendation-quality-report.ts:60-76,:90-100,:160-175;src/openapi/schemas.ts.