@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.
Files changed (44) hide show
  1. package/LICENSE.md +135 -0
  2. package/package.json +41 -0
  3. package/src/block-diff.test.ts +190 -0
  4. package/src/block-diff.ts +163 -0
  5. package/src/block-ids.test.ts +358 -0
  6. package/src/block-ids.ts +242 -0
  7. package/src/block-list.test.ts +241 -0
  8. package/src/block-list.ts +177 -0
  9. package/src/contacts-format.ts +260 -0
  10. package/src/doc-to-markdown.test.ts +194 -0
  11. package/src/doc-to-markdown.ts +315 -0
  12. package/src/formula-dimensions.test.ts +103 -0
  13. package/src/formula-dimensions.ts +231 -0
  14. package/src/formula-eval.ts +294 -0
  15. package/src/formula-seed.test.ts +175 -0
  16. package/src/formula-seed.ts +466 -0
  17. package/src/formula-signature.test.ts +336 -0
  18. package/src/formula-signature.ts +435 -0
  19. package/src/formula-spec.test.ts +458 -0
  20. package/src/formula-spec.ts +566 -0
  21. package/src/journal-options.test.ts +57 -0
  22. package/src/journal-options.ts +77 -0
  23. package/src/markdown-refs.test.ts +143 -0
  24. package/src/markdown-refs.ts +172 -0
  25. package/src/markdown-to-doc.test.ts +179 -0
  26. package/src/markdown-to-doc.ts +567 -0
  27. package/src/onboarding-questions.test.ts +75 -0
  28. package/src/onboarding-questions.ts +90 -0
  29. package/src/page-diff.test.ts +82 -0
  30. package/src/page-diff.ts +120 -0
  31. package/src/page-split.test.ts +141 -0
  32. package/src/page-split.ts +128 -0
  33. package/src/page-toc.test.ts +58 -0
  34. package/src/page-toc.ts +89 -0
  35. package/src/persona-bank.test.ts +67 -0
  36. package/src/persona-bank.ts +234 -0
  37. package/src/table-formula-mathjs.ts +259 -0
  38. package/src/table-formula.test.ts +157 -0
  39. package/src/table-formula.ts +496 -0
  40. package/src/table-model.test.ts +429 -0
  41. package/src/table-model.ts +870 -0
  42. package/src/thinking-tiers.ts +56 -0
  43. package/tsconfig.json +4 -0
  44. 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
+ });