From 79edecaf5df852e62b6d81ab0d66ab3e88da3337 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 08:49:30 +0000 Subject: [PATCH 1/2] test: property-check forecast recommendation and summary contracts Seven fast-check properties over recommendForecastAccount, summarizeForecast, and buildForecastExplanation with generated result pools (0-8 accounts, full availability/risk/wait/flag space): - a recommendation always points at a recommendable result (not disabled, hard-failed, exhausted, or unavailable); null only when no such candidate exists - a ready candidate always wins with the minimal risk score among ready candidates, and the reason says so - with no ready candidate, the shortest delayed wait wins - an empty candidate pool names the actual blocker class: 'blocked or exhausted' guidance iff some account is blocked/exhausted rather than disabled/hard-failed (the #exhausted-flag regression guard) - the recommendation is invariant under input order - the summary partitions availability exactly (ready + delayed + unavailable === total) and counts high-risk rows - the explanation mirrors inputs in order and marks selected on exactly the recommended index Companion to the property suites in #574/#575/#592-#598. https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB --- test/property/forecast.property.test.ts | 172 ++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 test/property/forecast.property.test.ts diff --git a/test/property/forecast.property.test.ts b/test/property/forecast.property.test.ts new file mode 100644 index 00000000..08242c55 --- /dev/null +++ b/test/property/forecast.property.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import * as fc from "fast-check"; +import { + buildForecastExplanation, + recommendForecastAccount, + summarizeForecast, + type ForecastAccountResult, +} from "../../lib/forecast.js"; + +const arbResults: fc.Arbitrary = fc + .array( + fc.record({ + availability: fc.constantFrom<"ready" | "delayed" | "unavailable">( + "ready", + "delayed", + "unavailable", + ), + riskScore: fc.integer({ min: 0, max: 100 }), + riskLevel: fc.constantFrom<"low" | "medium" | "high">( + "low", + "medium", + "high", + ), + waitMs: fc.integer({ min: 0, max: 600_000 }), + isCurrent: fc.boolean(), + hardFailure: fc.boolean(), + disabled: fc.boolean(), + exhausted: fc.boolean(), + }), + { minLength: 0, maxLength: 8 }, + ) + .map((rows) => + rows.map((row, index) => ({ + ...row, + index, + label: `acct-${index}`, + reasons: [], + })), + ); + +function recommendable(result: ForecastAccountResult): boolean { + return ( + !result.disabled && + !result.hardFailure && + !result.exhausted && + result.availability !== "unavailable" + ); +} + +describe("forecast recommendation property invariants", () => { + it("a recommendation always points at a recommendable result", () => { + fc.assert( + fc.property(arbResults, (results) => { + const recommendation = recommendForecastAccount(results); + if (recommendation.recommendedIndex === null) { + expect(results.some(recommendable)).toBe(false); + return; + } + const picked = results.find( + (result) => result.index === recommendation.recommendedIndex, + ); + expect(picked).toBeDefined(); + expect(recommendable(picked as ForecastAccountResult)).toBe(true); + }), + ); + }); + + it("a ready candidate always wins, with the minimal risk score among ready candidates", () => { + fc.assert( + fc.property(arbResults, (results) => { + const readyCandidates = results.filter( + (result) => recommendable(result) && result.availability === "ready", + ); + fc.pre(readyCandidates.length > 0); + const recommendation = recommendForecastAccount(results); + const picked = results.find( + (result) => result.index === recommendation.recommendedIndex, + ); + expect(picked?.availability).toBe("ready"); + expect(picked?.riskScore).toBe( + Math.min(...readyCandidates.map((result) => result.riskScore)), + ); + expect(recommendation.reason).toContain("Lowest risk ready account"); + }), + ); + }); + + it("with no ready candidate, the shortest delayed wait wins", () => { + fc.assert( + fc.property(arbResults, (results) => { + const candidates = results.filter(recommendable); + fc.pre(candidates.length > 0); + fc.pre(candidates.every((result) => result.availability === "delayed")); + const recommendation = recommendForecastAccount(results); + const picked = results.find( + (result) => result.index === recommendation.recommendedIndex, + ); + expect(picked?.waitMs).toBe( + Math.min(...candidates.map((result) => result.waitMs)), + ); + expect(recommendation.reason).toContain("shortest wait"); + }), + ); + }); + + it("an empty candidate pool names the actual blocker class in its guidance", () => { + fc.assert( + fc.property(arbResults, (results) => { + fc.pre(!results.some(recommendable)); + const recommendation = recommendForecastAccount(results); + expect(recommendation.recommendedIndex).toBeNull(); + const hasBlockedOrExhausted = results.some( + (result) => + !result.disabled && + !result.hardFailure && + (result.exhausted || result.availability === "unavailable"), + ); + expect( + recommendation.reason.includes("blocked or exhausted"), + ).toBe(hasBlockedOrExhausted); + }), + ); + }); + + it("the recommendation is invariant under input order", () => { + fc.assert( + fc.property(arbResults, (results) => { + const forward = recommendForecastAccount(results); + const reversed = recommendForecastAccount([...results].reverse()); + expect(reversed.recommendedIndex).toBe(forward.recommendedIndex); + }), + ); + }); + + it("the summary partitions availability exactly and counts high risk", () => { + fc.assert( + fc.property(arbResults, (results) => { + const summary = summarizeForecast(results); + expect(summary.total).toBe(results.length); + expect(summary.ready + summary.delayed + summary.unavailable).toBe( + results.length, + ); + expect(summary.ready).toBe( + results.filter((result) => result.availability === "ready").length, + ); + expect(summary.highRisk).toBe( + results.filter((result) => result.riskLevel === "high").length, + ); + }), + ); + }); + + it("the explanation mirrors the inputs in order and selects exactly the recommendation", () => { + fc.assert( + fc.property(arbResults, (results) => { + const recommendation = recommendForecastAccount(results); + const explanation = buildForecastExplanation(results, recommendation); + expect(explanation.recommendedIndex).toBe( + recommendation.recommendedIndex, + ); + expect(explanation.considered.map((entry) => entry.index)).toEqual( + results.map((result) => result.index), + ); + for (const entry of explanation.considered) { + expect(entry.selected).toBe( + entry.index === recommendation.recommendedIndex, + ); + } + }), + ); + }); +}); From c4b39bfec60cf6b15b65ae10814bbf4302975680 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 08:57:21 +0000 Subject: [PATCH 2/2] test: keep forecast generator in the reachable domain; shuffle permutations Greptile flagged that availability and exhausted were drawn independently (evaluateForecastAccount only emits exhausted accounts as delayed) and that order-invariance only probed reversal. The generator now derives the pairing, and the invariance property checks an arbitrary generated permutation plus the reversal. Validated at FAST_CHECK_NUM_RUNS=1000. https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB --- test/property/forecast.property.test.ts | 31 +++++++++++++++++++++---- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/test/property/forecast.property.test.ts b/test/property/forecast.property.test.ts index 08242c55..feb3a27f 100644 --- a/test/property/forecast.property.test.ts +++ b/test/property/forecast.property.test.ts @@ -32,6 +32,10 @@ const arbResults: fc.Arbitrary = fc .map((rows) => rows.map((row, index) => ({ ...row, + // evaluateForecastAccount only ever emits exhausted accounts as + // "delayed" (display/sorting keep them a timed wait); derive the + // pairing so the generated space stays inside the reachable domain. + availability: row.exhausted ? ("delayed" as const) : row.availability, index, label: `acct-${index}`, reasons: [], @@ -124,11 +128,28 @@ describe("forecast recommendation property invariants", () => { it("the recommendation is invariant under input order", () => { fc.assert( - fc.property(arbResults, (results) => { - const forward = recommendForecastAccount(results); - const reversed = recommendForecastAccount([...results].reverse()); - expect(reversed.recommendedIndex).toBe(forward.recommendedIndex); - }), + fc.property( + arbResults.chain((results) => + fc.record({ + results: fc.constant(results), + shuffled: fc.shuffledSubarray(results, { + minLength: results.length, + maxLength: results.length, + }), + }), + ), + ({ results, shuffled }) => { + // Unique indexes make the comparator's index tie-break total, so + // ANY permutation must produce the same pick. + const forward = recommendForecastAccount(results); + expect(recommendForecastAccount(shuffled).recommendedIndex).toBe( + forward.recommendedIndex, + ); + expect( + recommendForecastAccount([...results].reverse()).recommendedIndex, + ).toBe(forward.recommendedIndex); + }, + ), ); });