From d5f458dce3dd0297463a1ad9f5c4964e6fd40e7a Mon Sep 17 00:00:00 2001 From: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:11:55 +0300 Subject: [PATCH] =?UTF-8?q?goal=20seek:=20=3F=20*=201.2=20=3D=201000=20?= =?UTF-8?q?=E2=86=92=20833.33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/engine.test.ts | 8 ++++++++ src/engine/evaluator.ts | 43 +++++++++++++++++++++++++++++++++++++++ src/engine/parser.ts | 18 +++++++++++++++- src/engine/tokenizer.ts | 2 ++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/engine/engine.test.ts b/src/engine/engine.test.ts index 9a37e9c..9dc3bf1 100644 --- a/src/engine/engine.test.ts +++ b/src/engine/engine.test.ts @@ -302,6 +302,14 @@ describe("dates", () => { }); }); +describe("goal seek", () => { + it("? * 1.2 = 1000", () => expect(calc("? * 1.2 = 1000")).toBe("833.33")); + it("? + 50 = 200", () => expect(calc("? + 50 = 200")).toBe("150")); + it("100 - ? = 30", () => expect(calc("100 - ? = 30")).toBe("70")); + it("? / 4 = 5", () => expect(calc("? / 4 = 5")).toBe("20")); + it("? - ? = 5 has no solution", () => expect(calc("? - ? = 5")).toBeNull()); +}); + describe("extension API", () => { it("addUnit (horse from Sample.js)", () => { eng.addUnit({ id: "horse", phrases: "horse, horses, hrs", baseUnitId: "meter", format: "hrs", ratio: 2.4 }); diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index 13032aa..2fca796 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -44,6 +44,13 @@ export function evaluate(node: Node, ctx: EvalCtx): Value { return evalDateWord(node.word); case "datelit": return { kind: "date", ms: node.ms, hasTime: false }; + case "unknown": { + const xv = ctx.vars.get("__x__"); + if (!xv) throw new EvalError("? used outside goal seek"); + return xv; + } + case "goalseek": + return evalGoalSeek(node.lhs, node.rhs, ctx); case "agg": return evalAgg(node.name, ctx); case "neg": { @@ -399,6 +406,42 @@ function evalDateArith(op: string, l: Value, r: Value): Value { return { ...date, ms, hasTime }; } +function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value { + const evalF = (x: Decimal): Decimal => { + const vars = new Map(ctx.vars); + vars.set("__x__", qty(x)); + const innerCtx: EvalCtx = { ...ctx, vars }; + const lv = evaluate(lhsNode, innerCtx); + const rv = evaluate(rhsNode, innerCtx); + const toD = (v: Value) => v.kind === "quantity" ? toBase(v) : new Decimal(0); + return toD(lv).minus(toD(rv)); + }; + + // Linear probe: f(0) and f(1) give slope → exact root for linear expressions + const f0 = evalF(new Decimal(0)); + const f1 = evalF(new Decimal(1)); + const slope = f1.minus(f0); + if (!slope.isZero()) { + const root = f0.neg().div(slope); + if (evalF(root).abs().lt(new Decimal("1e-9"))) return qty(root); + } + + // Bisection fallback for nonlinear cases + let lo = new Decimal(-1e9); + let hi = new Decimal(1e9); + let flo = evalF(lo); + let fhi = evalF(hi); + if (flo.mul(fhi).gt(0)) throw new EvalError("no solution"); + for (let i = 0; i < 100; i++) { + const mid = lo.plus(hi).div(2); + if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) return qty(mid); + const fm = evalF(mid); + if (flo.mul(fm).lte(0)) { hi = mid; fhi = fm; } + else { lo = mid; flo = fm; } + } + return qty(lo.plus(hi).div(2)); +} + function evalAgg(name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart", ctx: EvalCtx): Value { const { lineValues, lineKinds, index } = ctx.line; if (name === "prev") { diff --git a/src/engine/parser.ts b/src/engine/parser.ts index 5705cb2..67005f9 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -32,7 +32,9 @@ export type Node = | { k: "scale"; x: Node; mult: Decimal; label: string } | { k: "conv"; x: Node; target: ConvTarget } | { k: "call"; name: string; args: Node[] } - | { k: "seq"; items: Node[] }; + | { k: "seq"; items: Node[] } + | { k: "unknown" } + | { k: "goalseek"; lhs: Node; rhs: Node }; export interface ParsedLine { assign?: string; @@ -73,6 +75,17 @@ export function parseLine(tokens: Token[], knownVars: Set, line: string) filtered.push(tk); } + // Goal seek: `? * 1.2 = 1000` → find x where lhs(x) = rhs(x) + const hasUnknown = filtered.some((tk) => tk.t === "unknown"); + if (hasUnknown) { + const assignIdx = filtered.findIndex((tk) => tk.t === "assign"); + if (assignIdx >= 0) { + const lhs = new Parser(filtered.slice(0, assignIdx), knownVars).parseSeq(); + const rhs = new Parser(filtered.slice(assignIdx + 1), knownVars).parseSeq(); + if (lhs && rhs) return { expr: { k: "goalseek", lhs, rhs } }; + } + } + const p = new Parser(filtered, knownVars); return { assign, expr: p.parseSeq() }; } @@ -419,6 +432,9 @@ class Parser { } return null; } + case "unknown": + this.i++; + return { k: "unknown" }; default: return null; } diff --git a/src/engine/tokenizer.ts b/src/engine/tokenizer.ts index 1a7c5cc..fefd526 100644 --- a/src/engine/tokenizer.ts +++ b/src/engine/tokenizer.ts @@ -26,6 +26,7 @@ export type Token = | { t: "semicolon"; start: number; end: number } | { t: "bang"; start: number; end: number } | { t: "word"; raw: string; start: number; end: number } + | { t: "unknown"; start: number; end: number } | { t: "junk"; raw: string; start: number; end: number }; const SYM_OPS: Record = {}; @@ -100,6 +101,7 @@ export function tokenize(line: string, reg: Registry): Token[] { case "=": tokens.push({ t: "assign", ...span }); break; case ";": tokens.push({ t: "semicolon", ...span }); break; case "!": tokens.push({ t: "bang", ...span }); break; + case "?": tokens.push({ t: "unknown", ...span }); break; default: tokens.push({ t: "junk", raw: lx.raw, ...span }); } i++;