@trazum/core 1.50.10 → 1.51.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/src/conform.ts CHANGED
@@ -33,7 +33,9 @@ export type ContractName =
33
33
  | 'verification'
34
34
  | 'history'
35
35
  | 'connected'
36
- | 'cost-answer';
36
+ | 'cost-answer'
37
+ | 'outcome-report'
38
+ | 'annual-record';
37
39
 
38
40
  export interface ConformanceProblem {
39
41
  /** Where: `line 12` for a log, or a dotted path inside a document. */
@@ -249,6 +251,46 @@ const DOCUMENT_RULES: Record<Exclude<ContractName, 'usage-log'>, FieldRule[]> =
249
251
  rule('total', 'an object', isObject),
250
252
  rule('unavailable', 'an array of findings this source cannot support', isArray),
251
253
  ],
254
+ /**
255
+ * The outcome chapter — the standard is only worth something if its
256
+ * refusals travel with it.
257
+ *
258
+ * Another tool emitting this format has to handle a **missing numerator**
259
+ * the same way this one does: a rate that is `null` rather than `0` when
260
+ * nothing was recorded, a `noRate` beside it saying which of the two reasons
261
+ * applies, and undeclared values kept in their own list rather than folded
262
+ * into the failures. A format that carried the fields and lost the refusals
263
+ * would be worse than no format, because it would look interoperable.
264
+ */
265
+ 'outcome-report': [
266
+ rule('slices', 'an array of declared outcome values, dearest first', isArray),
267
+ rule('undeclared', 'an array — named, never counted as failures', isArray),
268
+ rule('coverage', 'an object with recorded, parsed and unrecordedUsd', isObject),
269
+ rule(
270
+ 'successShareOfRecordedUsd',
271
+ 'a number, or **null** when nothing was recorded — never 0, which is a real and terrible measurement rather than an absence',
272
+ // `absence-as-zero` is detected from the word "null" in the expected
273
+ // text, so a tool emitting 0 here is told it emitted an absence as a
274
+ // measurement rather than merely getting a type wrong.
275
+ (v) => v === null || isNumber(v),
276
+ ),
277
+ rule(
278
+ 'noRate',
279
+ 'a string saying why there is no rate, or null when there is one — a refusal never arrives bare',
280
+ (v) => v === null || typeof v === 'string',
281
+ ),
282
+ ],
283
+ 'annual-record': [
284
+ rule('months', 'an array, oldest first', isArray),
285
+ rule('missingMonths', 'an array — named, never interpolated', isArray),
286
+ rule('promises', 'an object with planned, arrived, notArrived and cannotTell', isObject),
287
+ rule(
288
+ 'outcomes',
289
+ 'an object, or null when nothing recorded one',
290
+ (v) => v === null || isObject(v),
291
+ ),
292
+ rule('cannotSay', 'an array of what this record cannot answer', isArray),
293
+ ],
252
294
  'cost-answer': [
253
295
  rule('verdict', 'one of within, over, cannot-tell', (v) =>
254
296
  v === 'within' || v === 'over' || v === 'cannot-tell'),
@@ -257,6 +299,65 @@ const DOCUMENT_RULES: Record<Exclude<ContractName, 'usage-log'>, FieldRule[]> =
257
299
  ],
258
300
  };
259
301
 
302
+ /**
303
+ * Rules that read more than one field, because the refusals worth carrying are
304
+ * relational.
305
+ *
306
+ * A per-field contract can say "a number or null". It cannot say **"null when
307
+ * nothing was recorded, and a number otherwise"** — and that is the whole
308
+ * refusal. A rate of `0` is perfectly valid when calls were recorded and none
309
+ * of them succeeded; it is a lie when nothing was recorded at all, and the
310
+ * difference is in a different field.
311
+ *
312
+ * This was found while writing the outcome chapter: the per-field rule accepted
313
+ * `0` for the rate because zero is a finite number, so the strongest promise in
314
+ * the format was going uncarried. A standard that shipped the fields and lost
315
+ * that would be worse than no standard, because it would look interoperable.
316
+ */
317
+ interface CrossRule {
318
+ at: string;
319
+ kind: ConformanceProblem['kind'];
320
+ /** True when the document is fine. */
321
+ ok: (doc: Record<string, unknown>) => boolean;
322
+ detail: string;
323
+ }
324
+
325
+ const CROSS_RULES: Partial<Record<Exclude<ContractName, 'usage-log'>, CrossRule[]>> = {
326
+ 'outcome-report': [
327
+ {
328
+ at: 'successShareOfRecordedUsd',
329
+ kind: 'absence-as-zero',
330
+ ok: (doc) => {
331
+ const coverage = doc.coverage as { recorded?: unknown } | undefined;
332
+ const recorded = typeof coverage?.recorded === 'number' ? coverage.recorded : null;
333
+ if (recorded !== 0) return true;
334
+ return doc.successShareOfRecordedUsd === null;
335
+ },
336
+ detail:
337
+ 'nothing was recorded, so the rate must be null — 0 is a real and terrible measurement and this is an absence',
338
+ },
339
+ {
340
+ at: 'noRate',
341
+ kind: 'missing',
342
+ ok: (doc) => (doc.successShareOfRecordedUsd === null ? doc.noRate !== null : doc.noRate === null),
343
+ detail:
344
+ 'a null rate needs a reason beside it and a stated rate must not carry one — a refusal never arrives bare, and a reason attached to an answer is two answers',
345
+ },
346
+ ],
347
+ 'annual-record': [
348
+ {
349
+ at: 'cannotSay',
350
+ kind: 'missing',
351
+ ok: (doc) =>
352
+ !Array.isArray(doc.missingMonths) ||
353
+ doc.missingMonths.length === 0 ||
354
+ (Array.isArray(doc.cannotSay) && doc.cannotSay.includes('months-missing')),
355
+ detail:
356
+ 'months are missing and cannotSay does not say so — a year that quietly covers nine months and prints an annual total is wrong by a quarter',
357
+ },
358
+ ],
359
+ };
360
+
260
361
  /**
261
362
  * Which contract a document is claiming to be.
262
363
  *
@@ -266,6 +367,8 @@ const DOCUMENT_RULES: Record<Exclude<ContractName, 'usage-log'>, FieldRule[]> =
266
367
  */
