Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ function record(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

const ROUNDING_STEP = 0.005; // worst-case error of one probability rounded to two decimals
const MAX_SUM_DRIFT = 0.05;

function distribution(value: unknown, keys: string[]): { ok: true; probabilities: Record<string, number> } | { ok: false; reason: string } {
if (!record(value) || Object.keys(value).length !== keys.length || keys.some((key) => !Object.hasOwn(value, key))) {
return { ok: false, reason: "distribution must contain exactly the criteria keys" };
Expand All @@ -82,9 +85,15 @@ function distribution(value: unknown, keys: string[]): { ok: true; probabilities
entries.push([key, probability]);
sum += probability as number;
}
// Upstream distributions are rounded; permit a one-percent sum drift and
// a 0.001 selection tie, but not a different winner.
if (Math.abs(sum - 1) > 0.01) return { ok: false, reason: "distribution must sum to approximately 1" };
// Upstream distributions are rounded to two decimals, so each nonzero entry
// can be off by up to 0.005 and the drift grows with how many options carry
// weight (a results page with 8 live options can legitimately sum to 0.98).
// Permit that rounding drift (never less than one percent, never more than
// five, so garbage still fails) and a 0.001 selection tie, but not a
// different winner. The epsilon keeps an exact 1.01 from failing on IEEE-754.
const nonzero = entries.filter(([, probability]) => probability > 0).length;
const tolerance = Math.min(MAX_SUM_DRIFT, Math.max(0.01, ROUNDING_STEP * nonzero)) + 1e-9;
if (Math.abs(sum - 1) > tolerance) return { ok: false, reason: "distribution must sum to approximately 1" };
return { ok: true, probabilities: Object.fromEntries(entries) };
}

Expand Down
19 changes: 19 additions & 0 deletions test/transports.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,25 @@ test("forced credentials reject every missing or invalid variant without leaking
assert.equal(resolveTransport({ CLOUDFLARE_ACCOUNT_ID: "account-secret", AI_GATEWAY_API_KEY: "ai-secret" }).name, "vercel");
});

test("sum tolerance scales with two-decimal rounding across live options, but stays bounded", async () => {
// A wide action space (like a search results page): 60 options, most at 0.
const criteria = Object.fromEntries(Array.from({ length: 60 }, (_, i) => [`o${i}`, `option ${i}`]));
const wide = { ...input, questions: { pick: { type: "choice", criteria } } };
const reply = (weights) => {
const probabilities = Object.fromEntries(Object.keys(criteria).map((key, i) => [key, weights[i] ?? 0]));
return carrier({ answers: { pick: { type: "choice", choice: "o0", probabilities } } });
};
// 8 live options rounded to two decimals, summing to 0.98 (seen live) and 1.02: accepted.
assert.equal((await askJev(reply([0.4, 0.2, 0.14, 0.1, 0.06, 0.04, 0.02, 0.02]), wide)).ok, true);
assert.equal((await askJev(reply([0.44, 0.15, 0.15, 0.1, 0.08, 0.05, 0.03, 0.02]), wide)).ok, true);
// Two live options can only drift 0.01: 0.97 is not rounding.
rejected(await askJev(reply([0.6, 0.37]), wide), "invalid_distribution", /approximately 1/);
// An exact 1.01 on two options is valid rounding, despite 1.01 - 1 > 0.01 in IEEE-754.
assert.equal((await askJev(carrier({ answers: { ...answers, item: { ...answers.item, probabilities: { alpha: 0.2, beta: 0.81 } } } }), input)).ok, true);
// The drift is capped at five percent no matter how many options are live.
rejected(await askJev(reply(Array.from({ length: 20 }, (_, i) => (i === 0 ? 0.13 : 0.05))), wide), "invalid_distribution", /approximately 1/);
});

test("facade validates the complete answer contract before usage can be credited", async () => {
const good = await askJev(carrier(), input);
assert.equal(good.ok, true);
Expand Down
Loading