feat(spec): refuse a multi-value generator while enumerating

integers, strings, emails and edgeCaseText read the same rng from() does, so
under the model policy an authored InputText typed the same value on every
step while the seeded arm varied it. That is a silently different experiment,
not just a silently different action space.

Single-valued spans are exempt, because both policies then get the same value:
between(7,7), a zero-length string, and a one-entry corpus. length(4,4) is
still refused, since the length is pinned but each character is drawn from 62.

Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
This commit is contained in:
pj committed 2026-08-13 00:58:17 +05:30
1 parent 7880e3e70f
commit 0a71e05588
3 files changed
+101 -5

No files matched your search

+60 -1
View File
@@ -1,7 +1,11 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { Pcg } from "../src/pcg.ts";
import { setSamplerRng } from "../src/sampler-rng.ts";
import {
SAMPLER_REFUSAL_NAME,
setEnumeratingCandidates,
setSamplerRng,
} from "../src/sampler-rng.ts";
import { edgeCaseText, emails, integers, strings } from "../src/values.ts";
import { INPUT_CORPUS } from "../src/corpus.ts";
import type { Sampler } from "../src/types.ts";
@@ -73,3 +77,58 @@ test("outside a walk generators return a fixed deterministic default", () => {
assert.equal(emails().domain("folio.app").generate(), "[email protected]");
assert.equal(edgeCaseText().generate(), INPUT_CORPUS[0]);
});
function whileEnumerating<T>(body: () => T): T {
setEnumeratingCandidates(true);
try {
return body();
} finally {
setEnumeratingCandidates(false);
}
}
test("every value generator refuses a multi-value draw while the model policy enumerates", () => {
whileEnumerating(() => {
assert.throws(() => integers().between(1, 500).generate(), {
name: SAMPLER_REFUSAL_NAME,
message: /random value from integers\(\)/,
});
assert.throws(() => strings().length(3, 6).alpha().generate(), {
name: SAMPLER_REFUSAL_NAME,
message: /random value from strings\(\)/,
});
assert.throws(() => emails().domain("folio.app").generate(), {
name: SAMPLER_REFUSAL_NAME,
message: /random value from emails\(\)/,
});
assert.throws(() => edgeCaseText().generate(), {
name: SAMPLER_REFUSAL_NAME,
message: /random value from edgeCaseText\(\)/,
});
});
});
test("a fixed-length string still refuses, because its characters vary", () => {
whileEnumerating(() => {
assert.throws(() => strings().length(4, 4).alpha().generate(), {
name: SAMPLER_REFUSAL_NAME,
});
});
});
test("a single-valued generator keeps drawing while the model policy enumerates", () => {
whileEnumerating(() => {
assert.equal(integers().between(7, 7).generate(), 7);
assert.equal(strings().length(0, 0).generate(), "");
});
});
test("a refused generator draws again once enumeration ends", () => {
whileEnumerating(() => {
assert.throws(() => integers().between(1, 500).generate());
});
withRng(5n, () => {
const value = integers().between(1, 500).generate();
assert.ok(value >= 1 && value <= 500);
});
});