diff --git a/src/services/recommendation-quality-report.ts b/src/services/recommendation-quality-report.ts index 9741f56f0e..5b3e867ee4 100644 --- a/src/services/recommendation-quality-report.ts +++ b/src/services/recommendation-quality-report.ts @@ -224,7 +224,9 @@ function trendBuckets( return trendPeriods(generatedAt, windowDays).map((period) => { const bucketOutcomes = outcomes.filter((outcome) => { const timestamp = Date.parse(outcomeTimestamp(outcome)); - return Number.isFinite(timestamp) && timestamp >= period.startMs && timestamp <= period.endMs; + // Half-open buckets [start, end) (final bucket inclusive) so an outcome exactly on an internal + // boundary lands in exactly one bucket -- matching qualityRollups and keeping sum(trends) == totals. + return Number.isFinite(timestamp) && timestamp >= period.startMs && (period.last ? timestamp <= period.endMs : timestamp < period.endMs); }); return { periodStart: period.periodStart, diff --git a/test/unit/recommendation-quality-report.test.ts b/test/unit/recommendation-quality-report.test.ts index 8337a88bf0..5818ba9b0d 100644 --- a/test/unit/recommendation-quality-report.test.ts +++ b/test/unit/recommendation-quality-report.test.ts @@ -106,6 +106,32 @@ describe("recommendation quality report", () => { expect(JSON.stringify(report.rollups)).not.toMatch(FORBIDDEN_REPORT_TERMS); }); + it("assigns internal boundary outcomes to exactly one trend bucket", () => { + const report = buildRecommendationQualityReportFromOutcomes( + [ + outcome("boundary", "accepted", { + surface: "api", + metadata: { role: "miner" }, + updatedAt: "2026-05-25T00:00:00.000Z", + }), + ], + { generatedAt: "2026-06-01T00:00:00.000Z", windowDays: 14 }, + ); + + expect(report.totals.total).toBe(1); + // The 14-day window splits into two 7-day buckets at 2026-05-25; the boundary outcome belongs to + // exactly one bucket (the later one), matching qualityRollups -- not both via an inclusive end. + expect(report.trends.map((bucket) => bucket.total)).toEqual([0, 1]); + expect(report.trends.reduce((sum, bucket) => sum + bucket.total, 0)).toBe(1); + expect(report.rollups).toEqual([ + expect.objectContaining({ + periodStart: "2026-05-25T00:00:00.000Z", + periodEnd: "2026-06-01T00:00:00.000Z", + count: 1, + }), + ]); + }); + it("counts rejected outcomes as negative recommendations", () => { const rejectedOnly = buildRecommendationQualityReportFromOutcomes( [outcome("rejected", "rejected", { actionType: "monitor_existing_pr", repo: "owner/rejected", updatedAt: "2026-05-30T00:00:00.000Z" })],