@cruba/crubal 0.0.0-stage → 0.2.0
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 +202 -0
- package/README.md +97 -2
- package/dist/bin/crubal.d.ts +2 -0
- package/dist/bin/crubal.js +103 -0
- package/dist/bin/crubal.js.map +1 -0
- package/dist/src/access.d.ts +126 -0
- package/dist/src/access.js +517 -0
- package/dist/src/access.js.map +1 -0
- package/dist/src/arrival.d.ts +55 -0
- package/dist/src/arrival.js +124 -0
- package/dist/src/arrival.js.map +1 -0
- package/dist/src/browser.d.ts +10 -0
- package/dist/src/browser.js +13 -0
- package/dist/src/browser.js.map +1 -0
- package/dist/src/bundle.d.ts +66 -0
- package/dist/src/bundle.js +753 -0
- package/dist/src/bundle.js.map +1 -0
- package/dist/src/canonical.d.ts +6 -0
- package/dist/src/canonical.js +47 -0
- package/dist/src/canonical.js.map +1 -0
- package/dist/src/conditions.d.ts +28 -0
- package/dist/src/conditions.js +216 -0
- package/dist/src/conditions.js.map +1 -0
- package/dist/src/corpus.d.ts +8 -0
- package/dist/src/corpus.js +1207 -0
- package/dist/src/corpus.js.map +1 -0
- package/dist/src/defs.d.ts +151 -0
- package/dist/src/defs.js +611 -0
- package/dist/src/defs.js.map +1 -0
- package/dist/src/dictionary.d.ts +65 -0
- package/dist/src/dictionary.js +557 -0
- package/dist/src/dictionary.js.map +1 -0
- package/dist/src/exchange.d.ts +236 -0
- package/dist/src/exchange.js +1035 -0
- package/dist/src/exchange.js.map +1 -0
- package/dist/src/forms.d.ts +16 -0
- package/dist/src/forms.js +169 -0
- package/dist/src/forms.js.map +1 -0
- package/dist/src/index.d.ts +27 -0
- package/dist/src/index.js +29 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lex.d.ts +21 -0
- package/dist/src/lex.js +142 -0
- package/dist/src/lex.js.map +1 -0
- package/dist/src/lifecycle.d.ts +84 -0
- package/dist/src/lifecycle.js +415 -0
- package/dist/src/lifecycle.js.map +1 -0
- package/dist/src/log.d.ts +78 -0
- package/dist/src/log.js +506 -0
- package/dist/src/log.js.map +1 -0
- package/dist/src/migrate.d.ts +111 -0
- package/dist/src/migrate.js +286 -0
- package/dist/src/migrate.js.map +1 -0
- package/dist/src/model.d.ts +68 -0
- package/dist/src/model.js +188 -0
- package/dist/src/model.js.map +1 -0
- package/dist/src/packages.d.ts +69 -0
- package/dist/src/packages.js +232 -0
- package/dist/src/packages.js.map +1 -0
- package/dist/src/print-text.d.ts +7 -0
- package/dist/src/print-text.js +216 -0
- package/dist/src/print-text.js.map +1 -0
- package/dist/src/publish.d.ts +27 -0
- package/dist/src/publish.js +275 -0
- package/dist/src/publish.js.map +1 -0
- package/dist/src/read-text.d.ts +10 -0
- package/dist/src/read-text.js +385 -0
- package/dist/src/read-text.js.map +1 -0
- package/dist/src/records.d.ts +31 -0
- package/dist/src/records.js +72 -0
- package/dist/src/records.js.map +1 -0
- package/dist/src/review.d.ts +22 -0
- package/dist/src/review.js +198 -0
- package/dist/src/review.js.map +1 -0
- package/dist/src/rules.d.ts +96 -0
- package/dist/src/rules.js +611 -0
- package/dist/src/rules.js.map +1 -0
- package/dist/src/schedule.d.ts +24 -0
- package/dist/src/schedule.js +126 -0
- package/dist/src/schedule.js.map +1 -0
- package/dist/src/std-packages.d.ts +9 -0
- package/dist/src/std-packages.js +268 -0
- package/dist/src/std-packages.js.map +1 -0
- package/dist/src/store.d.ts +164 -0
- package/dist/src/store.js +538 -0
- package/dist/src/store.js.map +1 -0
- package/dist/src/tree.d.ts +35 -0
- package/dist/src/tree.js +159 -0
- package/dist/src/tree.js.map +1 -0
- package/dist/src/units-table.d.ts +1 -0
- package/dist/src/units-table.js +200 -0
- package/dist/src/units-table.js.map +1 -0
- package/dist/src/units.d.ts +42 -0
- package/dist/src/units.js +98 -0
- package/dist/src/units.js.map +1 -0
- package/dist/src/views.d.ts +216 -0
- package/dist/src/views.js +761 -0
- package/dist/src/views.js.map +1 -0
- package/package.json +60 -4
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
// rules.ts: the Rules level (spec chapter 6). Rules are read from `rule:` blocks and from the facet
|
|
2
|
+
// shortcuts that expand to them (4.4); a rule set is checked at publish (6.8: layered, no contradictions,
|
|
3
|
+
// one spelling); and writes run through the rules in order (6.2, 6.4, 6.7, 6.9): refused, held, filled,
|
|
4
|
+
// derived, flagged, incomplete, complete and completeness.
|
|
5
|
+
import { evaluate, parseCondition, reads } from "./conditions.js";
|
|
6
|
+
import { resolve } from "./dictionary.js";
|
|
7
|
+
import { itemText } from "./print-text.js";
|
|
8
|
+
import { CrubalError, readTree } from "./tree.js";
|
|
9
|
+
const AGG = /^(join|sum|best|narrow|worst|deduct|replace|keep|count|least|most) of (\S+)$/;
|
|
10
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
11
|
+
function production(thenText, strength) {
|
|
12
|
+
const m = thenText.match(/^([A-Za-z_][\w:]*) is (.+)$/);
|
|
13
|
+
if (!m)
|
|
14
|
+
return undefined;
|
|
15
|
+
const key = m[1];
|
|
16
|
+
const right = m[2].trim();
|
|
17
|
+
if (right.startsWith('"'))
|
|
18
|
+
return { key, kind: "template", right };
|
|
19
|
+
if (AGG.test(right))
|
|
20
|
+
return { key, kind: "rollup", right };
|
|
21
|
+
if (right === "unique" || right === "_" || right === "-")
|
|
22
|
+
return undefined;
|
|
23
|
+
if (strength === "suggest")
|
|
24
|
+
return { key, kind: "fill", right };
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
function makeRule(r) {
|
|
28
|
+
const rule = { ...r, when: r.whenText ? parseCondition(r.whenText, r.line) : null, then: null };
|
|
29
|
+
rule.produces = production(r.thenText, r.strength);
|
|
30
|
+
if (!rule.produces || rule.produces.kind === "fill") {
|
|
31
|
+
rule.then = r.thenText.endsWith(" is unique") ? null : parseCondition(r.thenText, r.line);
|
|
32
|
+
}
|
|
33
|
+
return rule;
|
|
34
|
+
}
|
|
35
|
+
/** Read `rule:` blocks from definition text. */
|
|
36
|
+
export function readRules(source) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const node of readTree(source)) {
|
|
39
|
+
if (node.kind !== "pair" || node.key !== "rule")
|
|
40
|
+
continue;
|
|
41
|
+
const get = (k) => node.children.find((c) => c.key === k);
|
|
42
|
+
const strength = get("strength")?.value;
|
|
43
|
+
if (!strength || !["must", "should", "suggest"].includes(strength)) {
|
|
44
|
+
throw new CrubalError(`the rule ${node.value} needs a strength: must, should or suggest`, node.line);
|
|
45
|
+
}
|
|
46
|
+
const thenNode = get("then");
|
|
47
|
+
if (!thenNode)
|
|
48
|
+
throw new CrubalError(`the rule ${node.value} needs a then`, node.line);
|
|
49
|
+
const because = get("because")?.value;
|
|
50
|
+
const version = get("version")?.value.trim();
|
|
51
|
+
out.push(makeRule({
|
|
52
|
+
name: node.value.trim(),
|
|
53
|
+
line: node.line,
|
|
54
|
+
for: get("for")?.value ?? "everywhere",
|
|
55
|
+
whenText: get("when")?.value ?? "",
|
|
56
|
+
thenText: thenNode.value,
|
|
57
|
+
strength,
|
|
58
|
+
...(because ? { because } : {}),
|
|
59
|
+
...(version ? { version } : {}),
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** The rules a facet's shortcut settings expand to (4.4). */
|
|
65
|
+
export function shortcutRules(dict) {
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const f of dict.facets.values()) {
|
|
68
|
+
const line = f.line ?? 0;
|
|
69
|
+
const add = (name, thenText, strength, whenText = "") => out.push(makeRule({ name: `${f.key}: ${name}`, line, for: "everywhere", whenText, thenText, strength, shortcut: true }));
|
|
70
|
+
if (f.required)
|
|
71
|
+
add("required", `${f.key} has`, "must");
|
|
72
|
+
if (f.unique)
|
|
73
|
+
add("unique", `${f.key} is unique`, "must");
|
|
74
|
+
if (f.when)
|
|
75
|
+
add("when", `${f.key} is -`, "must", `not (${f.when})`);
|
|
76
|
+
if (f.length !== undefined)
|
|
77
|
+
add("length", `${f.key} under ${f.length + 1}`, "should", `${f.key} has`);
|
|
78
|
+
if (f.range)
|
|
79
|
+
add("range", `${f.key} in ${f.range.min}..${f.range.max}`, "should", `${f.key} has`);
|
|
80
|
+
if (f.combine)
|
|
81
|
+
add("combine", `${f.key} is ${f.combine} of parts[*].${f.key}`, "must");
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
// ─── Publish checks (6.8) ────────────────────────────────────────────────────
|
|
86
|
+
function readsOf(r) {
|
|
87
|
+
const keys = r.when ? reads(r.when) : [];
|
|
88
|
+
if (r.produces?.kind === "template") {
|
|
89
|
+
for (const m of r.produces.right.matchAll(/\{([^{}]+)\}/g)) {
|
|
90
|
+
for (const alt of m[1].split("??"))
|
|
91
|
+
keys.push(alt.trim().replace(/\?$/, ""));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// A rollup reads its parts' addresses (parts[*].condition), not the key it produces on the whole.
|
|
95
|
+
if (r.produces?.kind === "rollup")
|
|
96
|
+
keys.push(r.produces.right.match(AGG)?.[2] ?? "");
|
|
97
|
+
return keys.map((k) => k.replace(/^\w+::/, ""));
|
|
98
|
+
}
|
|
99
|
+
/** Top-level `and`-terms of a condition that pin a key to a set of values. */
|
|
100
|
+
function pins(e) {
|
|
101
|
+
const out = new Map();
|
|
102
|
+
const walk = (x) => {
|
|
103
|
+
if (x.op === "and") {
|
|
104
|
+
walk(x.a);
|
|
105
|
+
walk(x.b);
|
|
106
|
+
}
|
|
107
|
+
else if (x.op === "test" && (x.verb === "is" || x.verb === "in") && !/\.\./.test(x.arg)) {
|
|
108
|
+
out.set(x.path, new Set(x.arg.split(",").map((s) => s.trim().replace(/^"|"$/g, ""))));
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
if (e)
|
|
112
|
+
walk(e);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
function disjoint(a, b) {
|
|
116
|
+
for (const x of a)
|
|
117
|
+
if (b.has(x))
|
|
118
|
+
return false;
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
/** Check a rule set (6.8). Throws the first problem. */
|
|
122
|
+
export function checkRules(rules, dict) {
|
|
123
|
+
const all = [...shortcutRules(dict), ...rules];
|
|
124
|
+
// One spelling: an explicit rule that says what a shortcut says.
|
|
125
|
+
for (const r of rules) {
|
|
126
|
+
const twin = all.find((s) => s.shortcut && s.thenText === r.thenText && s.whenText === r.whenText && s.strength === r.strength);
|
|
127
|
+
if (twin)
|
|
128
|
+
throw new CrubalError(`${r.name} says the same as ${twin.name.replace(": ", "'s ")} setting`, r.line);
|
|
129
|
+
}
|
|
130
|
+
// Layered: no cycle from what a rule reads to what it produces.
|
|
131
|
+
const producers = all.filter((r) => r.produces);
|
|
132
|
+
const edges = new Map();
|
|
133
|
+
for (const r of producers) {
|
|
134
|
+
for (const k of readsOf(r))
|
|
135
|
+
edges.set(k, [...(edges.get(k) ?? []), { to: r.produces?.key, rule: r }]);
|
|
136
|
+
}
|
|
137
|
+
for (const start of producers) {
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
const stack = [{ key: start.produces?.key, path: [start] }];
|
|
140
|
+
while (stack.length) {
|
|
141
|
+
const { key, path } = stack.pop();
|
|
142
|
+
for (const e of edges.get(key) ?? []) {
|
|
143
|
+
if (e.rule === start) {
|
|
144
|
+
const names = [...new Set(path.map((p) => p.name))];
|
|
145
|
+
const first = [...path].sort((a, b) => a.line - b.line)[0];
|
|
146
|
+
throw new CrubalError(`the rules ${names.join(" and ")} depend on each other's effect`, first.line);
|
|
147
|
+
}
|
|
148
|
+
if (!seen.has(e.to)) {
|
|
149
|
+
seen.add(e.to);
|
|
150
|
+
stack.push({ key: e.to, path: [...path, e.rule] });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// No contradictions: two must rules pinning one key to disjoint values, with whens that can both hold.
|
|
156
|
+
const musts = all.filter((r) => r.strength === "must" && r.then && !r.produces);
|
|
157
|
+
for (let i = 0; i < musts.length; i++) {
|
|
158
|
+
for (let j = i + 1; j < musts.length; j++) {
|
|
159
|
+
const a = musts[i];
|
|
160
|
+
const b = musts[j];
|
|
161
|
+
if (a.for !== b.for && a.for !== "everywhere" && b.for !== "everywhere")
|
|
162
|
+
continue;
|
|
163
|
+
const pa = pins(a.then);
|
|
164
|
+
const pb = pins(b.then);
|
|
165
|
+
const wa = pins(a.when);
|
|
166
|
+
const wb = pins(b.when);
|
|
167
|
+
const whensClash = [...wa].some(([k, s]) => wb.has(k) && disjoint(s, wb.get(k)));
|
|
168
|
+
if (whensClash)
|
|
169
|
+
continue;
|
|
170
|
+
for (const [k, s] of pa) {
|
|
171
|
+
const t = pb.get(k);
|
|
172
|
+
if (t && disjoint(s, t)) {
|
|
173
|
+
const when = a.whenText && a.whenText === b.whenText ? ` when ${a.whenText.replace(/ has$/, " has a value")}` : "";
|
|
174
|
+
throw new CrubalError(`${a.name} and ${b.name} can't both hold${when}`, Math.min(a.line, b.line));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function emptyOf(v) {
|
|
181
|
+
return v === undefined || (isObj(v) && v.$empty === "unknown");
|
|
182
|
+
}
|
|
183
|
+
export class Engine {
|
|
184
|
+
dict;
|
|
185
|
+
rules;
|
|
186
|
+
/** The moment `now` means in conditions; unset means the clock's. */
|
|
187
|
+
now;
|
|
188
|
+
store = new Map();
|
|
189
|
+
order = [];
|
|
190
|
+
/** The records a global rule reads (6.6), when the engine runs inside a store; else its own cache. */
|
|
191
|
+
records;
|
|
192
|
+
constructor(dict, rules, records) {
|
|
193
|
+
this.records = records;
|
|
194
|
+
this.dict = dict;
|
|
195
|
+
this.rules = [...shortcutRules(dict), ...rules];
|
|
196
|
+
}
|
|
197
|
+
/** The values a record reads: its own over everything above it (1.7). */
|
|
198
|
+
effective(rec) {
|
|
199
|
+
const up = rec.of ? this.store.get(rec.of)?.rec : undefined;
|
|
200
|
+
return { ...(up ? this.effective(up) : {}), ...rec.values };
|
|
201
|
+
}
|
|
202
|
+
applies(r, rec) {
|
|
203
|
+
if (r.for === "everywhere")
|
|
204
|
+
return true;
|
|
205
|
+
// A level is written with its prefix (`unit::`, 6.1), the same spelling conditions use.
|
|
206
|
+
const scope = r.for.replace(/::$/, "");
|
|
207
|
+
if (this.dict.classes.has(scope))
|
|
208
|
+
return (rec.wears ?? []).some((w) => w === scope || resolveUses(this.dict, w).includes(scope));
|
|
209
|
+
if (this.dict.levels.has(scope))
|
|
210
|
+
return rec.level === scope;
|
|
211
|
+
return rec.kind === scope;
|
|
212
|
+
}
|
|
213
|
+
/** Whether any rollup here reads a lower level (`count of unit::…`): then a child's write concerns its ancestors. */
|
|
214
|
+
get readsLowerLevels() {
|
|
215
|
+
return this.rules.some((r) => r.produces?.kind === "rollup" && /\b\w+::/.test(r.produces.right));
|
|
216
|
+
}
|
|
217
|
+
/** The chain up from a record along `of`, nearest first. */
|
|
218
|
+
ancestorsOf(id) {
|
|
219
|
+
const out = [];
|
|
220
|
+
const seen = new Set([id]);
|
|
221
|
+
let cur = this.recordOf(id)?.of;
|
|
222
|
+
while (cur && !seen.has(cur)) {
|
|
223
|
+
out.push(cur);
|
|
224
|
+
seen.add(cur);
|
|
225
|
+
cur = this.recordOf(cur)?.of;
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
recordOf(id) {
|
|
230
|
+
return this.store.get(id)?.rec ?? this.records?.().get(id);
|
|
231
|
+
}
|
|
232
|
+
/** The records at a lower level under a record, along `of`, merged ones left out. */
|
|
233
|
+
descendants(rec, level) {
|
|
234
|
+
const set = this.records?.();
|
|
235
|
+
const out = [];
|
|
236
|
+
const walk = (id, depth) => {
|
|
237
|
+
if (depth > 8)
|
|
238
|
+
return;
|
|
239
|
+
const kids = set ? set.childrenOf(id, "of") : [...this.store.values()].map((s) => s.rec).filter((r) => r.of === id);
|
|
240
|
+
for (const k of kids) {
|
|
241
|
+
if (k.status?.reason === "merged")
|
|
242
|
+
continue;
|
|
243
|
+
if (k.level === level)
|
|
244
|
+
out.push(this.store.get(k.id)?.rec ?? k);
|
|
245
|
+
else
|
|
246
|
+
walk(k.id, depth + 1);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
walk(rec.id, 0);
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Run the productions again for a record as it stands (6.4: recomputed whenever what it reads changes):
|
|
254
|
+
* what a store calls on the ancestors of a changed record, and on a record a plain write touched.
|
|
255
|
+
*/
|
|
256
|
+
reproduce(id) {
|
|
257
|
+
const rec = this.recordOf(id);
|
|
258
|
+
if (!rec)
|
|
259
|
+
return [];
|
|
260
|
+
if (!this.store.has(id))
|
|
261
|
+
this.put(rec);
|
|
262
|
+
return this.write({ ...rec, values: {} });
|
|
263
|
+
}
|
|
264
|
+
/** Where a produced key may be stored: the top of its chain, or a level where it varies. */
|
|
265
|
+
storesAt(key, rec) {
|
|
266
|
+
return !rec.of || (this.dict.vary.get(rec.level)?.has(key) ?? false);
|
|
267
|
+
}
|
|
268
|
+
states(rec, held) {
|
|
269
|
+
return (path) => {
|
|
270
|
+
const s = new Set();
|
|
271
|
+
const alt = rec.alternatives?.[path];
|
|
272
|
+
if (alt?.length)
|
|
273
|
+
s.add("conflicted");
|
|
274
|
+
if (held.has(path))
|
|
275
|
+
s.add("held");
|
|
276
|
+
const f = this.dict.facets.get(path);
|
|
277
|
+
const v = rec.values[path];
|
|
278
|
+
if (f?.type === "choice" && typeof v === "string" && !(f.values ?? []).some((x) => x.value === v))
|
|
279
|
+
s.add("new");
|
|
280
|
+
if (rec.sources?.[path]?.adopted)
|
|
281
|
+
s.add("adopted");
|
|
282
|
+
return s;
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/** A path a check tests, for reporting. */
|
|
286
|
+
pathOf(e) {
|
|
287
|
+
if (!e)
|
|
288
|
+
return undefined;
|
|
289
|
+
if (e.op === "test")
|
|
290
|
+
return e.path;
|
|
291
|
+
if (e.op === "not")
|
|
292
|
+
return this.pathOf(e.a);
|
|
293
|
+
return this.pathOf(e.a);
|
|
294
|
+
}
|
|
295
|
+
/** `is unique` compares records of the same level, and never one archived as merged (6.6). */
|
|
296
|
+
uniqueClash(r, rec) {
|
|
297
|
+
const key = r.thenText.replace(/ is unique$/, "");
|
|
298
|
+
const mine = rec.values[key];
|
|
299
|
+
if (emptyOf(mine) || (isObj(mine) && "$empty" in mine))
|
|
300
|
+
return false;
|
|
301
|
+
const set = this.records?.();
|
|
302
|
+
if (set && mine !== undefined)
|
|
303
|
+
return set.byKey(key, mine, rec.level).some((r) => r.id !== rec.id && r.status?.reason !== "merged");
|
|
304
|
+
const want = JSON.stringify(mine);
|
|
305
|
+
for (const [id, s] of this.store) {
|
|
306
|
+
if (id === rec.id || s.rec.level !== rec.level || s.rec.status?.reason === "merged")
|
|
307
|
+
continue;
|
|
308
|
+
if (JSON.stringify(s.rec.values[key]) === want)
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
/** Keep a record as it stands, without running the rules: how a store keeps the engine in step (7.4). */
|
|
314
|
+
put(rec) {
|
|
315
|
+
const was = this.store.get(rec.id);
|
|
316
|
+
this.store.set(rec.id, { rec: { ...rec, values: { ...rec.values } }, produced: new Set(was?.produced ?? []), held: new Set(was?.held ?? []) });
|
|
317
|
+
if (!this.order.includes(rec.id))
|
|
318
|
+
this.order.push(rec.id);
|
|
319
|
+
}
|
|
320
|
+
/** A record as the engine last left it: its values with what the rules produced, and the keys held. */
|
|
321
|
+
get(id) {
|
|
322
|
+
return this.store.get(id);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Would this write break a `must` rule on a value it touches (6.2)? Nothing is stored. Intake uses it to
|
|
326
|
+
* park such a value as a contradiction citing the rule (9.3) instead of landing it.
|
|
327
|
+
*/
|
|
328
|
+
probe(incoming) {
|
|
329
|
+
const before = this.store.get(incoming.id);
|
|
330
|
+
const rec = before ? { ...before.rec, values: { ...before.rec.values, ...incoming.values } } : { ...incoming, values: { ...incoming.values } };
|
|
331
|
+
const touched = new Set(Object.keys(incoming.values));
|
|
332
|
+
const none = new Set();
|
|
333
|
+
for (const r of this.rules) {
|
|
334
|
+
if (r.strength !== "must" || r.produces || !this.applies(r, rec))
|
|
335
|
+
continue;
|
|
336
|
+
const vals = this.effective(rec);
|
|
337
|
+
if (r.when && evaluate(r.when, vals, this.states(rec, none), this.now) !== true)
|
|
338
|
+
continue;
|
|
339
|
+
if (r.thenText.endsWith(" is unique")) {
|
|
340
|
+
const key = r.thenText.replace(/ is unique$/, "");
|
|
341
|
+
if (touched.has(key) && this.uniqueClash(r, rec))
|
|
342
|
+
return { record: rec.id, result: "refused", address: key, rule: r.name };
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const path = this.pathOf(r.then);
|
|
346
|
+
if (!touched.has(path) || emptyOf(vals[path]))
|
|
347
|
+
continue;
|
|
348
|
+
if (r.then && evaluate(r.then, vals, this.states(rec, none), this.now) === false)
|
|
349
|
+
return { record: rec.id, result: "refused", address: path, rule: r.name };
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
/** Write a record (a create, or values merged onto one already stored). Returns its results. */
|
|
354
|
+
write(incoming) {
|
|
355
|
+
const before = this.store.get(incoming.id);
|
|
356
|
+
const rec = before
|
|
357
|
+
? { ...before.rec, values: { ...before.rec.values, ...incoming.values }, parts: incoming.parts ?? before.rec.parts }
|
|
358
|
+
: { ...incoming, values: { ...incoming.values } };
|
|
359
|
+
// A value a record holds (6.7) is evaluated as if present, so a verdict that no longer holds releases it.
|
|
360
|
+
const heldBag = (incoming.held ?? before?.rec?.held ?? {});
|
|
361
|
+
for (const [k, h] of Object.entries(heldBag))
|
|
362
|
+
if (rec.values[k] === undefined && h.value !== undefined)
|
|
363
|
+
rec.values[k] = h.value;
|
|
364
|
+
const heldBefore = new Set([...(before?.held ?? []), ...Object.keys(heldBag)]);
|
|
365
|
+
const touched = new Set(Object.keys(incoming.values));
|
|
366
|
+
const produced = new Set(before?.produced ?? []);
|
|
367
|
+
const held = new Set();
|
|
368
|
+
const results = [];
|
|
369
|
+
// 1. Must checks against the write: refuse what the write broke; hold what a changed verdict broke.
|
|
370
|
+
for (const r of this.rules) {
|
|
371
|
+
if (r.strength !== "must" || r.produces || !this.applies(r, rec))
|
|
372
|
+
continue;
|
|
373
|
+
const vals = this.effective(rec);
|
|
374
|
+
if (r.when && evaluate(r.when, vals, this.states(rec, held), this.now) !== true)
|
|
375
|
+
continue;
|
|
376
|
+
if (r.thenText.endsWith(" is unique")) {
|
|
377
|
+
const key = r.thenText.replace(/ is unique$/, "");
|
|
378
|
+
if (this.uniqueClash(r, rec)) {
|
|
379
|
+
if (touched.has(key))
|
|
380
|
+
return [{ record: rec.id, result: "refused", address: key, rule: r.name }];
|
|
381
|
+
held.add(key);
|
|
382
|
+
results.push({ record: rec.id, result: "held", address: key, rule: r.name });
|
|
383
|
+
}
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const path = this.pathOf(r.then);
|
|
387
|
+
if (emptyOf(vals[path]))
|
|
388
|
+
continue; // an empty value is incompleteness, reported below
|
|
389
|
+
if (r.then && evaluate(r.then, vals, this.states(rec, held), this.now) === false) {
|
|
390
|
+
if (touched.has(path))
|
|
391
|
+
return [{ record: rec.id, result: "refused", address: path, rule: r.name }];
|
|
392
|
+
held.add(path);
|
|
393
|
+
results.push({ record: rec.id, result: "held", address: path, rule: r.name });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const visible = () => {
|
|
397
|
+
const v = this.effective(rec);
|
|
398
|
+
for (const h of held)
|
|
399
|
+
delete v[h];
|
|
400
|
+
return v;
|
|
401
|
+
};
|
|
402
|
+
// 2a. A gate that fails produces "does not apply" when the value is empty (chapter 1.5).
|
|
403
|
+
for (const r of this.rules) {
|
|
404
|
+
if (r.strength !== "must" || !this.applies(r, rec))
|
|
405
|
+
continue;
|
|
406
|
+
const gate = r.thenText.match(/^([A-Za-z_][\w:]*) is -$/);
|
|
407
|
+
if (!gate)
|
|
408
|
+
continue;
|
|
409
|
+
const key = gate[1];
|
|
410
|
+
if (held.has(key) || rec.values[key] !== undefined || !this.storesAt(key, rec))
|
|
411
|
+
continue;
|
|
412
|
+
if (r.when && evaluate(r.when, visible(), this.states(rec, held), this.now) !== true)
|
|
413
|
+
continue;
|
|
414
|
+
rec.values[key] = { $empty: "n/a" };
|
|
415
|
+
produced.add(key);
|
|
416
|
+
results.push({ record: rec.id, result: "filled", address: key, value: { $empty: "n/a" }, rule: r.name });
|
|
417
|
+
}
|
|
418
|
+
// 2. Productions, in layer order (a rule's inputs before its output).
|
|
419
|
+
for (const r of this.layered()) {
|
|
420
|
+
if (!r.produces || !this.applies(r, rec))
|
|
421
|
+
continue;
|
|
422
|
+
const { key, kind, right } = r.produces;
|
|
423
|
+
if (!this.storesAt(key, rec))
|
|
424
|
+
continue;
|
|
425
|
+
const vals = visible();
|
|
426
|
+
if (r.when && evaluate(r.when, vals, this.states(rec, held), this.now) !== true)
|
|
427
|
+
continue;
|
|
428
|
+
const current = rec.values[key];
|
|
429
|
+
const mine = produced.has(key);
|
|
430
|
+
if (kind === "fill") {
|
|
431
|
+
if (!emptyOf(current))
|
|
432
|
+
continue;
|
|
433
|
+
const f = this.dict.facets.get(key);
|
|
434
|
+
const v = f?.type === "choice" || !f ? right.replace(/^"|"$/g, "") : right;
|
|
435
|
+
rec.values[key] = v;
|
|
436
|
+
produced.add(key);
|
|
437
|
+
results.push({ record: rec.id, result: "filled", address: key, value: v, rule: r.name });
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
// A value the rules produced keeps following them, whichever engine produced it (its source says so);
|
|
441
|
+
// a person's value is adopted and never overwritten.
|
|
442
|
+
const derivedBefore = rec.sources?.[key]?.origin === "derived" && !rec.sources?.[key]?.adopted;
|
|
443
|
+
if (!emptyOf(current) && !mine && !derivedBefore)
|
|
444
|
+
continue;
|
|
445
|
+
const v = kind === "template" ? this.template(right, vals) : this.rollup(right, rec);
|
|
446
|
+
if (v === undefined)
|
|
447
|
+
continue;
|
|
448
|
+
rec.values[key] = v;
|
|
449
|
+
produced.add(key);
|
|
450
|
+
results.push({ record: rec.id, result: "derived", address: key, value: v, rule: r.name });
|
|
451
|
+
}
|
|
452
|
+
// 2b. A value held before whose verdict no longer holds comes back (6.7).
|
|
453
|
+
for (const k of heldBefore) {
|
|
454
|
+
if (held.has(k) || rec.values[k] === undefined)
|
|
455
|
+
continue;
|
|
456
|
+
results.push({ record: rec.id, result: "released", address: k, value: rec.values[k] });
|
|
457
|
+
}
|
|
458
|
+
// 3. Results: incomplete, flagged, complete, completeness.
|
|
459
|
+
let complete = held.size === 0;
|
|
460
|
+
const vals = visible();
|
|
461
|
+
for (const r of this.rules) {
|
|
462
|
+
if (r.produces || !this.applies(r, rec))
|
|
463
|
+
continue;
|
|
464
|
+
if (r.when && evaluate(r.when, vals, this.states(rec, held), this.now) !== true)
|
|
465
|
+
continue;
|
|
466
|
+
if (r.thenText.endsWith(" is unique"))
|
|
467
|
+
continue;
|
|
468
|
+
const truth = r.then ? evaluate(r.then, vals, this.states(rec, held), this.now) : true;
|
|
469
|
+
const path = this.pathOf(r.then);
|
|
470
|
+
const needsValue = path !== undefined && emptyOf(vals[path]);
|
|
471
|
+
if (r.strength === "must" && (truth === "undetermined" || (truth === false && needsValue))) {
|
|
472
|
+
complete = false;
|
|
473
|
+
results.push({ record: rec.id, result: "incomplete", rule: r.name, ...(path ? { address: path } : {}) });
|
|
474
|
+
}
|
|
475
|
+
else if (r.strength === "should" && truth !== true) {
|
|
476
|
+
results.push({ record: rec.id, result: "flagged", rule: r.name, ...(path ? { address: path } : {}) });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (complete)
|
|
480
|
+
results.unshift({ record: rec.id, result: "complete" });
|
|
481
|
+
results.push(...this.completeness(rec));
|
|
482
|
+
this.store.set(rec.id, { rec, produced, held });
|
|
483
|
+
if (!this.order.includes(rec.id))
|
|
484
|
+
this.order.push(rec.id);
|
|
485
|
+
return results;
|
|
486
|
+
}
|
|
487
|
+
layered() {
|
|
488
|
+
const prods = this.rules.filter((r) => r.produces);
|
|
489
|
+
const out = [];
|
|
490
|
+
const placed = new Set();
|
|
491
|
+
while (out.length < prods.length) {
|
|
492
|
+
const before = out.length;
|
|
493
|
+
for (const r of prods) {
|
|
494
|
+
if (placed.has(r))
|
|
495
|
+
continue;
|
|
496
|
+
const needs = readsOf(r);
|
|
497
|
+
const blocked = prods.some((p) => !placed.has(p) && p !== r && needs.includes(p.produces?.key));
|
|
498
|
+
if (!blocked) {
|
|
499
|
+
out.push(r);
|
|
500
|
+
placed.add(r);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (out.length === before)
|
|
504
|
+
return [...out, ...prods.filter((p) => !placed.has(p))];
|
|
505
|
+
}
|
|
506
|
+
return out;
|
|
507
|
+
}
|
|
508
|
+
/** A value's plain text in a template: the value itself, never quoted, without detail. */
|
|
509
|
+
text(key, v) {
|
|
510
|
+
const c = isObj(v) && "$value" in v ? v.$value : v;
|
|
511
|
+
if (typeof c === "string")
|
|
512
|
+
return c;
|
|
513
|
+
const f = this.dict.facets.get(key);
|
|
514
|
+
return f ? itemText(f, c) : JSON.stringify(c);
|
|
515
|
+
}
|
|
516
|
+
/** A template (6.4.1): `{a}`, `{a ?? b}`, `{a?}`, `{{`, `}}`. Nothing when a needed value is empty. */
|
|
517
|
+
template(right, vals) {
|
|
518
|
+
const src = JSON.parse(right);
|
|
519
|
+
let out = "";
|
|
520
|
+
for (let i = 0; i < src.length; i++) {
|
|
521
|
+
const ch = src[i];
|
|
522
|
+
if (src.startsWith("{{", i) || src.startsWith("}}", i)) {
|
|
523
|
+
out += ch;
|
|
524
|
+
i++;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (ch !== "{") {
|
|
528
|
+
out += ch;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const end = src.indexOf("}", i);
|
|
532
|
+
const inner = src.slice(i + 1, end).trim();
|
|
533
|
+
i = end;
|
|
534
|
+
const optional = inner.endsWith("?") && !inner.includes("??");
|
|
535
|
+
const alts = (optional ? inner.slice(0, -1) : inner).split("??").map((s) => s.trim());
|
|
536
|
+
const found = alts.find((a) => !emptyOf(vals[a]) && !(isObj(vals[a]) && "$empty" in vals[a]));
|
|
537
|
+
if (found === undefined) {
|
|
538
|
+
if (!optional)
|
|
539
|
+
return undefined;
|
|
540
|
+
out = out.replace(/[\s·,:-]+$/, "");
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
out += this.text(found, vals[found]);
|
|
544
|
+
}
|
|
545
|
+
return out;
|
|
546
|
+
}
|
|
547
|
+
/** A rollup over parts (`worst of parts[*].condition`) or over a lower level (`count of unit::cashierfuId`). */
|
|
548
|
+
rollup(right, rec) {
|
|
549
|
+
const m = right.match(AGG);
|
|
550
|
+
if (!m)
|
|
551
|
+
return undefined;
|
|
552
|
+
const how = m[1];
|
|
553
|
+
const address = m[2];
|
|
554
|
+
const lower = address.match(/^(\w+)::(.+)$/);
|
|
555
|
+
const key = lower ? lower[2] : address.replace(/^parts\[\*\]\./, "");
|
|
556
|
+
const raw = lower
|
|
557
|
+
? this.descendants(rec, lower[1]).map((d) => d.values[key])
|
|
558
|
+
: (rec.parts ?? []).map((p) => (isObj(p) && isObj(p.values) ? p.values[key] : undefined));
|
|
559
|
+
const items = raw.filter((v) => v !== undefined && !(isObj(v) && "$empty" in v));
|
|
560
|
+
if (how === "count")
|
|
561
|
+
return String(items.length);
|
|
562
|
+
if (!items.length)
|
|
563
|
+
return undefined;
|
|
564
|
+
const f = this.dict.facets.get(key);
|
|
565
|
+
if ((how === "worst" || how === "best") && f?.values) {
|
|
566
|
+
const rank = (v) => (f.values ?? []).findIndex((x) => x.value === v);
|
|
567
|
+
const sorted = [...items].sort((a, b) => rank(a) - rank(b));
|
|
568
|
+
return (how === "best" ? sorted[0] : sorted.at(-1));
|
|
569
|
+
}
|
|
570
|
+
const nums = items.map((v) => Number(isObj(v) ? (v.$qty ?? v.$money) : v));
|
|
571
|
+
if (how === "sum")
|
|
572
|
+
return String(nums.reduce((a, b) => a + b, 0));
|
|
573
|
+
if (how === "least" || how === "worst")
|
|
574
|
+
return String(Math.min(...nums));
|
|
575
|
+
if (how === "most" || how === "best")
|
|
576
|
+
return String(Math.max(...nums));
|
|
577
|
+
if (how === "join")
|
|
578
|
+
return items;
|
|
579
|
+
return items.at(-1);
|
|
580
|
+
}
|
|
581
|
+
/** Completeness (6.9): asked facets with a value ÷ asked facets, per record and per part. */
|
|
582
|
+
completeness(rec) {
|
|
583
|
+
const out = [];
|
|
584
|
+
const measure = (worn, values) => {
|
|
585
|
+
const asked = resolve(this.dict, worn.filter((w) => this.dict.classes.has(w))).filter((l) => !l.when || evaluate(parseCondition(l.when), values, undefined, this.now) === true);
|
|
586
|
+
if (!asked.length)
|
|
587
|
+
return null;
|
|
588
|
+
const had = asked.filter((l) => !emptyOf(values[l.facet])).length;
|
|
589
|
+
return String(Number((had / asked.length).toFixed(4)));
|
|
590
|
+
};
|
|
591
|
+
const own = measure(rec.wears ?? [], this.effective(rec));
|
|
592
|
+
if (own !== null)
|
|
593
|
+
out.push({ record: rec.id, result: "completeness", value: own });
|
|
594
|
+
for (const p of rec.parts ?? []) {
|
|
595
|
+
if (!isObj(p))
|
|
596
|
+
continue;
|
|
597
|
+
const v = measure(Array.isArray(p.wears) ? p.wears.map(String) : [], isObj(p.values) ? p.values : {});
|
|
598
|
+
if (v !== null)
|
|
599
|
+
out.push({ record: rec.id, address: `parts[${p.$row}]`, result: "completeness", value: v });
|
|
600
|
+
}
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
function resolveUses(dict, name, seen = new Set()) {
|
|
605
|
+
if (seen.has(name))
|
|
606
|
+
return [];
|
|
607
|
+
seen.add(name);
|
|
608
|
+
const c = dict.classes.get(name);
|
|
609
|
+
return c ? c.uses.flatMap((u) => [u, ...resolveUses(dict, u, seen)]) : [];
|
|
610
|
+
}
|
|
611
|
+
//# sourceMappingURL=rules.js.map
|