@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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The year, assembled from what was already written down.
3
+ *
4
+ * The last chapter of the arc, and the one that turns this product's argument
5
+ * into something a stranger can audit. Everything below comes from the store
6
+ * and the plans a team already keeps: **no new data, and nothing computed that
7
+ * could not be checked against a document that already exists.**
8
+ *
9
+ * That constraint is the whole design. An annual report is the document most
10
+ * likely to be quoted out of the room it was written in, and the one nobody
11
+ * goes back to verify. So it may not contain a single figure that this tool
12
+ * would refuse to print anywhere else — which means it is mostly a summing
13
+ * exercise with a great many refusals attached.
14
+ *
15
+ * ## Four questions, and the fourth is the one that matters
16
+ *
17
+ * What was spent. What was planned. What arrived. **And what could not be
18
+ * told** — which in a normal annual report is silently folded into one of the
19
+ * other three, almost always into the flattering one.
20
+ *
21
+ * `verify` has kept those three outcomes apart since 1.39. A year is where the
22
+ * temptation to collapse them is strongest, because "eleven of fourteen
23
+ * actions arrived" reads better than "eleven arrived, one did not, and two
24
+ * could not be judged" — and the second sentence is the one that tells
25
+ * somebody their measurement has a hole in it.
26
+ *
27
+ * ## It reports the record, not the team
28
+ *
29
+ * No per-person anything, no velocity, no ranking of who planned well. The
30
+ * doctrine rule from 1.44, and it matters most here: an annual document is
31
+ * exactly where a cost tool starts being used for performance review, and the
32
+ * way to not be is to hold no data that could be.
33
+ */
34
+ import type { PlanDocument } from './plan.js';
35
+ import type { PlanVerification } from './verify.js';
36
+ import type { OutcomeReport } from './outcome.js';
37
+ /** One period the year is built from, as the caller sliced it. */
38
+ export interface AnnualPeriod {
39
+ /** `YYYY-MM`. */
40
+ month: string;
41
+ usd: number;
42
+ calls: number;
43
+ /** The plan made for this period, when one was. */
44
+ plan?: PlanDocument;
45
+ /** The verification of the previous plan, when one was run. */
46
+ verification?: PlanVerification;
47
+ /** Outcomes recorded in this period, when any were. */
48
+ outcomes?: OutcomeReport;
49
+ }
50
+ export interface AnnualRecord {
51
+ schemaVersion: 1;
52
+ year: string;
53
+ /** Months present, oldest first. Gaps are named rather than interpolated. */
54
+ months: Array<{
55
+ month: string;
56
+ usd: number;
57
+ calls: number;
58
+ }>;
59
+ /**
60
+ * Months of the year with no record at all.
61
+ *
62
+ * Named, never filled. A year report that quietly covers nine months and
63
+ * prints an annual total is wrong by a quarter and says nothing about it.
64
+ */
65
+ missingMonths: string[];
66
+ totalUsd: number;
67
+ totalCalls: number;
68
+ /**
69
+ * What was planned and what became of it — three outcomes, never two.
70
+ *
71
+ * `cannotTell` is the field an ordinary annual report does not have, and its
72
+ * absence is how a year of unmeasurable promises turns into a year of kept
73
+ * ones.
74
+ */
75
+ promises: {
76
+ planned: number;
77
+ arrived: number;
78
+ notArrived: number;
79
+ cannotTell: number;
80
+ /**
81
+ * Dollars the plans projected, summed.
82
+ *
83
+ * **There is deliberately no `arrivedUsd` beside it**, and the reason is
84
+ * worth the paragraph: a verification says whether each action *arrived*,
85
+ * and its `observed` map carries where the money sits now — but the
86
+ * document has never carried a per-action figure for the saving that
87
+ * actually landed. Summing one out of the observations would mean deciding
88
+ * which of several numbers per action is "the saving", which is a
89
+ * judgement the verification refused to make and this module has no
90
+ * standing to make on its behalf.
91
+ *
92
+ * So the year says what was promised and how many promises were kept, and
93
+ * says plainly that it cannot put a dollar figure on the kept ones. The
94
+ * alternative — a plausible number assembled here — is precisely the
95
+ * annual-report arithmetic this document exists to replace.
96
+ */
97
+ projectedUsd: number;
98
+ };
99
+ /**
100
+ * The year's outcome coverage, or null when nothing recorded one.
101
+ *
102
+ * Null rather than a rate of zero, for the reason it has been null
103
+ * everywhere since 1.50.4: an uninstrumented year and a failing year are
104
+ * different sentences.
105
+ */
106
+ outcomes: {
107
+ recorded: number;
108
+ parsed: number;
109
+ /** Share of the year's spend that carried no outcome. */
110
+ unrecordedUsd: number;
111
+ } | null;
112
+ /**
113
+ * Everything this record cannot say, named.
114
+ *
115
+ * The section an annual report is usually missing, and the reason this one
116
+ * is worth trusting: a document that lists its own blind spots is a document
117
+ * somebody can act on the rest of.
118
+ */
119
+ cannotSay: string[];
120
+ }
121
+ export declare function annualRecord(year: string, periods: readonly AnnualPeriod[]): AnnualRecord;
122
+ //# sourceMappingURL=annual.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"annual.d.ts","sourceRoot":"","sources":["../src/annual.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,kEAAkE;AAClE,MAAM,WAAW,YAAY;IAC3B,iBAAiB;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,+DAA+D;IAC/D,YAAY,CAAC,EAAE,gBAAgB,CAAC;IAChC,uDAAuD;IACvD,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B;AAED,MAAM,WAAW,YAAY;IAC3B,aAAa,EAAE,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,MAAM,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7D;;;;;OAKG;IACH,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;OAMG;IACH,QAAQ,EAAE;QACR,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB;;;;;;;;;;;;;;;;WAgBG;QACH,YAAY,EAAE,MAAM,CAAC;KACtB,CAAC;IACF;;;;;;OAMG;IACH,QAAQ,EAAE;QACR,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,yDAAyD;QACzD,aAAa,EAAE,MAAM,CAAC;KACvB,GAAG,IAAI,CAAC;IACT;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AASD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,YAAY,EAAE,GAAG,YAAY,CA+DzF"}
package/dist/annual.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The year, assembled from what was already written down.
3
+ *
4
+ * The last chapter of the arc, and the one that turns this product's argument
5
+ * into something a stranger can audit. Everything below comes from the store
6
+ * and the plans a team already keeps: **no new data, and nothing computed that
7
+ * could not be checked against a document that already exists.**
8
+ *
9
+ * That constraint is the whole design. An annual report is the document most
10
+ * likely to be quoted out of the room it was written in, and the one nobody
11
+ * goes back to verify. So it may not contain a single figure that this tool
12
+ * would refuse to print anywhere else — which means it is mostly a summing
13
+ * exercise with a great many refusals attached.
14
+ *
15
+ * ## Four questions, and the fourth is the one that matters
16
+ *
17
+ * What was spent. What was planned. What arrived. **And what could not be
18
+ * told** — which in a normal annual report is silently folded into one of the
19
+ * other three, almost always into the flattering one.
20
+ *
21
+ * `verify` has kept those three outcomes apart since 1.39. A year is where the
22
+ * temptation to collapse them is strongest, because "eleven of fourteen
23
+ * actions arrived" reads better than "eleven arrived, one did not, and two
24
+ * could not be judged" — and the second sentence is the one that tells
25
+ * somebody their measurement has a hole in it.
26
+ *
27
+ * ## It reports the record, not the team
28
+ *
29
+ * No per-person anything, no velocity, no ranking of who planned well. The
30
+ * doctrine rule from 1.44, and it matters most here: an annual document is
31
+ * exactly where a cost tool starts being used for performance review, and the
32
+ * way to not be is to hold no data that could be.
33
+ */
34
+ /**
35
+ * The twelve months of a year, so a gap is a fact rather than an absence.
36
+ */
37
+ function monthsOf(year) {
38
+ return Array.from({ length: 12 }, (_, i) => `${year}-${String(i + 1).padStart(2, '0')}`);
39
+ }
40
+ export function annualRecord(year, periods) {
41
+ const inYear = [...periods]
42
+ .filter((p) => p.month.startsWith(`${year}-`))
43
+ .sort((a, b) => a.month.localeCompare(b.month));
44
+ const present = new Set(inYear.map((p) => p.month));
45
+ const missingMonths = monthsOf(year).filter((m) => !present.has(m));
46
+ let planned = 0;
47
+ let arrived = 0;
48
+ let notArrived = 0;
49
+ let cannotTell = 0;
50
+ let projectedUsd = 0;
51
+ for (const period of inYear) {
52
+ if (period.plan !== undefined) {
53
+ planned += period.plan.actions.length;
54
+ projectedUsd += period.plan.projectedSavingUsd;
55
+ }
56
+ if (period.verification !== undefined) {
57
+ arrived += period.verification.arrived;
58
+ notArrived += period.verification.notArrived;
59
+ cannotTell += period.verification.cannotTell;
60
+ }
61
+ }
62
+ const withOutcomes = inYear.filter((p) => p.outcomes !== undefined);
63
+ const outcomes = withOutcomes.length === 0
64
+ ? null
65
+ : {
66
+ recorded: withOutcomes.reduce((sum, p) => sum + (p.outcomes?.coverage.recorded ?? 0), 0),
67
+ parsed: withOutcomes.reduce((sum, p) => sum + (p.outcomes?.coverage.parsed ?? 0), 0),
68
+ unrecordedUsd: withOutcomes.reduce((sum, p) => sum + (p.outcomes?.coverage.unrecordedUsd ?? 0), 0),
69
+ };
70
+ const cannotSay = [];
71
+ if (missingMonths.length > 0)
72
+ cannotSay.push('months-missing');
73
+ if (planned === 0)
74
+ cannotSay.push('nothing-was-planned');
75
+ else if (arrived + notArrived + cannotTell === 0)
76
+ cannotSay.push('nothing-was-verified');
77
+ if (cannotTell > 0)
78
+ cannotSay.push('some-promises-unjudgeable');
79
+ /**
80
+ * Named every time there is anything to verify, because it is a permanent
81
+ * limit of the record rather than a gap in this particular year.
82
+ */
83
+ if (arrived > 0)
84
+ cannotSay.push('arrived-savings-not-quantified');
85
+ if (outcomes === null)
86
+ cannotSay.push('no-outcomes-recorded');
87
+ else if (outcomes.recorded < outcomes.parsed)
88
+ cannotSay.push('outcome-coverage-partial');
89
+ return {
90
+ schemaVersion: 1,
91
+ year,
92
+ months: inYear.map((p) => ({ month: p.month, usd: p.usd, calls: p.calls })),
93
+ missingMonths,
94
+ totalUsd: inYear.reduce((sum, p) => sum + p.usd, 0),
95
+ totalCalls: inYear.reduce((sum, p) => sum + p.calls, 0),
96
+ promises: { planned, arrived, notArrived, cannotTell, projectedUsd },
97
+ outcomes,
98
+ cannotSay,
99
+ };
100
+ }
101
+ //# sourceMappingURL=annual.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"annual.js","sourceRoot":"","sources":["../src/annual.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,144 @@
1
+ /**
2
+ * What a committed-use deal would have been worth **on the traffic you
3
+ * actually had**.
4
+ *
5
+ * Providers sell committed-use and reserved-capacity contracts, and every team
6
+ * that signs one is doing arithmetic in a spreadsheet against a number they
7
+ * guessed. That is exactly the failure this product exists to end — and it is
8
+ * the highest-stakes instance of it, because the guess is annual and signed.
9
+ *
10
+ * ## An as-if calculation, and the wording never blurs it
11
+ *
12
+ * "On the traffic you actually had, this commitment would have saved $X" is a
13
+ * measurement of the past.
14
+ *
15
+ * "You will save $X" is a claim about the future, and this product has refused
16
+ * that at every scale since 1.27. Nothing here projects, extrapolates, fits a
17
+ * trend or annualises a partial month. Every figure describes months that
18
+ * happened, and the type says so in a field a machine reader can check.
19
+ *
20
+ * ## Both directions, because one direction is the sales pitch
21
+ *
22
+ * A commitment is a **floor** as well as a discount. Below the floor you pay
23
+ * for capacity you did not use, and a saving quoted without that half is not
24
+ * an analysis — it is the vendor's slide.
25
+ *
26
+ * So every month is priced both ways, the months that would have fallen short
27
+ * are counted and named, and what the unused floor would have cost is its own
28
+ * figure rather than netted quietly against the good months.
29
+ *
30
+ * ## The shortfall risk is a count, not a probability
31
+ *
32
+ * "Three of your last twelve months would have fallen short, by $400, $150 and
33
+ * $2,900" is a measurement. "There is a 25% chance of shortfall" is a model of
34
+ * a distribution nobody fitted, presented with the authority of arithmetic.
35
+ * Only the first is available from a log, so only the first is printed.
36
+ */
37
+ /** The deal, as the provider states it. */
38
+ export interface CommitmentTerms {
39
+ /**
40
+ * What you commit to spending each month, **after** the discount — which is
41
+ * how these contracts are almost always written.
42
+ *
43
+ * If your discounted usage comes in under this, you pay this anyway.
44
+ */
45
+ monthlyFloorUsd: number;
46
+ /** The discount, 0-1. 0.2 is twenty per cent off. */
47
+ discount: number;
48
+ /** How many months the commitment runs. */
49
+ months: number;
50
+ }
51
+ /** One measured month, as the caller sliced it. */
52
+ export interface MeasuredMonth {
53
+ /** `YYYY-MM`. */
54
+ month: string;
55
+ usd: number;
56
+ }
57
+ export interface MonthReplay {
58
+ month: string;
59
+ /** What was actually paid, with no commitment. */
60
+ listUsd: number;
61
+ /** What the discounted usage would have come to. */
62
+ discountedUsd: number;
63
+ /** What would actually have been paid: the floor, or the discounted usage. */
64
+ paidUsd: number;
65
+ /** Positive means the commitment saved money that month. */
66
+ savingUsd: number;
67
+ /** Whether the floor was the binding number — a month that fell short. */
68
+ shortfall: boolean;
69
+ /** What the unused floor cost, in a month that fell short. Zero otherwise. */
70
+ unusedFloorUsd: number;
71
+ }
72
+ export type CommitmentUnknown = 'no-history' | 'too-few-months' | 'partial-months-excluded-everything';
73
+ export interface CommitmentReplay {
74
+ /**
75
+ * Always `measured-past`. There is no other value, and the field exists so a
76
+ * machine reader cannot mistake this for a projection — the same reason
77
+ * every other document in this product carries its provenance.
78
+ */
79
+ provenance: 'measured-past';
80
+ months: MonthReplay[];
81
+ /** Summed over the months replayed, positive meaning the deal won. */
82
+ netUsd: number;
83
+ /** What the good months saved, before the shortfalls are taken off. */
84
+ savedInGoodMonthsUsd: number;
85
+ /**
86
+ * What the shortfall months cost, kept as its own figure.
87
+ *
88
+ * Netted against the savings it disappears, and the disappearing is the
89
+ * whole trick a vendor's slide relies on.
90
+ */
91
+ lostToUnusedFloorUsd: number;
92
+ /** How many of the measured months would have fallen short. A count, never a rate. */
93
+ shortfallMonths: number;
94
+ /**
95
+ * The monthly spend at which the commitment stops losing money.
96
+ *
97
+ * Equal to the floor: below it you pay the floor for less usage. Above it
98
+ * the saving grows, first as (spend − floor) and then, once discounted usage
99
+ * clears the floor, as spend × discount.
100
+ */
101
+ breakEvenMonthlyUsd: number;
102
+ /**
103
+ * Measured spread across the months replayed — lowest and highest, and how
104
+ * far the range spans as a share of the median.
105
+ *
106
+ * The honest form of "shortfall risk": a reader looking at a range wider than
107
+ * the floor can see the deal is a bet, without anybody modelling a
108
+ * distribution nobody fitted.
109
+ */
110
+ spread: {
111
+ lowUsd: number;
112
+ highUsd: number;
113
+ medianUsd: number;
114
+ } | null;
115
+ /** Set when nothing could be replayed. A refusal never arrives bare. */
116
+ unknown: CommitmentUnknown | null;
117
+ /** How many whole months would settle it, when there are too few. */
118
+ monthsNeeded: number | null;
119
+ }
120
+ /**
121
+ * The fewest whole months worth replaying a commitment against.
122
+ *
123
+ * Three. Two months cannot show a shortfall pattern and one cannot show
124
+ * anything at all — and a commitment is signed for a year, so an answer from a
125
+ * single month is a year-long decision made on a fortnight of evidence.
126
+ */
127
+ export declare const MIN_MONTHS_FOR_REPLAY = 3;
128
+ export declare function replayCommitment(history: readonly MeasuredMonth[], terms: CommitmentTerms): CommitmentReplay;
129
+ /**
130
+ * Whether the replay covers as long as the commitment runs.
131
+ *
132
+ * Kept separate from the refusals because it does not stop the arithmetic: six
133
+ * months of history against a twelve-month deal is a real answer about six
134
+ * months, and saying so is more useful than refusing. What it must not do is
135
+ * go unsaid — a twelve-month decision read off half a year of evidence, with
136
+ * nothing on the page marking the gap, is the spreadsheet this module was
137
+ * written to replace.
138
+ */
139
+ export declare function coversTheTerm(replay: CommitmentReplay, terms: CommitmentTerms): {
140
+ covered: number;
141
+ ofMonths: number;
142
+ short: boolean;
143
+ };
144
+ //# sourceMappingURL=commitment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commitment.d.ts","sourceRoot":"","sources":["../src/commitment.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,2CAA2C;AAC3C,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,eAAe,EAAE,MAAM,CAAC;IACxB,qDAAqD;IACrD,QAAQ,EAAE,MAAM,CAAC;IACjB,2CAA2C;IAC3C,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,mDAAmD;AACnD,MAAM,WAAW,aAAa;IAC5B,iBAAiB;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,oDAAoD;IACpD,aAAa,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,OAAO,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,SAAS,EAAE,OAAO,CAAC;IACnB,8EAA8E;IAC9E,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,iBAAiB,GACzB,YAAY,GACZ,gBAAgB,GAChB,oCAAoC,CAAC;AAEzC,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,UAAU,EAAE,eAAe,CAAC;IAC5B,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,oBAAoB,EAAE,MAAM,CAAC;IAC7B;;;;;OAKG;IACH,oBAAoB,EAAE,MAAM,CAAC;IAC7B,sFAAsF;IACtF,eAAe,EAAE,MAAM,CAAC;IACxB;;;;;;OAMG;IACH,mBAAmB,EAAE,MAAM,CAAC;IAC5B;;;;;;;OAOG;IACH,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACtE,wEAAwE;IACxE,OAAO,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAClC,qEAAqE;IACrE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,SAAS,aAAa,EAAE,EACjC,KAAK,EAAE,eAAe,GACrB,gBAAgB,CAkDlB;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,gBAAgB,EACxB,KAAK,EAAE,eAAe,GACrB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAMvD"}
@@ -0,0 +1,110 @@
1
+ /**
2
+ * What a committed-use deal would have been worth **on the traffic you
3
+ * actually had**.
4
+ *
5
+ * Providers sell committed-use and reserved-capacity contracts, and every team
6
+ * that signs one is doing arithmetic in a spreadsheet against a number they
7
+ * guessed. That is exactly the failure this product exists to end — and it is
8
+ * the highest-stakes instance of it, because the guess is annual and signed.
9
+ *
10
+ * ## An as-if calculation, and the wording never blurs it
11
+ *
12
+ * "On the traffic you actually had, this commitment would have saved $X" is a
13
+ * measurement of the past.
14
+ *
15
+ * "You will save $X" is a claim about the future, and this product has refused
16
+ * that at every scale since 1.27. Nothing here projects, extrapolates, fits a
17
+ * trend or annualises a partial month. Every figure describes months that
18
+ * happened, and the type says so in a field a machine reader can check.
19
+ *
20
+ * ## Both directions, because one direction is the sales pitch
21
+ *
22
+ * A commitment is a **floor** as well as a discount. Below the floor you pay
23
+ * for capacity you did not use, and a saving quoted without that half is not
24
+ * an analysis — it is the vendor's slide.
25
+ *
26
+ * So every month is priced both ways, the months that would have fallen short
27
+ * are counted and named, and what the unused floor would have cost is its own
28
+ * figure rather than netted quietly against the good months.
29
+ *
30
+ * ## The shortfall risk is a count, not a probability
31
+ *
32
+ * "Three of your last twelve months would have fallen short, by $400, $150 and
33
+ * $2,900" is a measurement. "There is a 25% chance of shortfall" is a model of
34
+ * a distribution nobody fitted, presented with the authority of arithmetic.
35
+ * Only the first is available from a log, so only the first is printed.
36
+ */
37
+ /**
38
+ * The fewest whole months worth replaying a commitment against.
39
+ *
40
+ * Three. Two months cannot show a shortfall pattern and one cannot show
41
+ * anything at all — and a commitment is signed for a year, so an answer from a
42
+ * single month is a year-long decision made on a fortnight of evidence.
43
+ */
44
+ export const MIN_MONTHS_FOR_REPLAY = 3;
45
+ export function replayCommitment(history, terms) {
46
+ const bare = (unknown, have) => ({
47
+ provenance: 'measured-past',
48
+ months: [],
49
+ netUsd: 0,
50
+ savedInGoodMonthsUsd: 0,
51
+ lostToUnusedFloorUsd: 0,
52
+ shortfallMonths: 0,
53
+ breakEvenMonthlyUsd: terms.monthlyFloorUsd,
54
+ spread: null,
55
+ unknown,
56
+ monthsNeeded: Math.max(0, MIN_MONTHS_FOR_REPLAY - have),
57
+ });
58
+ if (history.length === 0)
59
+ return bare('no-history', 0);
60
+ if (history.length < MIN_MONTHS_FOR_REPLAY)
61
+ return bare('too-few-months', history.length);
62
+ const months = history.map((entry) => {
63
+ const discountedUsd = entry.usd * (1 - terms.discount);
64
+ const shortfall = discountedUsd < terms.monthlyFloorUsd;
65
+ const paidUsd = shortfall ? terms.monthlyFloorUsd : discountedUsd;
66
+ return {
67
+ month: entry.month,
68
+ listUsd: entry.usd,
69
+ discountedUsd,
70
+ paidUsd,
71
+ savingUsd: entry.usd - paidUsd,
72
+ shortfall,
73
+ // What the floor bought that nobody used. Its own figure, never netted.
74
+ unusedFloorUsd: shortfall ? terms.monthlyFloorUsd - discountedUsd : 0,
75
+ };
76
+ });
77
+ const sorted = [...history].map((m) => m.usd).sort((a, b) => a - b);
78
+ const mid = Math.floor(sorted.length / 2);
79
+ const medianUsd = sorted.length % 2 === 0 ? ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2 : (sorted[mid] ?? 0);
80
+ return {
81
+ provenance: 'measured-past',
82
+ months,
83
+ netUsd: months.reduce((sum, m) => sum + m.savingUsd, 0),
84
+ savedInGoodMonthsUsd: months.filter((m) => !m.shortfall).reduce((sum, m) => sum + m.savingUsd, 0),
85
+ lostToUnusedFloorUsd: months.reduce((sum, m) => sum + m.unusedFloorUsd, 0),
86
+ shortfallMonths: months.filter((m) => m.shortfall).length,
87
+ breakEvenMonthlyUsd: terms.monthlyFloorUsd,
88
+ spread: { lowUsd: sorted[0] ?? 0, highUsd: sorted[sorted.length - 1] ?? 0, medianUsd },
89
+ unknown: null,
90
+ monthsNeeded: null,
91
+ };
92
+ }
93
+ /**
94
+ * Whether the replay covers as long as the commitment runs.
95
+ *
96
+ * Kept separate from the refusals because it does not stop the arithmetic: six
97
+ * months of history against a twelve-month deal is a real answer about six
98
+ * months, and saying so is more useful than refusing. What it must not do is
99
+ * go unsaid — a twelve-month decision read off half a year of evidence, with
100
+ * nothing on the page marking the gap, is the spreadsheet this module was
101
+ * written to replace.
102
+ */
103
+ export function coversTheTerm(replay, terms) {
104
+ return {
105
+ covered: replay.months.length,
106
+ ofMonths: terms.months,
107
+ short: replay.months.length < terms.months,
108
+ };
109
+ }
110
+ //# sourceMappingURL=commitment.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commitment.js","sourceRoot":"","sources":["../src/commitment.ts"],"names":[],"mappings":"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"}
package/dist/conform.d.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  * nobody upgrades. Only *absent* required fields and *wrong* types fail.
26
26
  */
