@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/dist/annual.d.ts +122 -0
- package/dist/annual.d.ts.map +1 -0
- package/dist/annual.js +101 -0
- package/dist/annual.js.map +1 -0
- package/dist/commitment.d.ts +144 -0
- package/dist/commitment.d.ts.map +1 -0
- package/dist/commitment.js +110 -0
- package/dist/commitment.js.map +1 -0
- package/dist/conform.d.ts +1 -1
- package/dist/conform.d.ts.map +1 -1
- package/dist/conform.js +72 -0
- package/dist/conform.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/savings.d.ts.map +1 -1
- package/dist/savings.js +14 -1
- package/dist/savings.js.map +1 -1
- package/package.json +1 -1
- package/src/annual.ts +191 -0
- package/src/commitment.ts +208 -0
- package/src/conform.ts +112 -1
- package/src/index.ts +10 -0
- package/src/savings.ts +14 -1
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
|
|