goal seek: ? * 1.2 = 1000 → 833.33

This commit is contained in:
Horizontal42 2026-07-01 22:11:55 +03:00
parent bbbcc0497c
commit d5f458dce3
4 changed files with 70 additions and 1 deletions

View file

@ -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 });

View file

@ -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") {

View file

@ -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<string>, 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;
}

View file

@ -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<string, Token["t"] | "plus" | "minus" | "mul" | "div" | "pow"> = {};
@ -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++;