@crossworks/content-core 0.230.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +135 -0
- package/package.json +41 -0
- package/src/block-diff.test.ts +190 -0
- package/src/block-diff.ts +163 -0
- package/src/block-ids.test.ts +358 -0
- package/src/block-ids.ts +242 -0
- package/src/block-list.test.ts +241 -0
- package/src/block-list.ts +177 -0
- package/src/contacts-format.ts +260 -0
- package/src/doc-to-markdown.test.ts +194 -0
- package/src/doc-to-markdown.ts +315 -0
- package/src/formula-dimensions.test.ts +103 -0
- package/src/formula-dimensions.ts +231 -0
- package/src/formula-eval.ts +294 -0
- package/src/formula-seed.test.ts +175 -0
- package/src/formula-seed.ts +466 -0
- package/src/formula-signature.test.ts +336 -0
- package/src/formula-signature.ts +435 -0
- package/src/formula-spec.test.ts +458 -0
- package/src/formula-spec.ts +566 -0
- package/src/journal-options.test.ts +57 -0
- package/src/journal-options.ts +77 -0
- package/src/markdown-refs.test.ts +143 -0
- package/src/markdown-refs.ts +172 -0
- package/src/markdown-to-doc.test.ts +179 -0
- package/src/markdown-to-doc.ts +567 -0
- package/src/onboarding-questions.test.ts +75 -0
- package/src/onboarding-questions.ts +90 -0
- package/src/page-diff.test.ts +82 -0
- package/src/page-diff.ts +120 -0
- package/src/page-split.test.ts +141 -0
- package/src/page-split.ts +128 -0
- package/src/page-toc.test.ts +58 -0
- package/src/page-toc.ts +89 -0
- package/src/persona-bank.test.ts +67 -0
- package/src/persona-bank.ts +234 -0
- package/src/table-formula-mathjs.ts +259 -0
- package/src/table-formula.test.ts +157 -0
- package/src/table-formula.ts +496 -0
- package/src/table-model.test.ts +429 -0
- package/src/table-model.ts +870 -0
- package/src/thinking-tiers.ts +56 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dimensional analysis for a FormulaSpec — the reason mathjs is in this repo.
|
|
3
|
+
*
|
|
4
|
+
* Until now `unit` was a string carried for display. This turns it into a
|
|
5
|
+
* constraint: evaluate each expression with unit-bearing quantities and check
|
|
6
|
+
* the result's dimension against the declared one. A dropped term inside a
|
|
7
|
+
* `sqrt`, a mislabelled constant, a gauge/absolute mix-up — all change the
|
|
8
|
+
* result's dimension, and all are otherwise invisible until someone
|
|
9
|
+
* recomputes by hand.
|
|
10
|
+
*
|
|
11
|
+
* The motivating case is real: `g_c` was recorded as `ft/s2` when it is the
|
|
12
|
+
* gravitational conversion constant `lbm ft/(lbf s^2)`. Numerically identical
|
|
13
|
+
* in USC, so every test passed and every number was right — and any SI port
|
|
14
|
+
* would have been silently out by a factor of 3.13. This check rejects it.
|
|
15
|
+
*
|
|
16
|
+
* Reported SEPARATELY from `parseFormulaSpec`, like `checkLookupCoverage`:
|
|
17
|
+
* units are optional, older specs carry prose, and an unlabelled spec is
|
|
18
|
+
* incomplete rather than invalid.
|
|
19
|
+
*/
|
|
20
|
+
import { create, all, type FactoryFunctionMap, type MathJsInstance } from 'mathjs';
|
|
21
|
+
import type { FormulaSpec, SpecVariable } from './formula-spec';
|
|
22
|
+
|
|
23
|
+
const ALL_FACTORIES = all as FactoryFunctionMap;
|
|
24
|
+
|
|
25
|
+
let instance: {
|
|
26
|
+
math: MathJsInstance;
|
|
27
|
+
compile: (e: string) => { evaluate: (s: Record<string, unknown>) => unknown };
|
|
28
|
+
} | null = null;
|
|
29
|
+
|
|
30
|
+
function engine() {
|
|
31
|
+
if (!instance) {
|
|
32
|
+
const math = create(ALL_FACTORIES, { predictable: false });
|
|
33
|
+
// The uppercase vocabulary, bound to mathjs's OWN unit-aware functions.
|
|
34
|
+
// Deliberately not the same implementations as the table engine, which
|
|
35
|
+
// uses Math.sqrt so that an out-of-domain input yields NaN and renders a
|
|
36
|
+
// blank cell. Here `sqrt` must understand units — sqrt(lb^2/s^2) is lb/s,
|
|
37
|
+
// and that is the whole point.
|
|
38
|
+
math.import(
|
|
39
|
+
{
|
|
40
|
+
SQRT: math.sqrt,
|
|
41
|
+
ABS: math.abs,
|
|
42
|
+
MIN: math.min,
|
|
43
|
+
MAX: math.max,
|
|
44
|
+
SUM: math.add,
|
|
45
|
+
ROUND: (x: unknown) => x, // rounding cannot change a dimension
|
|
46
|
+
FLOOR: (x: unknown) => x,
|
|
47
|
+
CEIL: (x: unknown) => x,
|
|
48
|
+
POW: math.pow,
|
|
49
|
+
LN: math.log,
|
|
50
|
+
LOG10: math.log10,
|
|
51
|
+
EXP: math.exp,
|
|
52
|
+
IF: (_c: unknown, a: unknown) => a, // both branches must share a dimension
|
|
53
|
+
PI: Math.PI,
|
|
54
|
+
E: Math.E,
|
|
55
|
+
},
|
|
56
|
+
{ override: true },
|
|
57
|
+
);
|
|
58
|
+
const compile = math.compile.bind(math) as (e: string) => {
|
|
59
|
+
evaluate: (s: Record<string, unknown>) => unknown;
|
|
60
|
+
};
|
|
61
|
+
instance = { math, compile };
|
|
62
|
+
}
|
|
63
|
+
return instance;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Normalise a human-written unit into something mathjs parses.
|
|
68
|
+
*
|
|
69
|
+
* Specs are transcribed from printed tables, where units are written for
|
|
70
|
+
* people: `lbm-ft/(lbf-s2)`, `lb/ft3`, `lbf/in2 (abs)`. Rejecting those would
|
|
71
|
+
* mean retyping every spec in mathjs syntax, so the common conventions are
|
|
72
|
+
* translated instead — hyphen-as-multiply, implicit exponents, and the
|
|
73
|
+
* parenthetical qualifiers (abs/g/gauge) that carry a pressure BASIS rather
|
|
74
|
+
* than a dimension.
|
|
75
|
+
*/
|
|
76
|
+
export function normaliseUnit(raw: string): string | null {
|
|
77
|
+
let u = raw.trim();
|
|
78
|
+
if (!u) return null;
|
|
79
|
+
// Drop trailing qualifiers: "lbf/in2 (abs)" → "lbf/in2". The basis matters
|
|
80
|
+
// enormously (see the gauge/absolute finding) but it is not a dimension, so
|
|
81
|
+
// it cannot be expressed here — that is what two distinct symbols are for.
|
|
82
|
+
u = u.replace(/\s*\((?:abs|absolute|g|gauge|a)\)\s*$/i, '').trim();
|
|
83
|
+
if (!u || u === '-' || /^unitless$/i.test(u)) return null;
|
|
84
|
+
// Hyphen between unit tokens means multiply: "lbm-ft" → "lbm ft".
|
|
85
|
+
u = u.replace(/(?<=[A-Za-z0-9)])-(?=[A-Za-z])/g, ' ');
|
|
86
|
+
// Implicit exponent: "ft3" → "ft^3", "in2" → "in^2", "s2" → "s^2".
|
|
87
|
+
u = u.replace(/([A-Za-z])(\d+)(?![\d^])/g, '$1^$2');
|
|
88
|
+
// Common spellings mathjs does not use.
|
|
89
|
+
u = u.replace(/\bsec\b/g, 's').replace(/\bmol\b/g, 'mol');
|
|
90
|
+
// Rankine is `degR`; a bare "R" would parse as roentgen.
|
|
91
|
+
u = u.replace(/^°?R$/, 'degR').replace(/·/g, ' ');
|
|
92
|
+
return u;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A quantity of 1 in the given unit, or null if it isn't parseable. */
|
|
96
|
+
function unitQuantity(math: MathJsInstance, raw: string | null | undefined): unknown | null {
|
|
97
|
+
if (!raw) return null;
|
|
98
|
+
const norm = normaliseUnit(raw);
|
|
99
|
+
if (!norm) return null;
|
|
100
|
+
try {
|
|
101
|
+
return math.evaluate(`1 ${norm}`);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface DimensionIssue {
|
|
108
|
+
/** Expression (or derived-variable) id the problem belongs to. */
|
|
109
|
+
id: string;
|
|
110
|
+
kind: 'mismatch' | 'unparseable-unit' | 'inconsistent';
|
|
111
|
+
declared: string | null;
|
|
112
|
+
actual: string | null;
|
|
113
|
+
detail: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function refsOf(src: string): string[] {
|
|
117
|
+
const out: string[] = [];
|
|
118
|
+
for (const m of src.matchAll(/\{([^}]*)\}/g)) {
|
|
119
|
+
const name = (m[1] ?? '').trim();
|
|
120
|
+
if (name && !out.includes(name)) out.push(name);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check every expression that declares a result unit. Returns one issue per
|
|
127
|
+
* problem; an empty array means every declared unit is consistent with the
|
|
128
|
+
* arithmetic that produces it.
|
|
129
|
+
*/
|
|
130
|
+
export function checkDimensions(spec: FormulaSpec): DimensionIssue[] {
|
|
131
|
+
const issues: DimensionIssue[] = [];
|
|
132
|
+
const { math, compile } = engine();
|
|
133
|
+
const bySymbol = new Map<string, SpecVariable>();
|
|
134
|
+
for (const v of spec.variables) bySymbol.set(v.symbol, v);
|
|
135
|
+
|
|
136
|
+
// Surface a unit we cannot read ONCE per variable, rather than as a cascade
|
|
137
|
+
// of confusing mismatches downstream.
|
|
138
|
+
for (const v of spec.variables) {
|
|
139
|
+
if (!v.unit) continue;
|
|
140
|
+
const norm = normaliseUnit(v.unit);
|
|
141
|
+
if (norm && unitQuantity(math, v.unit) === null) {
|
|
142
|
+
issues.push({
|
|
143
|
+
id: v.symbol,
|
|
144
|
+
kind: 'unparseable-unit',
|
|
145
|
+
declared: v.unit,
|
|
146
|
+
actual: null,
|
|
147
|
+
detail: `could not interpret the unit '${v.unit}' (read as '${norm}') — dimensional checking skipped for anything using it`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const expr of spec.expressions) {
|
|
153
|
+
if (!expr.unit) continue;
|
|
154
|
+
const expected = unitQuantity(math, expr.unit);
|
|
155
|
+
if (expected === null) {
|
|
156
|
+
issues.push({
|
|
157
|
+
id: expr.id,
|
|
158
|
+
kind: 'unparseable-unit',
|
|
159
|
+
declared: expr.unit,
|
|
160
|
+
actual: null,
|
|
161
|
+
detail: `could not interpret the declared result unit '${expr.unit}'`,
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Bind each reference to 1-of-its-unit. Magnitudes are irrelevant — only
|
|
167
|
+
// dimensions are under test — but a variable with no unit binds as a plain
|
|
168
|
+
// 1 so a partially-annotated spec still checks what it can.
|
|
169
|
+
const scope: Record<string, unknown> = {};
|
|
170
|
+
let code = expr.expression;
|
|
171
|
+
let skip = false;
|
|
172
|
+
refsOf(expr.expression).forEach((name, i) => {
|
|
173
|
+
const variable = bySymbol.get(name);
|
|
174
|
+
const q = unitQuantity(math, variable?.unit ?? null);
|
|
175
|
+
if (variable?.unit && q === null) skip = true;
|
|
176
|
+
scope[`__d${i}`] = q ?? 1;
|
|
177
|
+
code = code.split(`{${name}}`).join(`__d${i}`);
|
|
178
|
+
});
|
|
179
|
+
if (skip) continue; // already reported as unparseable above
|
|
180
|
+
|
|
181
|
+
let actual: unknown;
|
|
182
|
+
try {
|
|
183
|
+
actual = compile(code).evaluate(scope);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
issues.push({
|
|
186
|
+
id: expr.id,
|
|
187
|
+
kind: 'inconsistent',
|
|
188
|
+
declared: expr.unit,
|
|
189
|
+
actual: null,
|
|
190
|
+
detail: `the expression is not dimensionally consistent: ${
|
|
191
|
+
err instanceof Error ? err.message : String(err)
|
|
192
|
+
}`,
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// `equalBase` is the real test: same dimension, regardless of magnitude or
|
|
198
|
+
// prefix. Comparing formatted strings would reject ft vs in.
|
|
199
|
+
try {
|
|
200
|
+
const a = actual as { equalBase?: (o: unknown) => boolean; toString: () => string };
|
|
201
|
+
const same =
|
|
202
|
+
typeof a?.equalBase === 'function'
|
|
203
|
+
? a.equalBase(expected)
|
|
204
|
+
: // A dimensionless result vs a declared unit: only equal if the
|
|
205
|
+
// declared unit is itself dimensionless.
|
|
206
|
+
typeof actual === 'number' &&
|
|
207
|
+
typeof (expected as { equalBase?: unknown }).equalBase !== 'function';
|
|
208
|
+
if (!same) {
|
|
209
|
+
issues.push({
|
|
210
|
+
id: expr.id,
|
|
211
|
+
kind: 'mismatch',
|
|
212
|
+
declared: expr.unit,
|
|
213
|
+
actual: typeof actual === 'number' ? 'dimensionless' : String(a?.toString?.() ?? actual),
|
|
214
|
+
detail: `declares '${expr.unit}' but the arithmetic produces ${
|
|
215
|
+
typeof actual === 'number' ? 'a dimensionless value' : `'${a.toString()}'`
|
|
216
|
+
} — a term is missing, or a variable's unit is wrong`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
issues.push({
|
|
221
|
+
id: expr.id,
|
|
222
|
+
kind: 'inconsistent',
|
|
223
|
+
declared: expr.unit,
|
|
224
|
+
actual: null,
|
|
225
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return issues;
|
|
231
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluator for a FormulaSpec. Pure — no I/O, no DB, no clock.
|
|
3
|
+
*
|
|
4
|
+
* Two behaviours that deliberately differ from table formula columns:
|
|
5
|
+
*
|
|
6
|
+
* 1. IT FAILS LOUD. `evalFormula` returns null on any problem, which is right
|
|
7
|
+
* for a spreadsheet cell: a broken formula renders blank and the user moves
|
|
8
|
+
* on. It is wrong here. A blank release rate on a risk assessment looks like
|
|
9
|
+
* a small number, and an unresolved symbol silently reading as zero is how a
|
|
10
|
+
* calculation gets quietly wrong for a year. Every failure returns an
|
|
11
|
+
* explicit error instead.
|
|
12
|
+
*
|
|
13
|
+
* 2. SYMBOLS ARE CASE-SENSITIVE. Table columns match case-insensitively, which
|
|
14
|
+
* is friendly for `{qty}` vs `{Qty}`. Engineering notation does not have
|
|
15
|
+
* that luxury — in the vapour equations `k` is the specific heat ratio and
|
|
16
|
+
* `K` is a correction factor. A near-miss must be an error, not a guess.
|
|
17
|
+
*
|
|
18
|
+
* Every evaluation returns a trace: which branch was taken, which lookup row
|
|
19
|
+
* matched, what each symbol resolved to. An engineering number that cannot be
|
|
20
|
+
* explained is not worth much, and the trace is what lets a result be shown
|
|
21
|
+
* with its derivation rather than asserted.
|
|
22
|
+
*/
|
|
23
|
+
import { evalExpression, truthy, type EvalValue, type RefResolver } from './table-formula';
|
|
24
|
+
import type { FormulaSpec, FormulaValue, SpecLookup } from './formula-spec';
|
|
25
|
+
|
|
26
|
+
export type TraceStep =
|
|
27
|
+
| {
|
|
28
|
+
kind: 'symbol';
|
|
29
|
+
symbol: string;
|
|
30
|
+
value: FormulaValue;
|
|
31
|
+
from: 'input' | 'constant' | 'default' | 'derived' | 'produced';
|
|
32
|
+
expression?: string;
|
|
33
|
+
}
|
|
34
|
+
| { kind: 'expression'; id: string; expression: string; value: FormulaValue; equation?: string }
|
|
35
|
+
| { kind: 'branch'; id: string; when: string; chose: string; label?: string }
|
|
36
|
+
| {
|
|
37
|
+
kind: 'lookup';
|
|
38
|
+
id: string;
|
|
39
|
+
key: Record<string, FormulaValue>;
|
|
40
|
+
value: FormulaValue;
|
|
41
|
+
row?: Record<string, FormulaValue>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type EvalResult =
|
|
45
|
+
| { ok: true; value: FormulaValue; trace: TraceStep[] }
|
|
46
|
+
| { ok: false; error: string; trace: TraceStep[] };
|
|
47
|
+
|
|
48
|
+
class SpecError extends Error {}
|
|
49
|
+
|
|
50
|
+
class SpecEvaluator {
|
|
51
|
+
private cache = new Map<string, EvalValue>();
|
|
52
|
+
private resolving = new Set<string>();
|
|
53
|
+
private resolvingTargets = new Set<string>();
|
|
54
|
+
readonly trace: TraceStep[] = [];
|
|
55
|
+
/** resultSymbol → ids of targets declaring it, for chaining. */
|
|
56
|
+
private producers = new Map<string, string[]>();
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
private spec: FormulaSpec,
|
|
60
|
+
private inputs: Record<string, FormulaValue>,
|
|
61
|
+
) {
|
|
62
|
+
const claim = (symbol: string | undefined, id: string) => {
|
|
63
|
+
if (!symbol) return;
|
|
64
|
+
this.producers.set(symbol, [...(this.producers.get(symbol) ?? []), id]);
|
|
65
|
+
};
|
|
66
|
+
for (const e of spec.expressions) claim(e.resultSymbol, e.id);
|
|
67
|
+
for (const p of spec.piecewise) claim(p.resultSymbol, p.id);
|
|
68
|
+
for (const l of spec.lookups) claim(l.resultSymbol, l.id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private resolver: RefResolver = (name) => this.resolveSymbol(name);
|
|
72
|
+
|
|
73
|
+
private resolveSymbol(symbol: string): EvalValue {
|
|
74
|
+
if (this.cache.has(symbol)) return this.cache.get(symbol)!;
|
|
75
|
+
if (this.resolving.has(symbol)) {
|
|
76
|
+
throw new SpecError(
|
|
77
|
+
`circular reference resolving '${symbol}' (via ${[...this.resolving].join(' → ')})`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A supplied input always wins, so a caller can override a constant or
|
|
82
|
+
// short-circuit a chain by handing in a value computed elsewhere.
|
|
83
|
+
//
|
|
84
|
+
// But an EMPTY input is not a supplied one. `Object.hasOwn` alone would
|
|
85
|
+
// treat `{"Pgauge": null}` as provided, and `toNum(null)` is 0 — so a form
|
|
86
|
+
// with a blank field, or a JSON body carrying an explicit null, produced a
|
|
87
|
+
// release rate of exactly zero reported as success. That is precisely the
|
|
88
|
+
// silent-zero failure this module exists to prevent, so null / undefined /
|
|
89
|
+
// '' are treated as absent and fall through to the missing-input error.
|
|
90
|
+
if (Object.hasOwn(this.inputs, symbol)) {
|
|
91
|
+
const supplied = this.inputs[symbol];
|
|
92
|
+
const blank = supplied === null || supplied === undefined || supplied === '';
|
|
93
|
+
if (!blank) {
|
|
94
|
+
this.cache.set(symbol, supplied);
|
|
95
|
+
this.trace.push({ kind: 'symbol', symbol, value: supplied, from: 'input' });
|
|
96
|
+
return supplied;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const variable = this.spec.variables.find((v) => v.symbol === symbol);
|
|
101
|
+
if (variable) {
|
|
102
|
+
if (variable.role === 'constant') {
|
|
103
|
+
const value = (variable.value ?? null) as EvalValue;
|
|
104
|
+
this.cache.set(symbol, value);
|
|
105
|
+
this.trace.push({ kind: 'symbol', symbol, value, from: 'constant' });
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
if (variable.role === 'derived') {
|
|
109
|
+
this.resolving.add(symbol);
|
|
110
|
+
try {
|
|
111
|
+
const value = evalExpression(variable.expression!, this.resolver);
|
|
112
|
+
this.cache.set(symbol, value);
|
|
113
|
+
this.trace.push({
|
|
114
|
+
kind: 'symbol',
|
|
115
|
+
symbol,
|
|
116
|
+
value: value as FormulaValue,
|
|
117
|
+
from: 'derived',
|
|
118
|
+
expression: variable.expression,
|
|
119
|
+
});
|
|
120
|
+
return value;
|
|
121
|
+
} finally {
|
|
122
|
+
this.resolving.delete(symbol);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (variable.role === 'input') {
|
|
126
|
+
if (variable.value !== undefined) {
|
|
127
|
+
const value = variable.value as EvalValue;
|
|
128
|
+
this.cache.set(symbol, value);
|
|
129
|
+
this.trace.push({ kind: 'symbol', symbol, value, from: 'default' });
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
throw new SpecError(`missing required input '${symbol}'${unitHint(variable.unit)}`);
|
|
133
|
+
}
|
|
134
|
+
// role 'output' falls through to chaining below.
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const producedBy = this.producers.get(symbol) ?? [];
|
|
138
|
+
if (producedBy.length === 1) {
|
|
139
|
+
this.resolving.add(symbol);
|
|
140
|
+
try {
|
|
141
|
+
const value = this.evalTarget(producedBy[0]!);
|
|
142
|
+
this.cache.set(symbol, value as EvalValue);
|
|
143
|
+
this.trace.push({ kind: 'symbol', symbol, value, from: 'produced' });
|
|
144
|
+
return value as EvalValue;
|
|
145
|
+
} finally {
|
|
146
|
+
this.resolving.delete(symbol);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (producedBy.length > 1) {
|
|
150
|
+
throw new SpecError(
|
|
151
|
+
`'${symbol}' is produced by more than one target (${producedBy.join(', ')}); ` +
|
|
152
|
+
`supply it as an input to say which`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
throw new SpecError(`unknown symbol '${symbol}'`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
evalTarget(id: string): FormulaValue {
|
|
159
|
+
// Targets recurse by id (a piecewise case names another target), but
|
|
160
|
+
// `resolving` only tracks SYMBOLS — so `p1 -> p1`, or a p1/p2 pair, blew
|
|
161
|
+
// the stack and returned thousands of junk trace steps with an error that
|
|
162
|
+
// said nothing useful. Guard the id edge with the same discipline.
|
|
163
|
+
if (this.resolvingTargets.has(id)) {
|
|
164
|
+
throw new SpecError(
|
|
165
|
+
`circular reference resolving target '${id}' (via ${[...this.resolvingTargets].join(' → ')})`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
this.resolvingTargets.add(id);
|
|
169
|
+
try {
|
|
170
|
+
return this.evalTargetInner(id);
|
|
171
|
+
} finally {
|
|
172
|
+
this.resolvingTargets.delete(id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private evalTargetInner(id: string): FormulaValue {
|
|
177
|
+
const expression = this.spec.expressions.find((e) => e.id === id);
|
|
178
|
+
if (expression) {
|
|
179
|
+
const value = evalExpression(expression.expression, this.resolver) as FormulaValue;
|
|
180
|
+
this.trace.push({
|
|
181
|
+
kind: 'expression',
|
|
182
|
+
id,
|
|
183
|
+
expression: expression.expression,
|
|
184
|
+
value,
|
|
185
|
+
equation: expression.equation,
|
|
186
|
+
});
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const piecewise = this.spec.piecewise.find((p) => p.id === id);
|
|
191
|
+
if (piecewise) {
|
|
192
|
+
for (const branch of piecewise.cases) {
|
|
193
|
+
if (truthy(evalExpression(branch.when, this.resolver))) {
|
|
194
|
+
this.trace.push({
|
|
195
|
+
kind: 'branch',
|
|
196
|
+
id,
|
|
197
|
+
when: branch.when,
|
|
198
|
+
chose: branch.use,
|
|
199
|
+
label: branch.label,
|
|
200
|
+
});
|
|
201
|
+
return this.evalTarget(branch.use);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (piecewise.otherwise) {
|
|
205
|
+
this.trace.push({ kind: 'branch', id, when: 'otherwise', chose: piecewise.otherwise });
|
|
206
|
+
return this.evalTarget(piecewise.otherwise);
|
|
207
|
+
}
|
|
208
|
+
throw new SpecError(`no case matched in '${id}' and no otherwise branch is defined`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const lookup = this.spec.lookups.find((l) => l.id === id);
|
|
212
|
+
if (lookup) return this.evalLookup(lookup);
|
|
213
|
+
|
|
214
|
+
throw new SpecError(`unknown target '${id}'`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private evalLookup(lookup: SpecLookup): FormulaValue {
|
|
218
|
+
const key: Record<string, FormulaValue> = {};
|
|
219
|
+
for (const k of lookup.keys) key[k] = this.resolveSymbol(k) as FormulaValue;
|
|
220
|
+
|
|
221
|
+
const row = lookup.rows.find((r) => lookup.keys.every((k) => r[k] === key[k]));
|
|
222
|
+
if (!row) {
|
|
223
|
+
if (lookup.onMiss === 'null') {
|
|
224
|
+
this.trace.push({ kind: 'lookup', id: lookup.id, key, value: null });
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const shown = lookup.keys.map((k) => `${k}=${String(key[k])}`).join(', ');
|
|
228
|
+
throw new SpecError(
|
|
229
|
+
`no row in '${lookup.id}' for ${shown} — the source table does not specify this combination`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const value = row[lookup.result] ?? null;
|
|
233
|
+
this.trace.push({ kind: 'lookup', id: lookup.id, key, value, row });
|
|
234
|
+
return value;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function unitHint(unit: string | null | undefined): string {
|
|
239
|
+
return unit ? ` (${unit})` : '';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Coerce ONE text-field input into the value `evaluateSpec` should receive.
|
|
244
|
+
*
|
|
245
|
+
* This exists exactly once because three UIs (the owner evaluator, the editor,
|
|
246
|
+
* the public share calculator) each grew their own copy with subtly different
|
|
247
|
+
* numeric regexes — `'1e-5'` was a number in two of them and a string in the
|
|
248
|
+
* third. Arithmetic forgives that (`toNum` re-parses strings), but lookup keys
|
|
249
|
+
* match with STRICT equality, so a numeric key supplied as a string silently
|
|
250
|
+
* matches no row.
|
|
251
|
+
*
|
|
252
|
+
* Rules: blank → `undefined` (absent — mirrors the evaluator's blank-input
|
|
253
|
+
* treatment above); `true`/`false` → boolean; anything that reads as a finite
|
|
254
|
+
* number → number; everything else stays the string it was ('1/4 in', 'A',
|
|
255
|
+
* '1.2.3').
|
|
256
|
+
*/
|
|
257
|
+
export function parseInputText(raw: string): FormulaValue | undefined {
|
|
258
|
+
const t = raw.trim();
|
|
259
|
+
if (t === '') return undefined;
|
|
260
|
+
if (t === 'true') return true;
|
|
261
|
+
if (t === 'false') return false;
|
|
262
|
+
if (!/^[-+]?[0-9.]+(e[-+]?[0-9]+)?$/i.test(t)) return t;
|
|
263
|
+
const n = Number(t);
|
|
264
|
+
return Number.isFinite(n) ? n : t;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Evaluate one target — an expression, a piecewise branch, or a lookup — by id.
|
|
269
|
+
* Inputs are keyed by symbol and override anything the spec declares.
|
|
270
|
+
*/
|
|
271
|
+
export function evaluateSpec(
|
|
272
|
+
spec: FormulaSpec,
|
|
273
|
+
targetId: string,
|
|
274
|
+
inputs: Record<string, FormulaValue> = {},
|
|
275
|
+
): EvalResult {
|
|
276
|
+
const evaluator = new SpecEvaluator(spec, inputs);
|
|
277
|
+
try {
|
|
278
|
+
const value = evaluator.evalTarget(targetId);
|
|
279
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
280
|
+
return {
|
|
281
|
+
ok: false,
|
|
282
|
+
error: `'${targetId}' evaluated to ${String(value)} — check for a divide by zero or an out-of-domain SQRT/LN`,
|
|
283
|
+
trace: evaluator.trace,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return { ok: true, value, trace: evaluator.trace };
|
|
287
|
+
} catch (err) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
error: err instanceof Error ? err.message : String(err),
|
|
291
|
+
trace: evaluator.trace,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { checkLookupCoverage, parseFormulaSpec, type FormulaSpec } from './formula-spec';
|
|
3
|
+
import { checkDimensions } from './formula-dimensions';
|
|
4
|
+
import { evaluateSpec } from './formula-eval';
|
|
5
|
+
import { signatureOf } from './formula-signature';
|
|
6
|
+
import { FORMULA_SEED, FORMULA_SEED_SLUGS, SEED_TAG } from './formula-seed';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The seed set is shipped content AND the regression suite. It is held to a
|
|
10
|
+
* higher bar than an owner's own formulas precisely because it is the example
|
|
11
|
+
* everyone learns the format from: it must parse clean, be dimensionally
|
|
12
|
+
* consistent, have no coverage gaps of its own, and produce the right NUMBERS.
|
|
13
|
+
*
|
|
14
|
+
* The arithmetic assertions are the valuable half. A change to the evaluator
|
|
15
|
+
* that quietly breaks exponentiation or lookup matching would otherwise surface
|
|
16
|
+
* on a live assessment rather than in CI.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const specOf = (raw: Record<string, unknown>): FormulaSpec => {
|
|
20
|
+
const parsed = parseFormulaSpec(raw);
|
|
21
|
+
if (!parsed.ok) throw new Error(parsed.errors.join('; '));
|
|
22
|
+
return parsed.spec;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('FORMULA_SEED', () => {
|
|
26
|
+
it('ships exactly five — the bank is owner-derived, this is only the primer', () => {
|
|
27
|
+
expect(FORMULA_SEED).toHaveLength(5);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('has unique slugs matching each spec id, so re-seeding can detect duplicates', () => {
|
|
31
|
+
expect(new Set(FORMULA_SEED_SLUGS).size).toBe(FORMULA_SEED.length);
|
|
32
|
+
for (const f of FORMULA_SEED) expect(f.spec.id).toBe(f.slug);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('tags every entry so the owner can find and clear the set as a group', () => {
|
|
36
|
+
for (const f of FORMULA_SEED) expect(f.tags).toContain(SEED_TAG);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it.each(FORMULA_SEED.map((f) => [f.slug, f] as const))('%s parses clean', (_slug, f) => {
|
|
40
|
+
const parsed = parseFormulaSpec(f.spec);
|
|
41
|
+
expect(parsed.ok ? [] : parsed.errors).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it.each(FORMULA_SEED.map((f) => [f.slug, f] as const))(
|
|
45
|
+
'%s is dimensionally consistent',
|
|
46
|
+
(_slug, f) => {
|
|
47
|
+
expect(checkDimensions(specOf(f.spec))).toEqual([]);
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
it.each(FORMULA_SEED.map((f) => [f.slug, f] as const))(
|
|
52
|
+
'%s has no coverage gaps of its own',
|
|
53
|
+
(_slug, f) => {
|
|
54
|
+
// An owner's transcription may legitimately have gaps (the SOURCE is
|
|
55
|
+
// incomplete). The teaching set must not — a gap here would read as the
|
|
56
|
+
// format being broken.
|
|
57
|
+
expect(checkLookupCoverage(specOf(f.spec))).toEqual([]);
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
it.each(FORMULA_SEED.map((f) => [f.slug, f] as const))(
|
|
62
|
+
'%s cites a source with an edition',
|
|
63
|
+
(_slug, f) => {
|
|
64
|
+
const source = specOf(f.spec).source;
|
|
65
|
+
expect(source?.standard).toBeTruthy();
|
|
66
|
+
// An equation number is part of a claim, and numbers move between
|
|
67
|
+
// editions — so the set practises what formula_authoring preaches.
|
|
68
|
+
expect(source?.edition).toBeTruthy();
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
it.each(FORMULA_SEED.map((f) => [f.slug, f] as const))(
|
|
73
|
+
'%s produces the right numbers',
|
|
74
|
+
(_slug, f) => {
|
|
75
|
+
expect(f.examples.length).toBeGreaterThan(0);
|
|
76
|
+
for (const ex of f.examples) {
|
|
77
|
+
const result = evaluateSpec(specOf(f.spec), ex.target, ex.inputs);
|
|
78
|
+
if (!result.ok) throw new Error(`${f.slug} / ${ex.target}: ${result.error}`);
|
|
79
|
+
expect(typeof result.value).toBe('number');
|
|
80
|
+
expect(result.value as number).toBeCloseTo(
|
|
81
|
+
ex.expected,
|
|
82
|
+
// toBeCloseTo takes digits; derive them from the stated tolerance.
|
|
83
|
+
Math.max(0, Math.round(-Math.log10(ex.tolerance ?? 1e-6))),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
it.each(FORMULA_SEED.map((f) => [f.slug, f] as const))(
|
|
90
|
+
'%s asks only for inputs its signature declares',
|
|
91
|
+
(_slug, f) => {
|
|
92
|
+
// Guards the contract the whole feature rests on: if an example supplies
|
|
93
|
+
// a symbol the signature never mentions, the two have diverged.
|
|
94
|
+
const spec = specOf(f.spec);
|
|
95
|
+
const sig = signatureOf(spec);
|
|
96
|
+
for (const ex of f.examples) {
|
|
97
|
+
const target = sig.find((s) => s.id === ex.target);
|
|
98
|
+
expect(target, `${f.slug}: no signature for target ${ex.target}`).toBeDefined();
|
|
99
|
+
const declared = new Set(target!.inputs.map((i) => i.symbol));
|
|
100
|
+
for (const supplied of Object.keys(ex.inputs)) {
|
|
101
|
+
expect(declared.has(supplied), `${f.slug}/${ex.target}: ${supplied} undeclared`).toBe(
|
|
102
|
+
true,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
// Every REQUIRED input must be covered by the example.
|
|
106
|
+
for (const required of target!.inputs.filter((i) => i.required)) {
|
|
107
|
+
expect(
|
|
108
|
+
Object.hasOwn(ex.inputs, required.symbol),
|
|
109
|
+
`${f.slug}/${ex.target}: missing required ${required.symbol}`,
|
|
110
|
+
).toBe(true);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The set's other job: between the five, exercise every part of the model. A
|
|
119
|
+
* teaching set with no piecewise teaches nothing about piecewise.
|
|
120
|
+
*/
|
|
121
|
+
describe('FORMULA_SEED — construct coverage', () => {
|
|
122
|
+
const specs = FORMULA_SEED.map((f) => specOf(f.spec));
|
|
123
|
+
const some = (p: (s: FormulaSpec) => boolean) => specs.some(p);
|
|
124
|
+
|
|
125
|
+
it('covers plain expressions and derived variables', () => {
|
|
126
|
+
expect(some((s) => s.expressions.length > 0)).toBe(true);
|
|
127
|
+
expect(some((s) => s.variables.some((v) => v.role === 'derived'))).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('covers constants and defaulted inputs', () => {
|
|
131
|
+
expect(some((s) => s.variables.some((v) => v.role === 'constant'))).toBe(true);
|
|
132
|
+
expect(some((s) => s.variables.some((v) => v.role === 'input' && v.value !== undefined))).toBe(
|
|
133
|
+
true,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('covers a piecewise branch', () => {
|
|
138
|
+
expect(some((s) => s.piecewise.length > 0)).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('covers a lookup with DECLARED domains — the reason coverage checking works', () => {
|
|
142
|
+
expect(some((s) => s.lookups.some((l) => l.domains && Object.keys(l.domains).length > 0))).toBe(
|
|
143
|
+
true,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('covers a classification with criteria prose', () => {
|
|
148
|
+
expect(some((s) => s.classifications.length > 0)).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('covers latex, notes, and units on results', () => {
|
|
152
|
+
expect(some((s) => s.expressions.some((e) => e.latex))).toBe(true);
|
|
153
|
+
expect(some((s) => Boolean(s.notes && Object.keys(s.notes).length > 0))).toBe(true);
|
|
154
|
+
expect(some((s) => s.expressions.some((e) => e.unit))).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('carries exactly one unverified equation — the warning has to be seeable', () => {
|
|
158
|
+
const unverified = specs.flatMap((s) => s.expressions.filter((e) => e.unverified));
|
|
159
|
+
expect(unverified).toHaveLength(1);
|
|
160
|
+
// And it must say WHY, since that text is what a reader acts on.
|
|
161
|
+
expect(unverified[0]!.unverified!.length).toBeGreaterThan(40);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('covers symbol chaining through a produced result', () => {
|
|
165
|
+
// pump-hydraulic-power's shaft power resolves Ph via the target producing it.
|
|
166
|
+
const chained = specs.some((s) =>
|
|
167
|
+
s.expressions.some((e) =>
|
|
168
|
+
s.expressions.some(
|
|
169
|
+
(o) => o.id !== e.id && o.resultSymbol && e.expression.includes(`{${o.resultSymbol}}`),
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
expect(chained).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
});
|