mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
fix(web-runtime): seed PRNG for reproducible runs and align weighted pick
This commit is contained in:
1 parent
b5e8d9892e
commit
d7c05181b0
1 file changed
+69
-10
+69
-10
@@ -33,6 +33,51 @@ interface ActionGeneratorHandle {
|
|||||||
const extractors: ExtractorEntry[] = [];
|
const extractors: ExtractorEntry[] = [];
|
||||||
let actionsRoot: ActionGeneratorHandle | null = null;
|
let actionsRoot: ActionGeneratorHandle | null = null;
|
||||||
|
|
||||||
|
// Deterministic PRNG so `sanderling test --platform web --seed N` is
|
||||||
|
// reproducible. The host computes a 64-bit seed and injects it via the bundle
|
||||||
|
// define SANDERLING_SEED; web-runtime reads it at init. Bit-identical parity
|
||||||
|
// with the goja PCG stream is deliberately out of scope here: this only
|
||||||
|
// guarantees same-seed -> same-run on web.
|
||||||
|
function deriveSeed32(raw: string | undefined): number {
|
||||||
|
if (!raw) return 0;
|
||||||
|
// Why: a 64-bit seed loses precision as a JS Number, so fold it to 32 bits
|
||||||
|
// off the decimal string without ever materializing the full value.
|
||||||
|
let hash = 0;
|
||||||
|
for (let index = 0; index < raw.length; index++) {
|
||||||
|
hash = (Math.imul(hash, 31) + raw.charCodeAt(index)) | 0;
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mulberry32: a small, well-known 32-bit PRNG. Returns a float in [0, 1).
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let state = seed >>> 0;
|
||||||
|
return function next(): number {
|
||||||
|
state = (state + 0x6d2b79f5) | 0;
|
||||||
|
let t = state;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// esbuild Define replaces the exact member expression `process.env.SANDERLING_SEED`
|
||||||
|
// with the host-injected seed string at bundle time. When unset (e.g. the
|
||||||
|
// bundler unit tests or a non-test eval) it stays undefined and the seed is 0.
|
||||||
|
function injectedSeed(): string | undefined {
|
||||||
|
try {
|
||||||
|
return process.env.SANDERLING_SEED;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seededRandomValue = mulberry32(deriveSeed32(injectedSeed()));
|
||||||
|
|
||||||
|
function random(): number {
|
||||||
|
return seededRandomValue();
|
||||||
|
}
|
||||||
|
|
||||||
function noopFormula(): unknown {
|
function noopFormula(): unknown {
|
||||||
const formula: Record<string, unknown> = { __sanderlingFormula: true };
|
const formula: Record<string, unknown> = { __sanderlingFormula: true };
|
||||||
formula.implies = () => formula;
|
formula.implies = () => formula;
|
||||||
@@ -327,7 +372,7 @@ const runtime = {
|
|||||||
return {
|
return {
|
||||||
generate(): T | undefined {
|
generate(): T | undefined {
|
||||||
if (items.length === 0) return undefined;
|
if (items.length === 0) return undefined;
|
||||||
return items[Math.floor(Math.random() * items.length)];
|
return items[Math.floor(random() * items.length)];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -435,16 +480,20 @@ function sanitizeAt(value: unknown, depth: number, seen: WeakSet<object>): unkno
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickWeighted(handle: ActionGeneratorHandle): ActionGeneratorHandle | null {
|
function pickWeighted(
|
||||||
|
handle: ActionGeneratorHandle,
|
||||||
|
rand: () => number = random,
|
||||||
|
): ActionGeneratorHandle | null {
|
||||||
const entries = handle.entries ?? [];
|
const entries = handle.entries ?? [];
|
||||||
if (entries.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const [weight] of entries) total += Math.max(0, weight);
|
for (const [weight] of entries) total += Math.max(0, weight);
|
||||||
if (total <= 0) return null;
|
if (total <= 0) return null;
|
||||||
let pick = Math.random() * total;
|
const pick = rand() * total;
|
||||||
|
let cumulative = 0;
|
||||||
for (const [weight, generator] of entries) {
|
for (const [weight, generator] of entries) {
|
||||||
pick -= Math.max(0, weight);
|
cumulative += Math.max(0, weight);
|
||||||
if (pick <= 0) return generator;
|
if (pick < cumulative) return generator;
|
||||||
}
|
}
|
||||||
return entries[entries.length - 1]?.[1] ?? null;
|
return entries[entries.length - 1]?.[1] ?? null;
|
||||||
}
|
}
|
||||||
@@ -499,7 +548,7 @@ function randomTap(kind: "Tap" | "DoubleTap"): unknown {
|
|||||||
}
|
}
|
||||||
const candidates = randomTapCandidates;
|
const candidates = randomTapCandidates;
|
||||||
if (candidates.length === 0) return null;
|
if (candidates.length === 0) return null;
|
||||||
const picked = candidates[Math.floor(Math.random() * candidates.length)];
|
const picked = candidates[Math.floor(random() * candidates.length)];
|
||||||
if (!picked) return null;
|
if (!picked) return null;
|
||||||
const rect = picked.getBoundingClientRect();
|
const rect = picked.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
@@ -550,10 +599,10 @@ function randomInput(): unknown {
|
|||||||
}
|
}
|
||||||
const candidates = randomInputCandidates;
|
const candidates = randomInputCandidates;
|
||||||
if (candidates.length === 0) return null;
|
if (candidates.length === 0) return null;
|
||||||
const picked = candidates[Math.floor(Math.random() * candidates.length)];
|
const picked = candidates[Math.floor(random() * candidates.length)];
|
||||||
if (!picked) return null;
|
if (!picked) return null;
|
||||||
const rect = picked.getBoundingClientRect();
|
const rect = picked.getBoundingClientRect();
|
||||||
const text = WEB_INPUT_CORPUS[Math.floor(Math.random() * WEB_INPUT_CORPUS.length)];
|
const text = WEB_INPUT_CORPUS[Math.floor(random() * WEB_INPUT_CORPUS.length)];
|
||||||
return {
|
return {
|
||||||
kind: "InputText",
|
kind: "InputText",
|
||||||
into: {
|
into: {
|
||||||
@@ -573,7 +622,7 @@ const WEB_PRESS_KEYS = ["enter", "tab", "escape", "up", "down", "left", "right"]
|
|||||||
|
|
||||||
function randomPressKey(): unknown {
|
function randomPressKey(): unknown {
|
||||||
// Why: only emit keys with meaningful browser semantics; "back"/"home" don't navigate.
|
// Why: only emit keys with meaningful browser semantics; "back"/"home" don't navigate.
|
||||||
const key = WEB_PRESS_KEYS[Math.floor(Math.random() * WEB_PRESS_KEYS.length)];
|
const key = WEB_PRESS_KEYS[Math.floor(random() * WEB_PRESS_KEYS.length)];
|
||||||
return { kind: "PressKey", key };
|
return { kind: "PressKey", key };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,7 +630,7 @@ function pickFromArray(value: unknown): unknown {
|
|||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
if (!Array.isArray(value)) return value;
|
if (!Array.isArray(value)) return value;
|
||||||
if (value.length === 0) return null;
|
if (value.length === 0) return null;
|
||||||
return value[Math.floor(Math.random() * value.length)];
|
return value[Math.floor(random() * value.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeAction(action: unknown): unknown {
|
function serializeAction(action: unknown): unknown {
|
||||||
@@ -674,4 +723,14 @@ defineLockedGlobal("__sanderlingNextAction__", function (): unknown {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Test-only exports. The IIFE bundle the host installs has no export surface,
|
||||||
|
// so these are stripped from production output; they only exist for unit tests.
|
||||||
|
export const __testing__ = {
|
||||||
|
mulberry32,
|
||||||
|
deriveSeed32,
|
||||||
|
pickWeighted,
|
||||||
|
WEB_INPUT_CORPUS,
|
||||||
|
WEB_PRESS_KEYS,
|
||||||
|
};
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
Reference in new issue
Block a user