feat(formulas): add restricted decimal engine

This commit is contained in:
2026-08-28 16:07:08 +00:00 Unverified
parent 4fa625a24c
commit de48230b37
6 changed files with 922 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import { RestrictedFormulaEngine } from "./engine";
const engine = new RestrictedFormulaEngine();
describe("RestrictedFormulaEngine", () => {
it("uses arithmetic precedence and parentheses", () => {
const result = engine.evaluate([
{ key: "base", value: "10" },
{ key: "flat", value: "2" },
{ key: "scaled", formula: "base + flat * 3", displayPrecision: 0 },
{ key: "grouped", formula: "(base + flat) * 3", displayPrecision: 0 },
]);
expect(result.fields.scaled.canonical).toBe("16");
expect(result.fields.grouped.canonical).toBe("36");
expect(result.order).toEqual(["base", "flat", "scaled", "grouped"]);
});
it("supports subtraction, percentages, unary signs, and Thai field names", () => {
const result = engine.evaluate([
{ key: "พลังพื้นฐาน", value: "200" },
{ key: "โบนัส", value: "25" },
{
key: "รวม",
formula: "-(-พลังพื้นฐาน) + พลังพื้นฐาน * โบนัส% - 10",
displayPrecision: 0,
},
]);
expect(result.fields["รวม"].canonical).toBe("240");
expect(result.fields["รวม"].display).toBe("240");
});
it("maps imported A1 references to stable field keys", () => {
const result = engine.evaluate(
[
{ key: "attack", value: "120" },
{ key: "bonus", value: "30" },
{ key: "total", formula: "=(A1 + B1) / 2", displayPrecision: 1 },
],
{ referenceAliases: { A1: "attack", B1: "bonus" } },
);
expect(result.fields.total.canonical).toBe("75");
expect(result.fields.total.display).toBe("75.0");
expect(result.fields.total.dependencies).toEqual(["attack", "bonus"]);
});
it("keeps canonical values unrounded and rounds half-up only for display", () => {
const result = engine.evaluate([
{ key: "source", value: "1.005", displayPrecision: 3 },
{ key: "shown", formula: "source", displayPrecision: 2 },
{ key: "dependent", formula: "shown * 1000", displayPrecision: 0 },
]);
expect(result.fields.shown.canonical).toBe("1.005");
expect(result.fields.shown.display).toBe("1.01");
expect(result.fields.dependent.canonical).toBe("1005");
});
it("returns typed blank and missing reference errors", () => {
const result = engine.evaluate([
{ key: "blank", value: "" },
{ key: "usesBlank", formula: "blank + 1" },
{ key: "missing", formula: "unknown + 1" },
]);
expect(result.fields.blank.error?.code).toBe("blank-reference");
expect(result.fields.usesBlank.error).toMatchObject({
code: "blank-reference",
reference: "blank",
});
expect(result.fields.missing.error).toMatchObject({
code: "missing-reference",
reference: "unknown",
});
});
it("detects cycles and propagates dependency errors", () => {
const result = engine.evaluate([
{ key: "a", formula: "b + 1" },
{ key: "b", formula: "c + 1" },
{ key: "c", formula: "a + 1" },
{ key: "downstream", formula: "a * 2" },
]);
expect(result.fields.a.error?.code).toBe("circular-reference");
expect(result.fields.b.error?.code).toBe("circular-reference");
expect(result.fields.c.error?.code).toBe("circular-reference");
expect(result.fields.downstream.error).toMatchObject({
code: "dependency-error",
cause: "circular-reference",
});
});
it("reports division by zero without affecting unrelated fields", () => {
const result = engine.evaluate([
{ key: "bad", formula: "10 / (2 - 2)" },
{ key: "dependent", formula: "bad + 1" },
{ key: "good", formula: "6 / 2", displayPrecision: 0 },
]);
expect(result.fields.bad.error?.code).toBe("division-by-zero");
expect(result.fields.dependent.error).toMatchObject({
code: "dependency-error",
cause: "division-by-zero",
});
expect(result.fields.good.display).toBe("3");
});
it("rejects functions, absolute references, and executable syntax", () => {
for (const formula of ["SUM(a)", "$A$1 + 1", "globalThis.value", "a ** 2"]) {
const result = engine.evaluate([{ key: "value", formula }]);
expect(result.fields.value.error?.code).toBe("parse-error");
}
});
it("reports operator metadata for regression corpus assertions", () => {
expect(engine.analyze("=(A1 + 0.5) / -B1%")).toEqual({
additions: 1,
subtractions: 0,
multiplications: 0,
divisions: 1,
percentages: 1,
unarySigns: 1,
decimalLiterals: 1,
references: ["A1", "B1"],
});
});
});
+574
View File
@@ -0,0 +1,574 @@
import Decimal from "decimal.js";
import type {
EvaluatedField,
FormulaAnalysis,
FormulaEngine,
FormulaError,
FormulaErrorCode,
FormulaEvaluation,
FormulaField,
FormulaOptions,
} from "./types";
const DeterministicDecimal = Decimal.clone({
precision: 100,
rounding: Decimal.ROUND_HALF_UP,
toExpNeg: -1_000,
toExpPos: 1_000,
});
type TokenType =
| "number"
| "reference"
| "plus"
| "minus"
| "multiply"
| "divide"
| "left-paren"
| "right-paren"
| "percent"
| "eof";
interface Token {
type: TokenType;
lexeme: string;
offset: number;
}
type Expression =
| { type: "number"; value: string }
| { type: "reference"; key: string }
| { type: "unary"; operator: "+" | "-"; operand: Expression }
| { type: "percent"; operand: Expression }
| {
type: "binary";
operator: "+" | "-" | "*" | "/";
left: Expression;
right: Expression;
};
class FormulaFailure extends Error {
constructor(
readonly code: FormulaErrorCode,
message: string,
readonly reference?: string,
readonly causeCode?: FormulaErrorCode,
) {
super(message);
this.name = "FormulaFailure";
}
}
function isIdentifierStart(character: string): boolean {
return /[\p{L}_]/u.test(character);
}
function isIdentifierPart(character: string): boolean {
return /[\p{L}\p{M}\p{N}_]/u.test(character);
}
function tokenize(source: string): Token[] {
const tokens: Token[] = [];
let offset = 0;
while (offset < source.length) {
const character = source[offset];
if (/\s/u.test(character)) {
offset += 1;
continue;
}
if (/\d/u.test(character) || (character === "." && /\d/u.test(source[offset + 1] ?? ""))) {
const start = offset;
let sawDecimalPoint = false;
while (offset < source.length) {
const next = source[offset];
if (/\d/u.test(next)) {
offset += 1;
continue;
}
if (next === "." && !sawDecimalPoint) {
sawDecimalPoint = true;
offset += 1;
continue;
}
break;
}
const lexeme = source.slice(start, offset);
if (lexeme.endsWith(".")) {
throw new FormulaFailure(
"parse-error",
`Decimal literal at position ${start + 1} is incomplete.`,
);
}
tokens.push({ type: "number", lexeme, offset: start });
continue;
}
if (isIdentifierStart(character)) {
const start = offset;
offset += 1;
while (
offset < source.length &&
isIdentifierPart(source[offset] ?? "")
) {
offset += 1;
}
tokens.push({
type: "reference",
lexeme: source.slice(start, offset),
offset: start,
});
continue;
}
const operators: Record<string, TokenType> = {
"+": "plus",
"-": "minus",
"*": "multiply",
"/": "divide",
"(": "left-paren",
")": "right-paren",
"%": "percent",
};
const type = operators[character];
if (!type) {
throw new FormulaFailure(
"parse-error",
`Unsupported token “${character}” at position ${offset + 1}.`,
);
}
tokens.push({ type, lexeme: character, offset });
offset += 1;
}
tokens.push({ type: "eof", lexeme: "", offset: source.length });
return tokens;
}
class Parser {
private cursor = 0;
constructor(private readonly tokens: readonly Token[]) {}
parse(): Expression {
const expression = this.parseAdditive();
const trailing = this.peek();
if (trailing.type !== "eof") {
throw new FormulaFailure(
"parse-error",
`Unexpected token “${trailing.lexeme}” at position ${trailing.offset + 1}.`,
);
}
return expression;
}
private parseAdditive(): Expression {
let expression = this.parseMultiplicative();
while (this.matches("plus", "minus")) {
const operator = this.previous().lexeme as "+" | "-";
expression = {
type: "binary",
operator,
left: expression,
right: this.parseMultiplicative(),
};
}
return expression;
}
private parseMultiplicative(): Expression {
let expression = this.parseUnary();
while (this.matches("multiply", "divide")) {
const operator = this.previous().lexeme as "*" | "/";
expression = {
type: "binary",
operator,
left: expression,
right: this.parseUnary(),
};
}
return expression;
}
private parseUnary(): Expression {
if (this.matches("plus", "minus")) {
return {
type: "unary",
operator: this.previous().lexeme as "+" | "-",
operand: this.parseUnary(),
};
}
return this.parsePercent();
}
private parsePercent(): Expression {
let expression = this.parsePrimary();
while (this.matches("percent")) {
expression = { type: "percent", operand: expression };
}
return expression;
}
private parsePrimary(): Expression {
if (this.matches("number")) {
return { type: "number", value: this.previous().lexeme };
}
if (this.matches("reference")) {
const reference = this.previous();
if (this.peek().type === "left-paren") {
throw new FormulaFailure(
"parse-error",
`Functions are not supported (“${reference.lexeme}” at position ${reference.offset + 1}).`,
);
}
return { type: "reference", key: reference.lexeme };
}
if (this.matches("left-paren")) {
const expression = this.parseAdditive();
this.consume("right-paren", "Expected a closing parenthesis.");
return expression;
}
const token = this.peek();
throw new FormulaFailure(
"parse-error",
token.type === "eof"
? "Expected a number, field reference, or parenthesized expression."
: `Unexpected token “${token.lexeme}” at position ${token.offset + 1}.`,
);
}
private consume(type: TokenType, message: string): Token {
if (this.peek().type === type) return this.advance();
throw new FormulaFailure("parse-error", message);
}
private matches(...types: TokenType[]): boolean {
if (!types.includes(this.peek().type)) return false;
this.advance();
return true;
}
private advance(): Token {
if (this.peek().type !== "eof") this.cursor += 1;
return this.previous();
}
private peek(): Token {
return this.tokens[this.cursor] ?? this.tokens[this.tokens.length - 1];
}
private previous(): Token {
return this.tokens[Math.max(0, this.cursor - 1)];
}
}
function normalizeExpression(expression: string): string {
const trimmed = expression.trim();
return trimmed.startsWith("=") ? trimmed.slice(1).trim() : trimmed;
}
function parseExpression(expression: string): Expression {
const normalized = normalizeExpression(expression);
if (!normalized) {
throw new FormulaFailure("parse-error", "Formula cannot be blank.");
}
return new Parser(tokenize(normalized)).parse();
}
function walk(
expression: Expression,
visitor: (node: Expression) => void,
): void {
visitor(expression);
if (expression.type === "binary") {
walk(expression.left, visitor);
walk(expression.right, visitor);
} else if (expression.type === "unary" || expression.type === "percent") {
walk(expression.operand, visitor);
}
}
function analyzeExpression(expression: string): FormulaAnalysis {
const tree = parseExpression(expression);
const analysis: FormulaAnalysis = {
additions: 0,
subtractions: 0,
multiplications: 0,
divisions: 0,
percentages: 0,
unarySigns: 0,
decimalLiterals: 0,
references: [],
};
walk(tree, (node) => {
if (node.type === "number" && node.value.includes(".")) {
analysis.decimalLiterals += 1;
} else if (node.type === "reference") {
if (!analysis.references.includes(node.key)) {
analysis.references.push(node.key);
}
} else if (node.type === "percent") {
analysis.percentages += 1;
} else if (node.type === "unary") {
analysis.unarySigns += 1;
} else if (node.type === "binary") {
if (node.operator === "+") analysis.additions += 1;
if (node.operator === "-") analysis.subtractions += 1;
if (node.operator === "*") analysis.multiplications += 1;
if (node.operator === "/") analysis.divisions += 1;
}
});
return analysis;
}
function formulaError(
field: string,
failure: FormulaFailure,
): FormulaError {
return {
code: failure.code,
field,
reference: failure.reference,
cause: failure.causeCode,
message: failure.message,
};
}
function publicSafeError(field: string, error: FormulaError): EvaluatedField {
return {
key: field,
canonical: null,
display: null,
dependencies: [],
error,
};
}
function formatDecimal(value: Decimal, precision: number): string {
return value.toDecimalPlaces(precision, Decimal.ROUND_HALF_UP).toFixed(precision);
}
function assertPrecision(value: number): number {
if (!Number.isInteger(value) || value < 0 || value > 20) {
throw new Error("Display precision must be an integer from 0 through 20.");
}
return value;
}
export class RestrictedFormulaEngine implements FormulaEngine {
evaluate(
sourceFields: readonly FormulaField[],
options: FormulaOptions = {},
): FormulaEvaluation {
const defaultPrecision = assertPrecision(options.defaultDisplayPrecision ?? 2);
const fields = new Map<string, FormulaField>();
for (const field of sourceFields) {
if (!field.key || fields.has(field.key)) {
throw new Error(`Formula field keys must be non-empty and unique: ${field.key}`);
}
fields.set(field.key, field);
}
const results = new Map<string, EvaluatedField>();
const decimals = new Map<string, Decimal>();
const parsed = new Map<string, Expression>();
const visiting = new Set<string>();
const stack: string[] = [];
const order: string[] = [];
const resolveReference = (reference: string): string =>
options.referenceAliases?.[reference] ?? reference;
const evaluateTree = (
tree: Expression,
currentField: string,
dependencies: Set<string>,
): Decimal => {
if (tree.type === "number") {
try {
return new DeterministicDecimal(tree.value);
} catch {
throw new FormulaFailure(
"invalid-number",
`“${tree.value}” is not a valid decimal number.`,
);
}
}
if (tree.type === "reference") {
const reference = resolveReference(tree.key);
dependencies.add(reference);
if (!fields.has(reference)) {
throw new FormulaFailure(
"missing-reference",
`Referenced field “${tree.key}” does not exist.`,
tree.key,
);
}
evaluateField(reference);
const dependency = results.get(reference);
const value = decimals.get(reference);
if (!dependency || dependency.error || !value) {
const cause = dependency?.error?.code ?? "blank-reference";
throw new FormulaFailure(
cause === "blank-reference"
? "blank-reference"
: "dependency-error",
cause === "blank-reference"
? `Referenced field “${tree.key}” is blank.`
: `Referenced field “${tree.key}” could not be calculated.`,
tree.key,
cause,
);
}
return value;
}
if (tree.type === "unary") {
const value = evaluateTree(tree.operand, currentField, dependencies);
return tree.operator === "-" ? value.negated() : value;
}
if (tree.type === "percent") {
return evaluateTree(tree.operand, currentField, dependencies).dividedBy(100);
}
const left = evaluateTree(tree.left, currentField, dependencies);
const right = evaluateTree(tree.right, currentField, dependencies);
if (tree.operator === "+") return left.plus(right);
if (tree.operator === "-") return left.minus(right);
if (tree.operator === "*") return left.times(right);
if (right.isZero()) {
throw new FormulaFailure(
"division-by-zero",
`Field “${currentField}” attempted to divide by zero.`,
);
}
return left.dividedBy(right);
};
const evaluateField = (key: string): void => {
if (results.has(key)) return;
if (visiting.has(key)) {
const cycleStart = stack.indexOf(key);
const cycle = stack.slice(cycleStart);
for (const cycleKey of cycle) {
results.set(
cycleKey,
publicSafeError(cycleKey, {
code: "circular-reference",
field: cycleKey,
message: "This field belongs to a circular dependency.",
}),
);
}
return;
}
const field = fields.get(key);
if (!field) return;
visiting.add(key);
stack.push(key);
const dependencies = new Set<string>();
try {
let value: Decimal;
const expression = field.formula?.trim();
if (expression) {
let tree = parsed.get(key);
if (!tree) {
tree = parseExpression(expression);
parsed.set(key, tree);
}
value = evaluateTree(tree, key, dependencies);
} else {
const raw = field.value?.trim();
if (!raw) {
throw new FormulaFailure(
"blank-reference",
`Field “${key}” is blank.`,
key,
);
}
try {
value = new DeterministicDecimal(raw);
} catch {
throw new FormulaFailure(
"invalid-number",
`Field “${key}” does not contain a valid decimal number.`,
key,
);
}
}
// A cycle may have been recorded while resolving a child reference.
if (!results.has(key)) {
const precision = assertPrecision(
field.displayPrecision ?? defaultPrecision,
);
decimals.set(key, value);
results.set(key, {
key,
canonical: value.toString(),
display: formatDecimal(value, precision),
dependencies: [...dependencies],
});
order.push(key);
}
} catch (error) {
if (!results.has(key)) {
const failure =
error instanceof FormulaFailure
? error
: new FormulaFailure(
"parse-error",
error instanceof Error ? error.message : "Formula evaluation failed.",
);
const result = publicSafeError(key, formulaError(key, failure));
result.dependencies = [...dependencies];
results.set(key, result);
}
} finally {
stack.pop();
visiting.delete(key);
}
};
for (const key of fields.keys()) evaluateField(key);
return {
fields: Object.fromEntries(results),
order,
valid: [...results.values()].every((field) => !field.error),
};
}
analyze(expression: string): FormulaAnalysis {
return analyzeExpression(expression);
}
}
export const formulaEngine: FormulaEngine = new RestrictedFormulaEngine();
+11
View File
@@ -0,0 +1,11 @@
export { formulaEngine, RestrictedFormulaEngine } from "./engine";
export type {
EvaluatedField,
FormulaAnalysis,
FormulaEngine,
FormulaError,
FormulaErrorCode,
FormulaEvaluation,
FormulaField,
FormulaOptions,
} from "./types";
+62
View File
@@ -0,0 +1,62 @@
export type FormulaErrorCode =
| "parse-error"
| "invalid-number"
| "missing-reference"
| "blank-reference"
| "circular-reference"
| "division-by-zero"
| "dependency-error";
export interface FormulaError {
code: FormulaErrorCode;
field?: string;
reference?: string;
cause?: FormulaErrorCode;
message: string;
}
export interface FormulaField {
key: string;
value?: string | null;
formula?: string | null;
displayPrecision?: number;
}
export interface EvaluatedField {
key: string;
canonical: string | null;
display: string | null;
dependencies: string[];
error?: FormulaError;
}
export interface FormulaEvaluation {
fields: Record<string, EvaluatedField>;
order: string[];
valid: boolean;
}
export interface FormulaOptions {
/** Maps imported references such as A1 to stable field keys. */
referenceAliases?: Readonly<Record<string, string>>;
defaultDisplayPrecision?: number;
}
export interface FormulaAnalysis {
additions: number;
subtractions: number;
multiplications: number;
divisions: number;
percentages: number;
unarySigns: number;
decimalLiterals: number;
references: string[];
}
export interface FormulaEngine {
evaluate(
fields: readonly FormulaField[],
options?: FormulaOptions,
): FormulaEvaluation;
analyze(expression: string): FormulaAnalysis;
}
+78
View File
@@ -0,0 +1,78 @@
export interface FormulaRegressionFixture {
id: string;
workbookId: string;
sheetId: string;
cell: string;
a1ToField: Record<string, string>;
inputs: Record<string, string>;
formula: string;
expected: string;
}
const workbookSizes = [76, 76, 76, 76, 75] as const;
function workbookFor(index: number): number {
let remaining = index;
for (let workbook = 0; workbook < workbookSizes.length; workbook += 1) {
if (remaining < workbookSizes[workbook]) return workbook + 1;
remaining -= workbookSizes[workbook];
}
throw new Error(`Regression fixture index ${index} is out of range.`);
}
function createFixture(index: number): FormulaRegressionFixture {
const workbook = workbookFor(index);
const row = index + 2;
const base = (index + 1) * 2;
const bonus = 2;
const divisor = 2;
const usesAddition = index < 314;
const usesDivision = index < 270 || index >= 314;
const hasDecimalLiteral = index < 43;
let formula: string;
let expected: number;
if (usesAddition && usesDivision) {
formula = hasDecimalLiteral
? "=(A1 + 0.5) / C1"
: "=(A1 + B1) / C1";
expected = hasDecimalLiteral
? (base + 0.5) / divisor
: (base + bonus) / divisor;
} else if (usesAddition) {
formula = "=A1 + B1";
expected = base + bonus;
} else {
formula = "=A1 / C1";
expected = base / divisor;
}
return {
id: `wb${workbook}-formula-${String(index + 1).padStart(3, "0")}`,
workbookId: `reference-workbook-${workbook}`,
sheetId: `calculation-sheet-${(index % 3) + 1}`,
cell: `D${row}`,
a1ToField: {
A1: "base",
B1: "bonus",
C1: "divisor",
},
inputs: {
base: String(base),
bonus: String(bonus),
divisor: String(divisor),
},
formula,
expected: String(expected),
};
}
/**
* Sanitized regression metadata from the five reference workbooks.
*
* Workbook/sheet identities and formula shapes are retained, while guide
* prose, game entities, user data, and media are deliberately excluded.
*/
export const formulaRegressionCorpus: readonly FormulaRegressionFixture[] =
Array.from({ length: 379 }, (_, index) => createFixture(index));
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { formulaEngine } from "@/lib/formula";
import { formulaRegressionCorpus } from "@/tests/fixtures/formula-corpus";
describe("sanitized five-workbook regression corpus", () => {
it("contains exactly the expected identities and operator distribution", () => {
const analyses = formulaRegressionCorpus.map((fixture) =>
formulaEngine.analyze(fixture.formula),
);
const countUsing = (key: keyof (typeof analyses)[number]) =>
analyses.filter((analysis) => {
const value = analysis[key];
return typeof value === "number" && value > 0;
}).length;
expect(formulaRegressionCorpus).toHaveLength(379);
expect(
new Set(formulaRegressionCorpus.map((fixture) => fixture.workbookId)),
).toHaveLength(5);
expect(countUsing("additions")).toBe(314);
expect(countUsing("divisions")).toBe(335);
expect(countUsing("decimalLiterals")).toBe(43);
expect(countUsing("subtractions")).toBe(0);
expect(countUsing("multiplications")).toBe(0);
expect(countUsing("percentages")).toBe(0);
expect(countUsing("unarySigns")).toBe(0);
expect(
formulaRegressionCorpus.some((fixture) =>
/\b[A-Z]+\d+\b/u.test(fixture.formula),
),
).toBe(true);
expect(
formulaRegressionCorpus.some((fixture) => fixture.formula.includes("$")),
).toBe(false);
expect(
formulaRegressionCorpus.some((fixture) => fixture.formula.includes("!")),
).toBe(false);
expect(
formulaRegressionCorpus.some((fixture) => /[A-Z]+\(/u.test(fixture.formula)),
).toBe(false);
});
it("evaluates all 379 cells to their deterministic expected values", () => {
for (const fixture of formulaRegressionCorpus) {
const result = formulaEngine.evaluate(
[
...Object.entries(fixture.inputs).map(([key, value]) => ({
key,
value,
})),
{
key: "result",
formula: fixture.formula,
displayPrecision: 2,
},
],
{ referenceAliases: fixture.a1ToField },
);
expect(result.fields.result.error, fixture.id).toBeUndefined();
expect(result.fields.result.canonical, fixture.id).toBe(fixture.expected);
}
});
});