diff --git a/src/engine/engine.test.ts b/src/engine/engine.test.ts index 7c14be7..5501da9 100644 --- a/src/engine/engine.test.ts +++ b/src/engine/engine.test.ts @@ -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"]); }); diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index ebc79d0..a388ebc 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -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); diff --git a/src/engine/index.ts b/src/engine/index.ts index ff02603..c5d3cdf 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -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); diff --git a/src/engine/registry.ts b/src/engine/registry.ts index 0122f1a..d713f45 100644 --- a/src/engine/registry.ts +++ b/src/engine/registry.ts @@ -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 { diff --git a/src/engine/vocab-data.ts b/src/engine/vocab-data.ts index 5de8097..dcff414 100644 --- a/src/engine/vocab-data.ts +++ b/src/engine/vocab-data.ts @@ -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": "как процент от,как процент",