267
368
  function contractOf(doc: Record<string, unknown>): Exclude<ContractName, 'usage-log'> | null {
268
369
  if (Array.isArray(doc.byLabelAndModel)) return 'profile';
370
+ if (Array.isArray(doc.missingMonths) && isObject(doc.promises)) return 'annual-record';
371
+ if (Array.isArray(doc.undeclared) && isObject(doc.coverage)) return 'outcome-report';
269
372
  if (Array.isArray(doc.periods) && Array.isArray(doc.runs)) return 'history';
270
373
  if (Array.isArray(doc.actions) && typeof doc.arrived === 'number') return 'verification';
271
374
  if (Array.isArray(doc.actions)) return 'plan';
@@ -406,6 +509,14 @@ export function conform(text: string, options: ConformOptions = {}): Conformance
406
509
  }
407
510
  }
408
511
 
512
+ // Relational rules last, so a document with a missing field is told about the
513
+ // field before it is told about a relationship that field is half of.
514
+ for (const cross of CROSS_RULES[contract] ?? []) {
515
+ if (!cross.ok(doc)) {
516
+ problems.push({ at: cross.at, kind: cross.kind, detail: cross.detail });
517
+ }
518
+ }
519
+
409
520
  return {
410
521
  schemaVersion: 1,
411
522
  contract,
package/src/index.ts CHANGED
@@ -63,6 +63,16 @@ export type {
63
63
  SemanticRejection,
64
64
  SemanticResult,
65
65
  } from './semantic.js';
66
+ export { replayCommitment, coversTheTerm, MIN_MONTHS_FOR_REPLAY } from './commitment.js';
67
+ export type {
68
+ CommitmentReplay,
69
+ CommitmentTerms,
70
+ CommitmentUnknown,
71
+ MeasuredMonth,
72
+ MonthReplay,
73
+ } from './commitment.js';
74
+ export { annualRecord } from './annual.js';
75
+ export type { AnnualPeriod, AnnualRecord } from './annual.js';
66
76
  export { allocate, validateOwners } from './owners.js';
67
77
  export type {
68
78
  Allocation,
package/src/savings.ts CHANGED
@@ -99,10 +99,23 @@ export function computeSavings(
99
99
  */
100
100
  export function formatUsd(value: number): string {
101
101
  if (value === 0) return '$0';
102
+ /**
103
+ * The branch is chosen on the **rounded** value, not the raw one.
104
+ *
105
+ * `999.998` is under a thousand, so the old version took the two-decimal
106
+ * branch and rendered `$1000.00` — a string the thousands branch would never
107
+ * produce, sitting in a column beside `$5,000` and looking like a different
108
+ * currency format for the same magnitude. Floating point puts values there
109
+ * routinely: a saving of exactly a thousand dollars, computed as
110
+ * `5000 - 5000 * 0.8`, lands at `999.9999999999999`.
111
+ *
112
+ * Rounding first makes the boundary the number a reader sees rather than the
113
+ * number the machine holds.
114
+ */
102
115
  const abs = Math.abs(value);
103
116
  if (abs < 0.01) return `$${value.toFixed(5)}`;
104
117
  if (abs < 1) return `$${value.toFixed(4)}`;
105
- if (abs < 1000) return `$${value.toFixed(2)}`;
118
+ if (Math.round(abs * 100) / 100 < 1000) return `$${value.toFixed(2)}`;
106
119
  return `$${value.toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
107
120
  }
108
121