add random() with deterministic line-seeded PRNG

This commit is contained in:
Horizontal42 2026-07-01 20:01:40 +03:00
parent 532c3265ad
commit 1d77ee45d8
5 changed files with 45 additions and 4 deletions

View file

@ -246,6 +246,18 @@ describe("variables and document context", () => {
it("product", () => {
expect(calcDoc("2\n3\n4\nproduct")).toEqual(["2", "3", "4", "24"]);
});
it("random — deterministic per line text", () => {
const [a] = calcDoc("random(1, 6)");
const [b] = calcDoc("random(1, 6)");
expect(a).toBe(b); // same text → same seed → same result
const [c] = calcDoc("random(1, 6) ");
expect(c).not.toBe(a); // different text → different seed
});
it("random — stays in range", () => {
const v = Number(calc("random(1, 100)"));
expect(v).toBeGreaterThanOrEqual(1);
expect(v).toBeLessThanOrEqual(100);
});
it("headers split blocks and produce no result", () => {
expect(calcDoc("# Food\n10\n20\nsum")).toEqual([null, "10", "20", "30"]);
});

View file

@ -11,6 +11,8 @@ export interface LineCtx {
/** per-line flags for block boundaries */
lineKinds: ("empty" | "header" | "normal")[];
index: number;
/** raw line text — used to seed random() deterministically */
lineText: string;
}
export interface EvalCtx {
@ -490,10 +492,35 @@ function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
case "ceil": return x.kind === "quantity" ? { ...x, value: x.value.ceil() } : qty(n().ceil());
case "floor": return x.kind === "quantity" ? { ...x, value: x.value.floor() } : qty(n().floor());
case "fact": case "factorial": return qty(factorial(n()));
case "random": {
const seed = strHash(ctx.line.lineText) ^ (ctx.line.index * 0x9e3779b9);
const rand = mulberry32(seed);
if (args.length === 0) return qty(new Decimal(rand));
const toNum = (v: Value) => v.kind === "quantity" ? v.value.toNumber() : 0;
const [lo, hi] = args.length === 1 ? [0, toNum(args[0]!)] : [toNum(args[0]!), toNum(args[1]!)];
const isInt = Number.isInteger(lo) && Number.isInteger(hi);
const raw = isInt ? Math.floor(rand * (hi - lo + 1)) + lo : rand * (hi - lo) + lo;
return qty(new Decimal(raw));
}
default: throw new EvalError(`unknown function ${name}`);
}
}
function strHash(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
}
return h >>> 0;
}
function mulberry32(seed: number): number {
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = t + Math.imul(t ^ (t >>> 7), 61 | t) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
function factorial(n: Decimal): Decimal {
if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial");
let acc = new Decimal(1);

View file

@ -97,7 +97,7 @@ export class SumEngine {
const ctx: EvalCtx = {
reg: this.reg,
vars,
line: { lineValues, lineKinds, index: i },
line: { lineValues, lineKinds, index: i, lineText: rawLine },
};
try {
value = evaluate(parsed.expr, ctx);

View file

@ -58,7 +58,7 @@ const BUILTIN_FUNCS = [
"sqrt", "cbrt", "sin", "cos", "tan", "cot", "asin", "acos", "atan",
"arcsin", "arccos", "arctan", "sinh", "cosh", "tanh",
"ln", "lg", "log", "log2", "exp", "abs", "round", "ceil", "floor",
"fact", "factorial",
"fact", "factorial", "random",
];
export class Registry {

View file

@ -431,7 +431,8 @@ export const EN: VocabLocale = {
"count.variants": "count,how many",
"min.variants": "min,minimum,smallest",
"max.variants": "max,maximum,largest",
"product.variants": "product"
"product.variants": "product",
"random.variants": "random,rand"
},
"Percent": {
"as_a_percent_of.variants": "as a % of,as a percent of",
@ -907,7 +908,8 @@ export const RU: VocabLocale = {
"count.variants": "количество,кол-во,счёт,число строк",
"min.variants": "минимум,мин,наименьшее",
"max.variants": "максимум,макс,наибольшее",
"product.variants": "произведение,произв"
"product.variants": "произведение,произв",
"random.variants": "случайное,случайно,рандом"
},
"Percent": {
"as_a_percent_of.variants": "как процент от,как процент",