@segmentstream-cloud/ontology 0.21.0 → 0.23.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.
@@ -0,0 +1,543 @@
1
+ import { dimensionTypeOf } from "./aggregate-filters.js";
2
+ import { isRecord, nameToWire } from "./authoring-names.js";
3
+ import { MEASURE_AGGREGATES } from "./measure.js";
4
+ import { humanize, RESERVED_ENTITY_NAMES, SNAKE_CASE_NAME, toCamelCase } from "./names.js";
5
+ /** The most measures a model declares. */
6
+ export const MAXIMUM_MODEL_MEASURES = 50;
7
+ /** The most distinct entity measures one model measure reads, references expanded. */
8
+ export const MAXIMUM_MODEL_MEASURE_INPUTS = 10;
9
+ /** The most alignment groups one model measure declares. */
10
+ export const MAXIMUM_ALIGNMENT_GROUPS = 5;
11
+ /** The deepest and the largest expression a model measure expands to. */
12
+ const MAXIMUM_DEPTH = 30;
13
+ const MAXIMUM_NODES = 1000;
14
+ const SEPARATOR = ".";
15
+ const UNITS = ["number", "currency", "ratio", "percent"];
16
+ const FORMATS = ["decimal", "currency", "percent"];
17
+ const ARITHMETIC = ["add", "subtract", "multiply", "divide"];
18
+ /** What only an entity measure contains: an aggregate, its condition and the column it reads. */
19
+ const AGGREGATE_NODES = [...MEASURE_AGGREGATES, "filtered", "column"];
20
+ const LITERAL = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/;
21
+ const NAMES = "Model measures require unique snake_case names and a description.";
22
+ const AGGREGATES = "Model measures combine entity measures; declare the aggregate on its entity.";
23
+ const CYCLE = "Measure references cannot form a cycle.";
24
+ const COMPLEX = "Invalid or overly complex expression.";
25
+ const NO_INPUT = 'A model measure reads at least one entity measure: m.ref("entity.measure").';
26
+ const TOO_MANY_INPUTS = `A model measure reads at most ${MAXIMUM_MODEL_MEASURE_INPUTS} entity measures.`;
27
+ const ALIGNMENT_SHAPE = "align is a list of groups, each naming every entity the measure reads with its property or " +
28
+ 'null, such as [{ sales: "orderDate", plans: "month" }].';
29
+ const ALL_NULL = "An alignment names a property for at least one entity.";
30
+ const NESTED_ALIGN = "align belongs on the model measure, not on an argument of its expression.";
31
+ const NO_WHERE = "A model measure takes no where; declare a filtered measure on the entity and read it.";
32
+ const NO_BALANCE = "A model measure takes no balanceOver; declare the balance on the entity and read it.";
33
+ function isBlank(value) {
34
+ return typeof value !== "string" || value.trim().length === 0;
35
+ }
36
+ /** The wire name of a name that round-trips: `planAttainment` or `plan_attainment`. */
37
+ function wireNameOf(name) {
38
+ const wire = nameToWire(name);
39
+ return SNAKE_CASE_NAME.test(wire) && (name === wire || toCamelCase(wire) === name)
40
+ ? wire
41
+ : undefined;
42
+ }
43
+ /**
44
+ * The declared model measures under their wire names, each a unique snake_case name, not an
45
+ * entity's and not a route's, with a description. A measure whose name is refused is not resolved
46
+ * further.
47
+ */
48
+ function declare(input) {
49
+ const { measures, entities, report } = input;
50
+ const declared = new Map();
51
+ for (const [name, measure] of Object.entries(measures)) {
52
+ const wire = wireNameOf(name);
53
+ const path = `measures.${wire ?? name}`;
54
+ if (wire === undefined || declared.has(wire) || !isRecord(measure)) {
55
+ report("invalid-measure", path, NAMES);
56
+ continue;
57
+ }
58
+ if (isBlank(measure.description)) {
59
+ report("invalid-measure", path, NAMES);
60
+ }
61
+ if (entities.has(wire)) {
62
+ report("duplicate-name", path, `${wire} is also an entity name.`);
63
+ continue;
64
+ }
65
+ if (RESERVED_ENTITY_NAMES.includes(wire)) {
66
+ report("reserved-name", path, `${wire} is a reserved name.`);
67
+ continue;
68
+ }
69
+ declared.set(wire, { name: wire, path, measure });
70
+ }
71
+ return declared;
72
+ }
73
+ /** Whether an entity declares a measure, by wire names. */
74
+ function declaresMeasure(entity, measure) {
75
+ const measures = entity?.entity.definition.measures ?? {};
76
+ return measure !== undefined && Object.hasOwn(measures, measure);
77
+ }
78
+ /**
79
+ * Expands every model measure: a reference to an entity measure becomes a `measure` node naming
80
+ * `entity.measure` in wire names, and a reference to another model measure that measure's expanded
81
+ * expression. A measure whose expansion fails maps to `undefined`; its reason is reported once, at
82
+ * the measure where it occurs.
83
+ */
84
+ function expandAll(input) {
85
+ const { declared, entities, report } = input;
86
+ const expansions = new Map();
87
+ const pending = new Set();
88
+ const expand = (current) => {
89
+ if (expansions.has(current.name)) {
90
+ return expansions.get(current.name);
91
+ }
92
+ const reported = new Set();
93
+ const references = new Set();
94
+ let failed = false;
95
+ let nodes = 0;
96
+ const refuse = (code, message) => {
97
+ failed = true;
98
+ if (!reported.has(message)) {
99
+ reported.add(message);
100
+ report(code, current.path, message);
101
+ }
102
+ return undefined;
103
+ };
104
+ const unknown = (name) => refuse("unknown-measure", `${current.name} reads ${name}, which the ontology does not declare.`);
105
+ /** A reference: `entity.measure` in TypeScript names per segment, or a model measure's name. */
106
+ const reference = (name, depth) => {
107
+ if (typeof name !== "string" || name.length === 0) {
108
+ return refuse("invalid-measure", "Invalid expression operator or arity.");
109
+ }
110
+ if (name.includes(SEPARATOR)) {
111
+ const wire = name.split(SEPARATOR).map(nameToWire).join(SEPARATOR);
112
+ const [owner, measure, ...rest] = wire.split(SEPARATOR);
113
+ if (rest.length > 0 || !declaresMeasure(entities.get(owner ?? ""), measure)) {
114
+ return unknown(wire);
115
+ }
116
+ return { op: "measure", name: wire };
117
+ }
118
+ const wire = nameToWire(name);
119
+ const target = declared.get(wire);
120
+ if (target === undefined) {
121
+ return unknown(wire);
122
+ }
123
+ if (pending.has(wire)) {
124
+ return refuse("circular-measure", CYCLE);
125
+ }
126
+ const inner = expand(target);
127
+ references.add(wire);
128
+ if (inner === undefined) {
129
+ failed = true;
130
+ return undefined;
131
+ }
132
+ nodes += inner.nodes - 1;
133
+ if (depth + inner.depth > MAXIMUM_DEPTH || nodes > MAXIMUM_NODES) {
134
+ return refuse("invalid-measure", COMPLEX);
135
+ }
136
+ return inner.expression;
137
+ };
138
+ const visit = (node, depth) => {
139
+ nodes += 1;
140
+ if (depth > MAXIMUM_DEPTH || nodes > MAXIMUM_NODES || !isRecord(node)) {
141
+ return refuse("invalid-measure", COMPLEX);
142
+ }
143
+ const { op } = node;
144
+ // Options of an argument are not the measure's: only the root declares them.
145
+ if (depth > 0 && node.align !== undefined) {
146
+ refuse("invalid-alignment", NESTED_ALIGN);
147
+ }
148
+ if (AGGREGATE_NODES.includes(op)) {
149
+ return refuse("invalid-measure", AGGREGATES);
150
+ }
151
+ if (node.where !== undefined) {
152
+ refuse("invalid-measure", NO_WHERE);
153
+ }
154
+ if (node.balanceOver !== undefined) {
155
+ refuse("invalid-measure", NO_BALANCE);
156
+ }
157
+ if (op === "measure") {
158
+ return reference(node.name, depth);
159
+ }
160
+ if (op === "literal") {
161
+ const { value } = node;
162
+ if (typeof value !== "string" ||
163
+ value.length >= 100 ||
164
+ !LITERAL.test(value) ||
165
+ !Number.isFinite(Number(value))) {
166
+ return refuse("invalid-measure", "Literals must be finite numbers.");
167
+ }
168
+ return { op: "literal", value };
169
+ }
170
+ if (!ARITHMETIC.includes(op) || !Array.isArray(node.args) || node.args.length !== 2) {
171
+ return refuse("invalid-measure", "Invalid expression operator or arity.");
172
+ }
173
+ const args = node.args.map((arg) => visit(arg, depth + 1));
174
+ const [left, right] = args;
175
+ if (left === undefined || right === undefined) {
176
+ return undefined;
177
+ }
178
+ return { op: op, args: [left, right] };
179
+ };
180
+ pending.add(current.name);
181
+ const expression = visit(current.measure, 0);
182
+ pending.delete(current.name);
183
+ const expansion = failed || expression === undefined
184
+ ? undefined
185
+ : { expression, depth: depthOf(expression), nodes, references: [...references] };
186
+ expansions.set(current.name, expansion);
187
+ return expansion;
188
+ };
189
+ for (const candidate of declared.values()) {
190
+ expand(candidate);
191
+ }
192
+ return expansions;
193
+ }
194
+ function depthOf(expression) {
195
+ return "args" in expression ? 1 + Math.max(...expression.args.map(depthOf)) : 0;
196
+ }
197
+ /** The entity measures an expanded expression reads, `entity.measure`, each once, in order. */
198
+ function inputsOf(expression) {
199
+ if (expression.op === "measure") {
200
+ return [expression.name];
201
+ }
202
+ return "args" in expression ? [...new Set(expression.args.flatMap(inputsOf))] : [];
203
+ }
204
+ function ownerOf(input) {
205
+ return input.slice(0, input.indexOf(SEPARATOR));
206
+ }
207
+ /**
208
+ * The quantity an expression amounts to, by unit algebra: money times or over a number is money,
209
+ * money over money is a number, money times money is neither, and a sum with money is money.
210
+ * Records in `found` what the arithmetic does with two amounts of money.
211
+ */
212
+ function quantityOf(input) {
213
+ const { expression, currency, found } = input;
214
+ if (expression.op === "measure") {
215
+ return currency(expression.name) ? "money" : "number";
216
+ }
217
+ if (!("args" in expression) || expression.args.length !== 2) {
218
+ return "number";
219
+ }
220
+ const [left, right] = expression.args.map((arg) => quantityOf({ expression: arg, currency, found }));
221
+ if (left === "neither" || right === "neither") {
222
+ return "neither";
223
+ }
224
+ const money = (left === "money" ? 1 : 0) + (right === "money" ? 1 : 0);
225
+ switch (expression.op) {
226
+ case "add":
227
+ case "subtract":
228
+ found.sum ||= money === 2;
229
+ return money > 0 ? "money" : "number";
230
+ case "multiply":
231
+ found.product ||= money === 2;
232
+ return money === 2 ? "neither" : money === 1 ? "money" : "number";
233
+ case "divide":
234
+ found.ratio ||= money === 2;
235
+ if (right === "money") {
236
+ return left === "money" ? "number" : "neither";
237
+ }
238
+ return left;
239
+ default:
240
+ return "number";
241
+ }
242
+ }
243
+ /** Why the declared unit contradicts the arithmetic of the currency inputs, if it does. */
244
+ function unitIssue(input) {
245
+ const { unit, expression, currency } = input;
246
+ const found = { ratio: false, product: false, sum: false };
247
+ const quantity = quantityOf({ expression, currency, found });
248
+ if (unit === "currency" && quantity !== "money" && found.ratio) {
249
+ return "A ratio of two currency measures is a ratio, not a currency.";
250
+ }
251
+ if (unit === "currency" && quantity !== "money" && found.product) {
252
+ return "A product of two currency measures is not a currency.";
253
+ }
254
+ if ((unit === "ratio" || unit === "percent") && quantity === "money" && found.sum) {
255
+ return `A sum or difference of currency measures is a currency, not a ${unit}.`;
256
+ }
257
+ return undefined;
258
+ }
259
+ /** A property an alignment may name: a declared property, or the `id` of a stored entity. */
260
+ function dimensionOf(entity, property) {
261
+ const declared = entity.properties.find((candidate) => candidate.name === property);
262
+ if (declared !== undefined) {
263
+ return { type: declared.definition.type };
264
+ }
265
+ return entity.entity.definition.source === undefined && property === "id"
266
+ ? { type: "text" }
267
+ : undefined;
268
+ }
269
+ /**
270
+ * A model measure's alignment in wire names, each group naming every entity it reads, in reading
271
+ * order, or `undefined` when it is refused.
272
+ */
273
+ function alignmentOf(input) {
274
+ const { measure, read, entities, refuse } = input;
275
+ const declared = measure.measure.align;
276
+ if (declared === undefined) {
277
+ return [];
278
+ }
279
+ if (!Array.isArray(declared) || !declared.every(isRecord)) {
280
+ refuse("invalid-alignment", ALIGNMENT_SHAPE);
281
+ return undefined;
282
+ }
283
+ if (declared.length > 0 && read.length === 1) {
284
+ refuse("invalid-alignment", `${measure.name} reads one entity and needs no alignment.`);
285
+ return undefined;
286
+ }
287
+ if (declared.length > MAXIMUM_ALIGNMENT_GROUPS) {
288
+ refuse("invalid-alignment", `A model measure aligns at most ${MAXIMUM_ALIGNMENT_GROUPS} groups.`);
289
+ return undefined;
290
+ }
291
+ const aligned = new Set();
292
+ const groups = [];
293
+ let valid = true;
294
+ const invalid = (message) => {
295
+ valid = false;
296
+ refuse("invalid-alignment", message);
297
+ };
298
+ for (const group of declared) {
299
+ const members = Object.entries(group).map(([entity, property]) => ({
300
+ entity: nameToWire(entity),
301
+ property,
302
+ }));
303
+ const named = members.map(({ entity }) => entity);
304
+ if (named.length !== read.length ||
305
+ new Set(named).size !== named.length ||
306
+ !named.every((entity) => read.includes(entity))) {
307
+ invalid(`An alignment names every entity ${measure.name} reads exactly once.`);
308
+ continue;
309
+ }
310
+ const typed = [];
311
+ const wire = new Map();
312
+ for (const { entity, property } of members) {
313
+ if (property === null) {
314
+ wire.set(entity, null);
315
+ continue;
316
+ }
317
+ if (typeof property !== "string") {
318
+ invalid(ALIGNMENT_SHAPE);
319
+ continue;
320
+ }
321
+ const name = nameToWire(property);
322
+ const identity = `${entity}${SEPARATOR}${name}`;
323
+ const dimension = dimensionOf(entities.get(entity), name);
324
+ const type = dimension === undefined ? undefined : dimensionTypeOf(dimension.type);
325
+ if (dimension === undefined) {
326
+ invalid(`${entity} has no property ${name}.`);
327
+ continue;
328
+ }
329
+ if (type === undefined) {
330
+ invalid(`${identity} cannot be aligned: JSON and file properties are not dimensions.`);
331
+ continue;
332
+ }
333
+ if (aligned.has(identity)) {
334
+ invalid(`${identity} is aligned twice.`);
335
+ }
336
+ aligned.add(identity);
337
+ typed.push({ identity, type });
338
+ wire.set(entity, name);
339
+ }
340
+ if (members.every(({ property }) => property === null)) {
341
+ invalid(ALL_NULL);
342
+ continue;
343
+ }
344
+ const [first] = typed;
345
+ const differing = typed.find((candidate) => candidate.type !== first?.type);
346
+ if (first !== undefined && differing !== undefined) {
347
+ invalid(`${measure.name} aligns ${first.identity} with ${differing.identity}, whose types differ.`);
348
+ }
349
+ groups.push(Object.fromEntries(read.map((entity) => [entity, wire.get(entity) ?? null])));
350
+ }
351
+ return valid ? groups : undefined;
352
+ }
353
+ /** Whether two groups share a member: one entity aligned through the same property in both. */
354
+ function sharesMember(group, other) {
355
+ return Object.entries(group).some(([entity, property]) => property !== null && other[entity] === property);
356
+ }
357
+ /** Why a group disagrees with a group of a measure it reads, on the entities that one names. */
358
+ function disagreement(input) {
359
+ const { measure, group, source, inherited } = input;
360
+ for (const [entity, member] of Object.entries(inherited)) {
361
+ const own = group[entity] ?? null;
362
+ if (own === member) {
363
+ continue;
364
+ }
365
+ if (own !== null && member !== null) {
366
+ return (`${measure} aligns ${entity}${SEPARATOR}${own}, but ${source}, which it reads, aligns ` +
367
+ `${entity}${SEPARATOR}${member}.`);
368
+ }
369
+ if (own !== null) {
370
+ return (`${measure} aligns ${entity}${SEPARATOR}${own}, but ${source}, which it reads, gives ` +
371
+ `${entity} no counterpart.`);
372
+ }
373
+ return (`${measure} gives ${entity} no counterpart, but ${source}, which it reads, aligns ` +
374
+ `${entity}${SEPARATOR}${member}.`);
375
+ }
376
+ return undefined;
377
+ }
378
+ /**
379
+ * A model measure's effective alignment: its own groups, then every group of the model measures it
380
+ * reads that it does not restate. A restated group must agree on every entity the read measure
381
+ * reads; an inherited group must name every entity this measure reads.
382
+ */
383
+ function inheritAlignment(input) {
384
+ const { measure, read, own, sources, refuse } = input;
385
+ const groups = [...own];
386
+ let valid = true;
387
+ for (const source of sources) {
388
+ for (const inherited of source.align) {
389
+ const restated = groups.filter((group) => sharesMember(group, inherited));
390
+ const missing = read.find((entity) => !Object.hasOwn(inherited, entity));
391
+ for (const group of restated) {
392
+ const reason = disagreement({ measure, group, source: source.name, inherited });
393
+ if (reason !== undefined) {
394
+ valid = false;
395
+ refuse("invalid-alignment", reason);
396
+ }
397
+ }
398
+ if (restated.length > 0) {
399
+ continue;
400
+ }
401
+ if (missing !== undefined) {
402
+ valid = false;
403
+ refuse("invalid-alignment", `${measure} reads ${source.name}, whose alignment does not name ${missing}; align it ` +
404
+ `in ${measure}.`);
405
+ continue;
406
+ }
407
+ groups.push(Object.fromEntries(read.map((entity) => [entity, inherited[entity] ?? null])));
408
+ }
409
+ }
410
+ if (valid && groups.length > MAXIMUM_ALIGNMENT_GROUPS) {
411
+ refuse("invalid-alignment", `A model measure aligns at most ${MAXIMUM_ALIGNMENT_GROUPS} groups.`);
412
+ return undefined;
413
+ }
414
+ return valid ? groups : undefined;
415
+ }
416
+ function formatOf(unit, format) {
417
+ if (format !== undefined) {
418
+ return format;
419
+ }
420
+ if (unit === "currency") {
421
+ return "currency";
422
+ }
423
+ return unit === "percent" ? "percent" : "decimal";
424
+ }
425
+ /**
426
+ * Resolves and checks the measures of the model, `defineOntology({ measures })`, against the
427
+ * resolved entities, whose measures were checked first (docs/ontology-aggregates-release-3-spec.md,
428
+ * "Declaring model measures"). Returns the manifest form of each measure that passed, in declaration
429
+ * order: references expanded, names on the wire, inputs in reading order.
430
+ */
431
+ export function resolveModelMeasures(input) {
432
+ const { measures, report } = input;
433
+ const entities = new Map(input.entities.map((candidate) => [candidate.name, candidate]));
434
+ if (measures === undefined) {
435
+ return [];
436
+ }
437
+ if (!isRecord(measures)) {
438
+ report("invalid-measure", "measures", "measures maps names to measures built with m: { planAttainment: m.divide(…) }.");
439
+ return [];
440
+ }
441
+ const count = Object.keys(measures).length;
442
+ if (count > MAXIMUM_MODEL_MEASURES) {
443
+ report("invalid-measure", "measures", `The ontology declares ${count} model measures; the limit is ${MAXIMUM_MODEL_MEASURES}.`);
444
+ }
445
+ const declared = declare({ measures, entities, report });
446
+ const expansions = expandAll({ declared, entities, report });
447
+ const resolved = new Map();
448
+ /** A measure, after the model measures it reads, whose alignment it inherits. */
449
+ const resolve = (measure) => {
450
+ const known = resolved.get(measure.name);
451
+ if (known !== undefined) {
452
+ return known;
453
+ }
454
+ const resolution = resolveMeasure({
455
+ measure,
456
+ expansion: expansions.get(measure.name),
457
+ entities,
458
+ report,
459
+ resolveReference: (name) => resolve(declared.get(name)),
460
+ });
461
+ resolved.set(measure.name, resolution);
462
+ return resolution;
463
+ };
464
+ return [...declared.values()].flatMap((measure) => {
465
+ const { manifest } = resolve(measure);
466
+ return manifest === undefined ? [] : [manifest];
467
+ });
468
+ }
469
+ function resolveMeasure(input) {
470
+ const { measure, expansion, entities, report, resolveReference } = input;
471
+ if (expansion === undefined) {
472
+ return {};
473
+ }
474
+ let refused = isBlank(measure.measure.description);
475
+ const refuse = (code, message) => {
476
+ refused = true;
477
+ report(code, measure.path, message);
478
+ };
479
+ const { expression } = expansion;
480
+ const { unit, format, label, description } = measure.measure;
481
+ const inputs = inputsOf(expression);
482
+ const read = [...new Set(inputs.map(ownerOf))];
483
+ if (inputs.length === 0) {
484
+ refuse("invalid-measure", NO_INPUT);
485
+ }
486
+ if (inputs.length > MAXIMUM_MODEL_MEASURE_INPUTS) {
487
+ refuse("invalid-measure", TOO_MANY_INPUTS);
488
+ }
489
+ if (unit !== undefined && !UNITS.includes(unit)) {
490
+ refuse("invalid-measure", "Invalid measure unit.");
491
+ }
492
+ if (format !== undefined && !FORMATS.includes(format)) {
493
+ refuse("invalid-measure", "Invalid measure format.");
494
+ }
495
+ const currency = (name) => {
496
+ const owner = entities.get(ownerOf(name));
497
+ const declaredMeasure = owner?.entity.definition.measures?.[name.slice(ownerOf(name).length + 1)];
498
+ return declaredMeasure?.unit === "currency";
499
+ };
500
+ const issue = unitIssue({ unit, expression, currency });
501
+ if (issue !== undefined) {
502
+ refuse("invalid-measure", issue);
503
+ }
504
+ // A measure that reads nothing has no entities to align.
505
+ const own = read.length === 0 ? undefined : alignmentOf({ measure, read, entities, refuse });
506
+ const sources = expansion.references.map((name) => ({
507
+ name,
508
+ align: resolveReference(name).align,
509
+ }));
510
+ // A read measure whose alignment was refused has said so; this one cannot inherit it.
511
+ const inheritable = sources.every((source) => source.align !== undefined);
512
+ const align = own === undefined || !inheritable
513
+ ? undefined
514
+ : inheritAlignment({
515
+ measure: measure.name,
516
+ read,
517
+ own,
518
+ sources: sources,
519
+ refuse,
520
+ });
521
+ if (read.length === 1 && !refused && align !== undefined) {
522
+ report("single-entity-model-measure", measure.path, `${measure.name} reads one entity; declare it as a measure of ${read[0]}.`, "warning");
523
+ }
524
+ if (align === undefined) {
525
+ return {};
526
+ }
527
+ if (refused) {
528
+ return { align };
529
+ }
530
+ return {
531
+ align,
532
+ manifest: {
533
+ name: measure.name,
534
+ label: typeof label === "string" ? label : humanize(measure.name),
535
+ description: description,
536
+ unit: (unit ?? "number"),
537
+ format: formatOf(unit, format),
538
+ expression: expression,
539
+ inputs,
540
+ align,
541
+ },
542
+ };
543
+ }
@@ -2,12 +2,13 @@ import { type AnalyzeOptions, type OntologyConfig, type ResolvedConnection, type
2
2
  import { type Diagnostic } from "./diagnostics.js";
3
3
  import type { AnyEntity } from "./entity.js";
4
4
  import { type OntologyManifest } from "./manifest.js";
5
+ import type { ModelMeasures } from "./measure.js";
5
6
  import type { Role } from "./role.js";
6
7
  import type { SignInOptions } from "./sign-in.js";
7
8
  /** A checked ontology: the resolved definitions and the manifest built from them. */
8
- export interface Ontology<Entities extends ReadonlyArray<AnyEntity> = ReadonlyArray<AnyEntity>> {
9
+ export interface Ontology<Entities extends ReadonlyArray<AnyEntity> = ReadonlyArray<AnyEntity>, Measures extends ModelMeasures = ModelMeasures> {
9
10
  /** The definition as given to `defineOntology`. */
10
- readonly config: OntologyConfig<Entities>;
11
+ readonly config: OntologyConfig<Entities, Measures>;
11
12
  /** The built-in `User` first, then the declared entities in order. */
12
13
  readonly entities: ReadonlyArray<ResolvedEntity>;
13
14
  /** The built-in `member` role first, with the ontology's member permissions, then the declared roles. */
@@ -30,9 +31,9 @@ export declare function check(input: OntologyConfig | Ontology): ReadonlyArray<D
30
31
  * manifest's roles, whose permissions name entities the rebuilt ontology does not have, and whose
31
32
  * `user` table may predate properties this SDK added to the built-in `User`.
32
33
  */
33
- export declare function buildOntology<const Entities extends ReadonlyArray<AnyEntity>>(config: OntologyConfig<Entities>, options: AnalyzeOptions): Ontology<Entities>;
34
+ export declare function buildOntology<const Entities extends ReadonlyArray<AnyEntity>, const Measures extends ModelMeasures = ModelMeasures>(config: OntologyConfig<Entities, Measures>, options: AnalyzeOptions): Ontology<Entities, Measures>;
34
35
  /**
35
36
  * Resolves and checks the ontology and builds its manifest. Throws {@link InvalidOntology}, carrying
36
37
  * every diagnostic, when `check` reports an error.
37
38
  */
38
- export declare function defineOntology<const Entities extends ReadonlyArray<AnyEntity>>(config: OntologyConfig<Entities>): Ontology<Entities>;
39
+ export declare function defineOntology<const Entities extends ReadonlyArray<AnyEntity>, const Measures extends ModelMeasures = ModelMeasures>(config: OntologyConfig<Entities, Measures>): Ontology<Entities, Measures>;
package/dist/render.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ActionManifest, EntityManifest, MeasureManifest, OntologyManifest } from "./manifest.js";
1
+ import type { ActionManifest, EntityManifest, MeasureManifest, ModelMeasureManifest, OntologyManifest } from "./manifest.js";
2
2
  export interface RenderOptions {
3
3
  /**
4
4
  * The permissions of the reader. Only the actions these permissions may run are listed, and only
@@ -50,6 +50,17 @@ export declare const RECORDS_OR_AGGREGATES = "look at or change records through
50
50
  * declaration order. Without permissions, every measure.
51
51
  */
52
52
  export declare function measuresFor(manifest: OntologyManifest, permissions?: ReadonlyArray<string>): ReadonlyArray<NamedMeasure>;
53
+ /**
54
+ * The measures of the model whose every input entity the reader may read, in declaration order.
55
+ * Reading a model measure is reading its inputs. Without permissions, every one.
56
+ */
57
+ export declare function modelMeasuresFor(manifest: OntologyManifest, permissions?: ReadonlyArray<string>): ReadonlyArray<ModelMeasureManifest>;
58
+ /**
59
+ * A measure of the model as `describe`, the MCP tool and the instructions show it: its description,
60
+ * the entity measures it reads and how their properties align. `Ordered over planned units. Reads
61
+ * sales.ordered and plans.planned_units; sales.order_date aligns with plans.month.`
62
+ */
63
+ export declare function modelMeasureDescription(measure: ModelMeasureManifest): string;
53
64
  /**
54
65
  * The entities one entity reaches, with the shortest routes to each: exactly the routes aggregate
55
66
  * resolution weighs ({@link shortestRoutesFrom}). Only then is the reader applied: a target they may
@@ -59,7 +70,8 @@ export declare function measuresFor(manifest: OntologyManifest, permissions?: Re
59
70
  */
60
71
  export declare function reachesOf(manifest: OntologyManifest, from: string, permissions?: ReadonlyArray<string>): ReadonlyArray<Reach>;
61
72
  /**
62
- * The aggregate part of the catalog's prose: every measure the reader may read, model-wide, and
63
- * per measure entity the entities it reaches. Empty when the reader may read no measure.
73
+ * The aggregate part of the catalog's prose: every measure the reader may read, model-wide, the
74
+ * measures of the model whose every input they may read, and per measure entity the entities it
75
+ * reaches. Empty when the reader may read no measure.
64
76
  */
65
77
  export declare function renderAggregates(manifest: OntologyManifest, permissions?: ReadonlyArray<string>): string;