27
27
  /** The documents this project emits, and their names. */
28
- export type ContractName = 'usage-log' | 'profile' | 'plan' | 'verification' | 'history' | 'connected' | 'cost-answer';
28
+ export type ContractName = 'usage-log' | 'profile' | 'plan' | 'verification' | 'history' | 'connected' | 'cost-answer' | 'outcome-report' | 'annual-record';
29
29
  export interface ConformanceProblem {
30
30
  /** Where: `line 12` for a log, or a dotted path inside a document. */
31
31
  at: string;
@@ -1 +1 @@
1
- {"version":3,"file":"conform.d.ts","sourceRoot":"","sources":["../src/conform.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,yDAAyD;AACzD,MAAM,MAAM,YAAY,GACpB,WAAW,GACX,SAAS,GACT,MAAM,GACN,cAAc,GACd,SAAS,GACT,WAAW,GACX,aAAa,CAAC;AAElB,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,EAAE,EAAE,MAAM,CAAC;IACX,IAAI;IACF,kCAAkC;IAChC,SAAS;IACX,qDAAqD;OACnD,YAAY;IACd,qEAAqE;OACnE,iBAAiB;IACnB,2DAA2D;OACzD,YAAY,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,sEAAsE;AACtE,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,aAAa,EAAE,CAAC,CAAC;IACjB,gEAAgE;IAChE,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAC;IAC9B;;;;OAIG;IACH,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,kBAAkB,EAAE,CAAC;IAC/B,WAAW,EAAE,kBAAkB,EAAE,CAAC;IAClC,+DAA+D;IAC/D,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;CACnB;AA0MD,MAAM,WAAW,cAAc;IAC7B,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,YAAY,CAAC;CACzB;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,iBAAiB,CAgIrF"}
1
+ {"version":3,"file":"conform.d.ts","sourceRoot":"","sources":["../src/conform.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,yDAAyD;AACzD,MAAM,MAAM,YAAY,GACpB,WAAW,GACX,SAAS,GACT,MAAM,GACN,cAAc,GACd,SAAS,GACT,WAAW,GACX,aAAa,GACb,gBAAgB,GAChB,eAAe,CAAC;AAEpB,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,EAAE,EAAE,MAAM,CAAC;IACX,IAAI;IACF,kCAAkC;IAChC,SAAS;IACX,qDAAqD;OACnD,YAAY;IACd,qEAAqE;OACnE,iBAAiB;IACnB,2DAA2D;OACzD,YAAY,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,sEAAsE;AACtE,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,aAAa,EAAE,CAAC,CAAC;IACjB,gEAAgE;IAChE,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAC;IAC9B;;;;OAIG;IACH,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,kBAAkB,EAAE,CAAC;IAC/B,WAAW,EAAE,kBAAkB,EAAE,CAAC;IAClC,+DAA+D;IAC/D,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;CACnB;AA+SD,MAAM,WAAW,cAAc;IAC7B,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,YAAY,CAAC;CACzB;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,iBAAiB,CAwIrF"}
package/dist/conform.js CHANGED
@@ -182,12 +182,73 @@ const DOCUMENT_RULES = {
182
182
  rule('total', 'an object', isObject),
183
183
  rule('unavailable', 'an array of findings this source cannot support', isArray),
184
184
  ],
185
+ /**
186
+ * The outcome chapter — the standard is only worth something if its
187
+ * refusals travel with it.
188
+ *
189
+ * Another tool emitting this format has to handle a **missing numerator**
190
+ * the same way this one does: a rate that is `null` rather than `0` when
191
+ * nothing was recorded, a `noRate` beside it saying which of the two reasons
192
+ * applies, and undeclared values kept in their own list rather than folded
193
+ * into the failures. A format that carried the fields and lost the refusals
194
+ * would be worse than no format, because it would look interoperable.
195
+ */
196
+ 'outcome-report': [
197
+ rule('slices', 'an array of declared outcome values, dearest first', isArray),
198
+ rule('undeclared', 'an array — named, never counted as failures', isArray),
199
+ rule('coverage', 'an object with recorded, parsed and unrecordedUsd', isObject),
200
+ rule('successShareOfRecordedUsd', 'a number, or **null** when nothing was recorded — never 0, which is a real and terrible measurement rather than an absence',
201
+ // `absence-as-zero` is detected from the word "null" in the expected
202
+ // text, so a tool emitting 0 here is told it emitted an absence as a
203
+ // measurement rather than merely getting a type wrong.
204
+ (v) => v === null || isNumber(v)),
205
+ rule('noRate', 'a string saying why there is no rate, or null when there is one — a refusal never arrives bare', (v) => v === null || typeof v === 'string'),
206
+ ],
207
+ 'annual-record': [
208
+ rule('months', 'an array, oldest first', isArray),
209
+ rule('missingMonths', 'an array — named, never interpolated', isArray),
210
+ rule('promises', 'an object with planned, arrived, notArrived and cannotTell', isObject),
211
+ rule('outcomes', 'an object, or null when nothing recorded one', (v) => v === null || isObject(v)),
212
+ rule('cannotSay', 'an array of what this record cannot answer', isArray),
213
+ ],
185
214
  'cost-answer': [
186
215
  rule('verdict', 'one of within, over, cannot-tell', (v) => v === 'within' || v === 'over' || v === 'cannot-tell'),
187
216
  rule('call', 'an object, or null when nothing was described', (v) => v === null || isObject(v)),
188
217
  rule('budget', 'an object, or null when there is no budget', (v) => v === null || isObject(v)),
189
218
  ],
190
219
  };
220
+ const CROSS_RULES = {
221
+ 'outcome-report': [
222
+ {
223
+ at: 'successShareOfRecordedUsd',
224
+ kind: 'absence-as-zero',
225
+ ok: (doc) => {
226
+ const coverage = doc.coverage;
227
+ const recorded = typeof coverage?.recorded === 'number' ? coverage.recorded : null;
228
+ if (recorded !== 0)
229
+ return true;
230
+ return doc.successShareOfRecordedUsd === null;
231
+ },
232
+ detail: 'nothing was recorded, so the rate must be null — 0 is a real and terrible measurement and this is an absence',
233
+ },
234
+ {
235
+ at: 'noRate',
236
+ kind: 'missing',
237
+ ok: (doc) => (doc.successShareOfRecordedUsd === null ? doc.noRate !== null : doc.noRate === null),
238
+ detail: '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',
239
+ },
240
+ ],
241
+ 'annual-record': [
242
+ {
243
+ at: 'cannotSay',
244
+ kind: 'missing',
245
+ ok: (doc) => !Array.isArray(doc.missingMonths) ||
246
+ doc.missingMonths.length === 0 ||
247
+ (Array.isArray(doc.cannotSay) && doc.cannotSay.includes('months-missing')),
248
+ detail: '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',
249
+ },
250
+ ],
251
+ };
191
252
  /**
192
253
  * Which contract a document is claiming to be.
193
254
  *
@@ -198,6 +259,10 @@ const DOCUMENT_RULES = {
198
259
  function contractOf(doc) {
199
260
  if (Array.isArray(doc.byLabelAndModel))
200
261
  return 'profile';
262
+ if (Array.isArray(doc.missingMonths) && isObject(doc.promises))
263
+ return 'annual-record';
264
+ if (Array.isArray(doc.undeclared) && isObject(doc.coverage))
265
+ return 'outcome-report';
201
266
  if (Array.isArray(doc.periods) && Array.isArray(doc.runs))
202
267
  return 'history';
203
268
  if (Array.isArray(doc.actions) && typeof doc.arrived === 'number')
@@ -329,6 +394,13 @@ export function conform(text, options = {}) {
329
394
  });
330
395
  }
331
396
  }
397
+ // Relational rules last, so a document with a missing field is told about the
398
+ // field before it is told about a relationship that field is half of.
399
+ for (const cross of CROSS_RULES[contract] ?? []) {
400
+ if (!cross.ok(doc)) {
401
+ problems.push({ at: cross.at, kind: cross.kind, detail: cross.detail });
402
+ }
403
+ }
332
404
  return {
333
405
  schemaVersion: 1,
334
406
  contract,