@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,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The formula spec: a declarative description of a calculation that came out of
|
|
3
|
+
* a published standard, rather than a single expression string.
|
|
4
|
+
*
|
|
5
|
+
* A real engineering calculation is not one formula. Working from API RP 581
|
|
6
|
+
* Part 3 §5.3 as the motivating case, one "release quantity" model contains all
|
|
7
|
+
* four of these, and only the first is an expression:
|
|
8
|
+
*
|
|
9
|
+
* expressions scalar maths — the release-rate equations
|
|
10
|
+
* piecewise a branch — sonic vs subsonic, on a pressure threshold
|
|
11
|
+
* lookups keyed tables — a reduction factor per detection/isolation
|
|
12
|
+
* rating, a leak duration per rating AND hole size
|
|
13
|
+
* classifications prose rubrics mapping a described system to a rating.
|
|
14
|
+
* Human (or model) judgment, NOT arithmetic.
|
|
15
|
+
*
|
|
16
|
+
* Two decisions worth defending, because both were tempting to do otherwise:
|
|
17
|
+
*
|
|
18
|
+
* 1. Lookup tables are stored as DATA ROWS, never as a nested IF() chain in an
|
|
19
|
+
* expression. These tables come from a standard that gets revised; a changed
|
|
20
|
+
* factor should be a one-line diff a reviewer can hold against the printed
|
|
21
|
+
* table, not a re-reading of a forty-term conditional. Storing them as rows
|
|
22
|
+
* is also what makes `checkLookupCoverage` possible, which is the only
|
|
23
|
+
* reason we can prove a table is total over its keys instead of hoping.
|
|
24
|
+
*
|
|
25
|
+
* 2. Classifications are INPUTS, not computations. The criteria text lives in
|
|
26
|
+
* the spec so that a rating can be justified by citing the clause it matched,
|
|
27
|
+
* but nothing here tries to infer a rating from prose.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately dependency-free and pure (no zod, no YAML) so it runs unchanged
|
|
30
|
+
* in tool handlers, the API and the browser. Callers hand `parseFormulaSpec` an
|
|
31
|
+
* already-parsed object; where that object came from is their problem.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { evalExpression } from './table-formula';
|
|
35
|
+
|
|
36
|
+
export type FormulaValue = number | string | boolean | null;
|
|
37
|
+
|
|
38
|
+
export type VariableRole = 'constant' | 'input' | 'derived' | 'output';
|
|
39
|
+
|
|
40
|
+
export interface SpecVariable {
|
|
41
|
+
symbol: string;
|
|
42
|
+
name?: string;
|
|
43
|
+
/** Free text. Carried for display and review; not machine-checked (yet). */
|
|
44
|
+
unit?: string | null;
|
|
45
|
+
role: VariableRole;
|
|
46
|
+
/** Required for `constant`; optional default for `input`. */
|
|
47
|
+
value?: number | string | boolean;
|
|
48
|
+
/** Required for `derived` — an expression over other symbols. */
|
|
49
|
+
expression?: string;
|
|
50
|
+
note?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SpecExpression {
|
|
54
|
+
id: string;
|
|
55
|
+
expression: string;
|
|
56
|
+
/** Equation number in the source standard, for citation. */
|
|
57
|
+
equation?: string;
|
|
58
|
+
/** Symbol this expression produces, enabling unambiguous chaining. */
|
|
59
|
+
resultSymbol?: string;
|
|
60
|
+
unit?: string;
|
|
61
|
+
/**
|
|
62
|
+
* DISPLAY ONLY, and never parsed. `expression` is the single source of truth
|
|
63
|
+
* for what is computed; this is a parallel rendering for human eyes, so that
|
|
64
|
+
* a spec can be shown the way it appears in the standard. Nothing verifies
|
|
65
|
+
* the two agree — treat a mismatch as a documentation bug, and never reach
|
|
66
|
+
* for this when you mean `expression`.
|
|
67
|
+
*/
|
|
68
|
+
latex?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Set when the equation was NOT read off the source — supplied from memory,
|
|
71
|
+
* inferred, or reconstructed. It renders as a warning wherever the equation
|
|
72
|
+
* is shown or indexed, so a from-memory citation can never be mistaken for a
|
|
73
|
+
* transcribed one. The first cut carried this as an ad-hoc `derivedNotInSource`
|
|
74
|
+
* key, which the parser silently dropped — so the caveat vanished while the
|
|
75
|
+
* fabricated equation number went into the embedding as fact.
|
|
76
|
+
*/
|
|
77
|
+
unverified?: string;
|
|
78
|
+
note?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SpecPiecewiseCase {
|
|
82
|
+
/** Condition expression; the first truthy case wins. */
|
|
83
|
+
when: string;
|
|
84
|
+
/** Id of the expression to evaluate when this case matches. */
|
|
85
|
+
use: string;
|
|
86
|
+
label?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SpecPiecewise {
|
|
90
|
+
id: string;
|
|
91
|
+
cases: SpecPiecewiseCase[];
|
|
92
|
+
/** Expression id used when no case matches. Absent means "that is an error". */
|
|
93
|
+
otherwise?: string;
|
|
94
|
+
resultSymbol?: string;
|
|
95
|
+
note?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SpecLookup {
|
|
99
|
+
id: string;
|
|
100
|
+
name?: string;
|
|
101
|
+
/** Variable symbols supplying the key, in no particular order. */
|
|
102
|
+
keys: string[];
|
|
103
|
+
/** The field on each row carrying the looked-up value. */
|
|
104
|
+
result: string;
|
|
105
|
+
rows: Array<Record<string, FormulaValue>>;
|
|
106
|
+
/** Declared legal values per key. Enables `checkLookupCoverage`. */
|
|
107
|
+
domains?: Record<string, FormulaValue[]>;
|
|
108
|
+
/**
|
|
109
|
+
* What an unmatched key means. Defaults to `error` — a missing row in a
|
|
110
|
+
* safety calculation is a gap in the standard, not a zero.
|
|
111
|
+
*/
|
|
112
|
+
onMiss?: 'error' | 'null';
|
|
113
|
+
resultSymbol?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface SpecClassification {
|
|
117
|
+
id: string;
|
|
118
|
+
domain: string[];
|
|
119
|
+
/** Rating → the criterion text from the source, for justification. */
|
|
120
|
+
criteria: Record<string, string>;
|
|
121
|
+
note?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface SpecSource {
|
|
125
|
+
standard?: string;
|
|
126
|
+
part?: string;
|
|
127
|
+
sections?: string[];
|
|
128
|
+
tables?: string[];
|
|
129
|
+
edition?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface FormulaSpec {
|
|
133
|
+
id: string;
|
|
134
|
+
name?: string;
|
|
135
|
+
source?: SpecSource;
|
|
136
|
+
unitSystem?: string;
|
|
137
|
+
notes?: Record<string, string>;
|
|
138
|
+
variables: SpecVariable[];
|
|
139
|
+
expressions: SpecExpression[];
|
|
140
|
+
piecewise: SpecPiecewise[];
|
|
141
|
+
lookups: SpecLookup[];
|
|
142
|
+
classifications: SpecClassification[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type ParseResult = { ok: true; spec: FormulaSpec } | { ok: false; errors: string[] };
|
|
146
|
+
|
|
147
|
+
const ROLES: VariableRole[] = ['constant', 'input', 'derived', 'output'];
|
|
148
|
+
|
|
149
|
+
function isObj(v: unknown): v is Record<string, unknown> {
|
|
150
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A cell a lookup row may carry. Anything else (object, array, function,
|
|
154
|
+
* NaN) is rejected: it would flow out of `evaluateSpec` as the result and
|
|
155
|
+
* `toNum` would quietly turn it into 0. */
|
|
156
|
+
function isScalar(v: unknown): v is FormulaValue {
|
|
157
|
+
if (v === null) return true;
|
|
158
|
+
if (typeof v === 'string' || typeof v === 'boolean') return true;
|
|
159
|
+
return typeof v === 'number' && Number.isFinite(v);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function str(v: unknown): string | undefined {
|
|
163
|
+
return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Is this a syntactically valid expression? The validator used to check only
|
|
168
|
+
* that the string was non-empty, so a spec of pure punctuation validated
|
|
169
|
+
* clean and failed at evaluation time with no id context. Parsing here is
|
|
170
|
+
* what makes `parseFormulaSpec`'s "every problem found" claim true.
|
|
171
|
+
*/
|
|
172
|
+
function syntaxError(src: string): string | null {
|
|
173
|
+
try {
|
|
174
|
+
evalExpression(src, () => null);
|
|
175
|
+
return null;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
return err instanceof Error ? err.message : String(err);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Declared legal values per key. Rejects a non-array (a string has `.length`
|
|
182
|
+
* too, which used to reach `.map` and throw), non-scalar entries, and a key
|
|
183
|
+
* the lookup does not actually have. */
|
|
184
|
+
function parseDomains(
|
|
185
|
+
raw: unknown,
|
|
186
|
+
keys: string[],
|
|
187
|
+
at: string,
|
|
188
|
+
errors: string[],
|
|
189
|
+
): Record<string, FormulaValue[]> | undefined {
|
|
190
|
+
if (raw === undefined || raw === null) return undefined;
|
|
191
|
+
if (!isObj(raw)) {
|
|
192
|
+
errors.push(`${at}.domains must be an object`);
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
const out: Record<string, FormulaValue[]> = {};
|
|
196
|
+
for (const [key, values] of Object.entries(raw)) {
|
|
197
|
+
if (!keys.includes(key)) {
|
|
198
|
+
errors.push(`${at}.domains names '${key}', which is not one of the lookup's keys`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (!Array.isArray(values)) {
|
|
202
|
+
errors.push(`${at}.domains['${key}'] must be an array`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (!values.every(isScalar)) {
|
|
206
|
+
errors.push(`${at}.domains['${key}'] may only contain numbers, strings, booleans or null`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
out[key] = values as FormulaValue[];
|
|
210
|
+
}
|
|
211
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** `sections`/`tables` reached `.join` unvalidated, so `sections: '5.3'`
|
|
215
|
+
* validated clean and then threw inside `formulaToText` — hard-failing the
|
|
216
|
+
* extractor's ingest path for an otherwise-loadable spec. */
|
|
217
|
+
function parseSource(raw: unknown, errors: string[]): SpecSource | undefined {
|
|
218
|
+
if (raw === undefined || raw === null) return undefined;
|
|
219
|
+
if (!isObj(raw)) {
|
|
220
|
+
errors.push('spec.source must be an object');
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
const strList = (v: unknown, field: string): string[] | undefined => {
|
|
224
|
+
if (v === undefined || v === null) return undefined;
|
|
225
|
+
if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
|
|
226
|
+
errors.push(`spec.source.${field} must be an array of strings`);
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
return v as string[];
|
|
230
|
+
};
|
|
231
|
+
return {
|
|
232
|
+
standard: str(raw.standard),
|
|
233
|
+
part: str(raw.part),
|
|
234
|
+
edition: str(raw.edition),
|
|
235
|
+
sections: strList(raw.sections, 'sections'),
|
|
236
|
+
tables: strList(raw.tables, 'tables'),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Notes are free prose keyed by topic; anything non-string is dropped rather
|
|
241
|
+
* than rendered as `[object Object]` into the indexed text. */
|
|
242
|
+
function parseNotes(raw: unknown): Record<string, string> | undefined {
|
|
243
|
+
if (!isObj(raw)) return undefined;
|
|
244
|
+
const out: Record<string, string> = {};
|
|
245
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
246
|
+
const text = str(value);
|
|
247
|
+
if (text) out[key] = text;
|
|
248
|
+
}
|
|
249
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Validate an already-parsed object into a FormulaSpec. Returns every problem
|
|
254
|
+
* found rather than throwing on the first, because these specs get transcribed
|
|
255
|
+
* from printed standards by hand and a reviewer wants the whole list.
|
|
256
|
+
*/
|
|
257
|
+
export function parseFormulaSpec(input: unknown): ParseResult {
|
|
258
|
+
const errors: string[] = [];
|
|
259
|
+
/** A non-array where a list belongs used to be silently coerced to [], so a
|
|
260
|
+
* spec with `expressions: 'oops'` validated clean AND EMPTY. Report it. */
|
|
261
|
+
const asArray = (v: unknown, at: string): unknown[] => {
|
|
262
|
+
if (v === undefined || v === null) return [];
|
|
263
|
+
if (!Array.isArray(v)) {
|
|
264
|
+
errors.push(`${at} must be an array`);
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
return v;
|
|
268
|
+
};
|
|
269
|
+
if (!isObj(input)) return { ok: false, errors: ['spec must be an object'] };
|
|
270
|
+
|
|
271
|
+
const id = str(input.id);
|
|
272
|
+
if (!id) errors.push('spec.id is required');
|
|
273
|
+
|
|
274
|
+
const variables: SpecVariable[] = [];
|
|
275
|
+
const seenSymbols = new Set<string>();
|
|
276
|
+
for (const [i, raw] of asArray(input.variables, 'variables').entries()) {
|
|
277
|
+
const at = `variables[${i}]`;
|
|
278
|
+
if (!isObj(raw)) {
|
|
279
|
+
errors.push(`${at} must be an object`);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const symbol = str(raw.symbol);
|
|
283
|
+
const role = str(raw.role) as VariableRole | undefined;
|
|
284
|
+
if (!symbol) {
|
|
285
|
+
errors.push(`${at}.symbol is required`);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (seenSymbols.has(symbol)) errors.push(`${at}: duplicate symbol '${symbol}'`);
|
|
289
|
+
seenSymbols.add(symbol);
|
|
290
|
+
if (!role || !ROLES.includes(role)) {
|
|
291
|
+
errors.push(`${at} '${symbol}': role must be one of ${ROLES.join(', ')}`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const expression = str(raw.expression);
|
|
295
|
+
const hasValue = raw.value !== undefined && raw.value !== null;
|
|
296
|
+
if (role === 'constant' && !hasValue) {
|
|
297
|
+
errors.push(`${at} '${symbol}': a constant needs a value`);
|
|
298
|
+
}
|
|
299
|
+
if (role === 'derived' && !expression) {
|
|
300
|
+
errors.push(`${at} '${symbol}': a derived variable needs an expression`);
|
|
301
|
+
}
|
|
302
|
+
if (expression) {
|
|
303
|
+
const bad = syntaxError(expression);
|
|
304
|
+
if (bad) errors.push(`${at} '${symbol}': expression does not parse — ${bad}`);
|
|
305
|
+
}
|
|
306
|
+
variables.push({
|
|
307
|
+
symbol,
|
|
308
|
+
name: str(raw.name),
|
|
309
|
+
unit: typeof raw.unit === 'string' ? raw.unit : null,
|
|
310
|
+
role,
|
|
311
|
+
value: hasValue ? (raw.value as number | string | boolean) : undefined,
|
|
312
|
+
expression,
|
|
313
|
+
note: str(raw.note),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Expressions, piecewise and lookups share one id namespace: a piecewise case
|
|
318
|
+
// and an evaluation target both address them by bare id.
|
|
319
|
+
const ids = new Set<string>();
|
|
320
|
+
const claimId = (candidate: string | undefined, at: string): string => {
|
|
321
|
+
if (!candidate) {
|
|
322
|
+
errors.push(`${at}.id is required`);
|
|
323
|
+
return '';
|
|
324
|
+
}
|
|
325
|
+
if (ids.has(candidate)) errors.push(`${at}: duplicate id '${candidate}'`);
|
|
326
|
+
ids.add(candidate);
|
|
327
|
+
return candidate;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const expressions: SpecExpression[] = [];
|
|
331
|
+
for (const [i, raw] of asArray(input.expressions, 'expressions').entries()) {
|
|
332
|
+
const at = `expressions[${i}]`;
|
|
333
|
+
if (!isObj(raw)) {
|
|
334
|
+
errors.push(`${at} must be an object`);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const exprId = claimId(str(raw.id), at);
|
|
338
|
+
const expression = str(raw.expression);
|
|
339
|
+
if (!expression) errors.push(`${at} '${exprId}': expression is required`);
|
|
340
|
+
else {
|
|
341
|
+
const bad = syntaxError(expression);
|
|
342
|
+
if (bad) errors.push(`${at} '${exprId}': expression does not parse — ${bad}`);
|
|
343
|
+
}
|
|
344
|
+
expressions.push({
|
|
345
|
+
id: exprId,
|
|
346
|
+
expression: expression ?? '',
|
|
347
|
+
equation: str(raw.equation),
|
|
348
|
+
resultSymbol: str(raw.resultSymbol),
|
|
349
|
+
unit: str(raw.unit),
|
|
350
|
+
latex: str(raw.latex),
|
|
351
|
+
unverified: str(raw.unverified),
|
|
352
|
+
note: str(raw.note),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const piecewise: SpecPiecewise[] = [];
|
|
357
|
+
for (const [i, raw] of asArray(input.piecewise, 'piecewise').entries()) {
|
|
358
|
+
const at = `piecewise[${i}]`;
|
|
359
|
+
if (!isObj(raw)) {
|
|
360
|
+
errors.push(`${at} must be an object`);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const pwId = claimId(str(raw.id), at);
|
|
364
|
+
const cases: SpecPiecewiseCase[] = [];
|
|
365
|
+
for (const [j, rawCase] of asArray(raw.cases, `${at}.cases`).entries()) {
|
|
366
|
+
if (!isObj(rawCase)) {
|
|
367
|
+
errors.push(`${at}.cases[${j}] must be an object`);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const when = str(rawCase.when);
|
|
371
|
+
const use = str(rawCase.use);
|
|
372
|
+
if (!when) errors.push(`${at}.cases[${j}]: when is required`);
|
|
373
|
+
else {
|
|
374
|
+
const bad = syntaxError(when);
|
|
375
|
+
if (bad) errors.push(`${at}.cases[${j}]: when does not parse — ${bad}`);
|
|
376
|
+
}
|
|
377
|
+
if (!use) errors.push(`${at}.cases[${j}]: use is required`);
|
|
378
|
+
if (when && use) cases.push({ when, use, label: str(rawCase.label) });
|
|
379
|
+
}
|
|
380
|
+
if (cases.length === 0) errors.push(`${at} '${pwId}': needs at least one case`);
|
|
381
|
+
piecewise.push({
|
|
382
|
+
id: pwId,
|
|
383
|
+
cases,
|
|
384
|
+
otherwise: str(raw.otherwise),
|
|
385
|
+
resultSymbol: str(raw.resultSymbol),
|
|
386
|
+
note: str(raw.note),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const lookups: SpecLookup[] = [];
|
|
391
|
+
for (const [i, raw] of asArray(input.lookups, 'lookups').entries()) {
|
|
392
|
+
const at = `lookups[${i}]`;
|
|
393
|
+
if (!isObj(raw)) {
|
|
394
|
+
errors.push(`${at} must be an object`);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
const lkId = claimId(str(raw.id), at);
|
|
398
|
+
const keys = asArray(raw.keys, `${at}.keys`).filter((k): k is string => typeof k === 'string');
|
|
399
|
+
const result = str(raw.result);
|
|
400
|
+
if (keys.length === 0) errors.push(`${at} '${lkId}': needs at least one key`);
|
|
401
|
+
if (!result) errors.push(`${at} '${lkId}': result field name is required`);
|
|
402
|
+
const rows: Array<Record<string, FormulaValue>> = [];
|
|
403
|
+
for (const [j, rawRow] of asArray(raw.rows, `${at}.rows`).entries()) {
|
|
404
|
+
if (!isObj(rawRow)) {
|
|
405
|
+
errors.push(`${at}.rows[${j}] must be an object`);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
// Object.hasOwn, not `!== undefined`: a field named `toString` or
|
|
409
|
+
// `constructor` inherits from the prototype, so the loose check passed
|
|
410
|
+
// and a FUNCTION flowed out as the looked-up value.
|
|
411
|
+
for (const key of keys) {
|
|
412
|
+
if (!Object.hasOwn(rawRow, key)) {
|
|
413
|
+
errors.push(`${at}.rows[${j}] is missing key '${key}'`);
|
|
414
|
+
} else if (!isScalar(rawRow[key])) {
|
|
415
|
+
errors.push(`${at}.rows[${j}] key '${key}' must be a number, string, boolean or null`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (result) {
|
|
419
|
+
if (!Object.hasOwn(rawRow, result)) {
|
|
420
|
+
errors.push(`${at}.rows[${j}] is missing result '${result}'`);
|
|
421
|
+
} else if (!isScalar(rawRow[result])) {
|
|
422
|
+
// Without this, `fact_di: {"value": 0.25}` validated clean and then
|
|
423
|
+
// read as 0 in arithmetic — a silent zero adjustment.
|
|
424
|
+
errors.push(
|
|
425
|
+
`${at}.rows[${j}] result '${result}' must be a number, string, boolean or null`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
rows.push(rawRow as Record<string, FormulaValue>);
|
|
430
|
+
}
|
|
431
|
+
if (rows.length === 0) errors.push(`${at} '${lkId}': needs at least one row`);
|
|
432
|
+
const onMiss = str(raw.onMiss);
|
|
433
|
+
if (onMiss && onMiss !== 'error' && onMiss !== 'null') {
|
|
434
|
+
errors.push(`${at} '${lkId}': onMiss must be 'error' or 'null'`);
|
|
435
|
+
}
|
|
436
|
+
lookups.push({
|
|
437
|
+
id: lkId,
|
|
438
|
+
name: str(raw.name),
|
|
439
|
+
keys,
|
|
440
|
+
result: result ?? '',
|
|
441
|
+
rows,
|
|
442
|
+
domains: parseDomains(raw.domains, keys, at, errors),
|
|
443
|
+
onMiss: onMiss === 'null' ? 'null' : 'error',
|
|
444
|
+
resultSymbol: str(raw.resultSymbol),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const classifications: SpecClassification[] = [];
|
|
449
|
+
for (const [i, raw] of asArray(input.classifications, 'classifications').entries()) {
|
|
450
|
+
const at = `classifications[${i}]`;
|
|
451
|
+
if (!isObj(raw)) {
|
|
452
|
+
errors.push(`${at} must be an object`);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const clId = claimId(str(raw.id), at);
|
|
456
|
+
const domain = asArray(raw.domain, `${at}.domain`).filter(
|
|
457
|
+
(d): d is string => typeof d === 'string',
|
|
458
|
+
);
|
|
459
|
+
if (domain.length === 0) errors.push(`${at} '${clId}': domain is required`);
|
|
460
|
+
const criteria = isObj(raw.criteria) ? (raw.criteria as Record<string, string>) : {};
|
|
461
|
+
for (const value of domain) {
|
|
462
|
+
// Own-property again: a domain entry of 'toString' would otherwise find
|
|
463
|
+
// an inherited function and render it into the indexed text.
|
|
464
|
+
if (!Object.hasOwn(criteria, value) || !str(criteria[value])) {
|
|
465
|
+
errors.push(`${at} '${clId}': no criterion for '${value}'`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
classifications.push({ id: clId, domain, criteria, note: str(raw.note) });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Cross-references resolve only once every id is known.
|
|
472
|
+
for (const pw of piecewise) {
|
|
473
|
+
for (const c of pw.cases) {
|
|
474
|
+
if (!ids.has(c.use)) errors.push(`piecewise '${pw.id}': case uses unknown id '${c.use}'`);
|
|
475
|
+
}
|
|
476
|
+
if (pw.otherwise && !ids.has(pw.otherwise)) {
|
|
477
|
+
errors.push(`piecewise '${pw.id}': otherwise uses unknown id '${pw.otherwise}'`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const source = parseSource(input.source, errors);
|
|
482
|
+
const notes = parseNotes(input.notes);
|
|
483
|
+
|
|
484
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
485
|
+
return {
|
|
486
|
+
ok: true,
|
|
487
|
+
spec: {
|
|
488
|
+
id: id!,
|
|
489
|
+
name: str(input.name),
|
|
490
|
+
source,
|
|
491
|
+
unitSystem: str(input.unitSystem),
|
|
492
|
+
notes,
|
|
493
|
+
variables,
|
|
494
|
+
expressions,
|
|
495
|
+
piecewise,
|
|
496
|
+
lookups,
|
|
497
|
+
classifications,
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export interface CoverageGap {
|
|
503
|
+
lookupId: string;
|
|
504
|
+
/** A key combination in the declared domains with no matching row. */
|
|
505
|
+
key: Record<string, FormulaValue>;
|
|
506
|
+
/** Set instead of `key` when the table was too large to check — a silent
|
|
507
|
+
* "no gaps" on an unchecked table would be a lie. */
|
|
508
|
+
skipped?: string;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Report key combinations a lookup declares as legal but has no row for.
|
|
513
|
+
*
|
|
514
|
+
* This is the payoff for storing tables as data. API RP 581 Table 5.6 specifies
|
|
515
|
+
* six of the nine detection/isolation combinations; the other three are simply
|
|
516
|
+
* absent from the printed table. Encoded as a nested IF() that gap is invisible
|
|
517
|
+
* until it silently yields a zero adjustment on a live assessment. Encoded as
|
|
518
|
+
* rows with declared domains, it is a list you can put in front of an engineer.
|
|
519
|
+
*
|
|
520
|
+
* Not an error — an incomplete table is a fact about the source, not a
|
|
521
|
+
* malformed spec — so this is reported separately from `parseFormulaSpec`.
|
|
522
|
+
*/
|
|
523
|
+
/**
|
|
524
|
+
* Ceiling on the cartesian product a single lookup may expand to.
|
|
525
|
+
*
|
|
526
|
+
* Without it this function is a remote kill switch: `domains` of 6 keys × 20
|
|
527
|
+
* values is 64M combinations, each materialised as an object, and Node dies
|
|
528
|
+
* with an OOM that NO caller can catch. Coverage runs on every read of every
|
|
529
|
+
* formula, so one stored spec would brick the brain permanently. Real tables
|
|
530
|
+
* are tens of rows; anything past this is a malformed or hostile spec.
|
|
531
|
+
*/
|
|
532
|
+
const MAX_COVERAGE_COMBINATIONS = 10_000;
|
|
533
|
+
|
|
534
|
+
export function checkLookupCoverage(spec: FormulaSpec): CoverageGap[] {
|
|
535
|
+
const gaps: CoverageGap[] = [];
|
|
536
|
+
for (const lookup of spec.lookups ?? []) {
|
|
537
|
+
if (!lookup.domains) continue;
|
|
538
|
+
const keys = lookup.keys.filter(
|
|
539
|
+
(k) => Array.isArray(lookup.domains?.[k]) && lookup.domains[k]!.length > 0,
|
|
540
|
+
);
|
|
541
|
+
if (keys.length !== lookup.keys.length) continue; // partial domains: can't be exhaustive
|
|
542
|
+
|
|
543
|
+
// Size the product BEFORE building it.
|
|
544
|
+
let total = 1;
|
|
545
|
+
for (const key of keys) total *= lookup.domains[key]!.length;
|
|
546
|
+
if (total > MAX_COVERAGE_COMBINATIONS) {
|
|
547
|
+
gaps.push({
|
|
548
|
+
lookupId: lookup.id,
|
|
549
|
+
key: {},
|
|
550
|
+
skipped: `declares ${total} key combinations, above the ${MAX_COVERAGE_COMBINATIONS} coverage limit — not checked`,
|
|
551
|
+
});
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
let combos: Array<Record<string, FormulaValue>> = [{}];
|
|
556
|
+
for (const key of keys) {
|
|
557
|
+
const values = lookup.domains[key]!;
|
|
558
|
+
combos = combos.flatMap((base) => values.map((v) => ({ ...base, [key]: v })));
|
|
559
|
+
}
|
|
560
|
+
for (const combo of combos) {
|
|
561
|
+
const hit = lookup.rows.some((row) => keys.every((k) => row[k] === combo[k]));
|
|
562
|
+
if (!hit) gaps.push({ lookupId: lookup.id, key: combo });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return gaps;
|
|
566
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { categoryLabel, moodDisplay, normalizeEntryDate } from './journal-options';
|
|
3
|
+
|
|
4
|
+
describe('normalizeEntryDate', () => {
|
|
5
|
+
it('passes through a full ISO timestamp (canonicalised)', () => {
|
|
6
|
+
const out = normalizeEntryDate('2025-12-25T08:30:00.000Z');
|
|
7
|
+
expect(out).toBe('2025-12-25T08:30:00.000Z');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('accepts a bare date and yields a valid ISO string', () => {
|
|
11
|
+
const out = normalizeEntryDate('2025-12-25');
|
|
12
|
+
expect(out).not.toBeNull();
|
|
13
|
+
expect(out).toMatch(/^2025-12-25T/);
|
|
14
|
+
// round-trips through Date without throwing
|
|
15
|
+
expect(Number.isNaN(Date.parse(out!))).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('rejects free-text that is not a date — the cast-poison guard', () => {
|
|
19
|
+
expect(normalizeEntryDate('next Tuesday')).toBeNull();
|
|
20
|
+
expect(normalizeEntryDate('tomorrow')).toBeNull();
|
|
21
|
+
expect(normalizeEntryDate('soon-ish')).toBeNull();
|
|
22
|
+
expect(normalizeEntryDate('2026-13-99')).toBeNull(); // out-of-range → invalid
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('treats empty / whitespace / non-string as "no date"', () => {
|
|
26
|
+
expect(normalizeEntryDate('')).toBeNull();
|
|
27
|
+
expect(normalizeEntryDate(' ')).toBeNull();
|
|
28
|
+
expect(normalizeEntryDate(null)).toBeNull();
|
|
29
|
+
expect(normalizeEntryDate(undefined)).toBeNull();
|
|
30
|
+
// @ts-expect-error — guard against a non-string slipping through at runtime
|
|
31
|
+
expect(normalizeEntryDate(123)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('moodDisplay', () => {
|
|
36
|
+
it('maps a known mood key to emoji + label', () => {
|
|
37
|
+
expect(moodDisplay('grateful')).toEqual({ emoji: '🙏', label: 'Grateful' });
|
|
38
|
+
});
|
|
39
|
+
it('tolerates an unknown/free-text mood (no emoji, raw label)', () => {
|
|
40
|
+
expect(moodDisplay('zonked')).toEqual({ emoji: '', label: 'zonked' });
|
|
41
|
+
});
|
|
42
|
+
it('returns null for no mood', () => {
|
|
43
|
+
expect(moodDisplay(null)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('categoryLabel', () => {
|
|
48
|
+
it('maps a known category key to its label', () => {
|
|
49
|
+
expect(categoryLabel('faith')).toBe('Faith');
|
|
50
|
+
});
|
|
51
|
+
it('title-cases an unknown/free-text category', () => {
|
|
52
|
+
expect(categoryLabel('hobbies')).toBe('Hobbies');
|
|
53
|
+
});
|
|
54
|
+
it('returns null for no category', () => {
|
|
55
|
+
expect(categoryLabel(null)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe leaf for Journal option lists (moods + categories).
|
|
3
|
+
*
|
|
4
|
+
* These constants are needed both server-side (CRUD, extractor framing, the
|
|
5
|
+
* identity-context distiller) and client-side (the /journal editor + filters).
|
|
6
|
+
* They live in their own module — with NO `@mantle/db` import — so a client
|
|
7
|
+
* component can pull them in without dragging `postgres` into the browser
|
|
8
|
+
* bundle. Same pattern as `contacts-format.ts`. `journal.ts` re-exports these.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Curated mood palette (emoji + label). Stored as the bare key string in
|
|
12
|
+
* `data.mood`; the UI maps key → emoji + label. Free text is tolerated on
|
|
13
|
+
* read, but the picker offers these. */
|
|
14
|
+
export const MOODS = [
|
|
15
|
+
{ key: 'happy', label: 'Happy', emoji: '😀' },
|
|
16
|
+
{ key: 'grateful', label: 'Grateful', emoji: '🙏' },
|
|
17
|
+
{ key: 'calm', label: 'Calm', emoji: '😌' },
|
|
18
|
+
{ key: 'excited', label: 'Excited', emoji: '🤩' },
|
|
19
|
+
{ key: 'hopeful', label: 'Hopeful', emoji: '🌱' },
|
|
20
|
+
{ key: 'reflective', label: 'Reflective', emoji: '🤔' },
|
|
21
|
+
{ key: 'tired', label: 'Tired', emoji: '😮💨' },
|
|
22
|
+
{ key: 'anxious', label: 'Anxious', emoji: '😟' },
|
|
23
|
+
{ key: 'sad', label: 'Sad', emoji: '😔' },
|
|
24
|
+
{ key: 'angry', label: 'Angry', emoji: '😠' },
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type MoodKey = (typeof MOODS)[number]['key'];
|
|
28
|
+
export const MOOD_KEYS: readonly string[] = MOODS.map((m) => m.key);
|
|
29
|
+
|
|
30
|
+
/** Life areas the entry speaks to. Drives the identity block's grouping
|
|
31
|
+
* ("## Work", "## Faith", …) and the list filter. */
|
|
32
|
+
export const CATEGORIES = [
|
|
33
|
+
{ key: 'identity', label: 'Identity' },
|
|
34
|
+
{ key: 'work', label: 'Work' },
|
|
35
|
+
{ key: 'family', label: 'Family' },
|
|
36
|
+
{ key: 'relationships', label: 'Relationships' },
|
|
37
|
+
{ key: 'faith', label: 'Faith' },
|
|
38
|
+
{ key: 'health', label: 'Health' },
|
|
39
|
+
{ key: 'emotion', label: 'Emotion' },
|
|
40
|
+
{ key: 'goal', label: 'Goal' },
|
|
41
|
+
{ key: 'reflection', label: 'Reflection' },
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export type CategoryKey = (typeof CATEGORIES)[number]['key'];
|
|
45
|
+
export const CATEGORY_KEYS: readonly string[] = CATEGORIES.map((c) => c.key);
|
|
46
|
+
|
|
47
|
+
/** Mood key → display (emoji + label), tolerant of free-text/unknown values. */
|
|
48
|
+
export function moodDisplay(key: string | null): { emoji: string; label: string } | null {
|
|
49
|
+
if (!key) return null;
|
|
50
|
+
const found = MOODS.find((m) => m.key === key);
|
|
51
|
+
if (found) return { emoji: found.emoji, label: found.label };
|
|
52
|
+
return { emoji: '', label: key };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Category key → human label, tolerant of free-text/unknown values. */
|
|
56
|
+
export function categoryLabel(key: string | null): string | null {
|
|
57
|
+
if (!key) return null;
|
|
58
|
+
const found = CATEGORIES.find((c) => c.key === key);
|
|
59
|
+
if (found) return found.label;
|
|
60
|
+
return key.charAt(0).toUpperCase() + key.slice(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalise a user/agent-supplied entry date to a canonical ISO-8601 string,
|
|
65
|
+
* or return null if it isn't a real date. Stored `entry_date` is later cast to
|
|
66
|
+
* `timestamptz` in the list/identity sort, so an unparseable value (e.g. the
|
|
67
|
+
* agent passing "next Tuesday") MUST be rejected here — otherwise it poisons
|
|
68
|
+
* the ORDER BY and breaks the whole list. `''`/whitespace → null (no date).
|
|
69
|
+
*/
|
|
70
|
+
export function normalizeEntryDate(value: string | null | undefined): string | null {
|
|
71
|
+
if (typeof value !== 'string') return null;
|
|
72
|
+
const trimmed = value.trim();
|
|
73
|
+
if (!trimmed) return null;
|
|
74
|
+
const ms = Date.parse(trimmed);
|
|
75
|
+
if (Number.isNaN(ms)) return null;
|
|
76
|
+
return new Date(ms).toISOString();
|
|
77
|
+
}
|