@trazum/core 1.50.9 → 1.50.11
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/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/config-schema.d.ts +18 -1
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +69 -0
- package/dist/config-schema.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/owners.d.ts +133 -0
- package/dist/owners.d.ts.map +1 -0
- package/dist/owners.js +191 -0
- package/dist/owners.js.map +1 -0
- 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/commitment.ts +208 -0
- package/src/config-schema.ts +102 -0
- package/src/index.ts +18 -0
- package/src/owners.ts +264 -0
- package/src/savings.ts +14 -1
package/src/config-schema.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BASELINE_FILENAME } from './baseline.js';
|
|
2
2
|
import type { OutcomeVocabulary } from './outcome.js';
|
|
3
3
|
import type { LadderPolicy } from './ladder.js';
|
|
4
|
+
import type { OwnersConfig } from './owners.js';
|
|
4
5
|
import { mostSpecificMatch } from './glob.js';
|
|
5
6
|
import type { PricingCatalogue } from './pricing.js';
|
|
6
7
|
import { isLocale } from './i18n/index.js';
|
|
@@ -246,6 +247,21 @@ export interface TrazumConfig {
|
|
|
246
247
|
* traffic to a more expensive model on the strength of that guess, forever.
|
|
247
248
|
*/
|
|
248
249
|
ladders?: Record<string, LadderPolicy>;
|
|
250
|
+
/**
|
|
251
|
+
* Whose money: label patterns per owner, declared shared splits, and
|
|
252
|
+
* per-owner budgets.
|
|
253
|
+
*
|
|
254
|
+
* **The unallocated is never spread.** Spend matching no owner stays its own
|
|
255
|
+
* line until somebody claims it — splitting it proportionally is the single
|
|
256
|
+
* most common lie in cost reporting, and it makes every team's figure wrong
|
|
257
|
+
* by an amount nobody can see, hitting hardest the team whose instrumentation
|
|
258
|
+
* is cleanest.
|
|
259
|
+
*
|
|
260
|
+
* A shared workload is split by a rule written here, and the rule travels
|
|
261
|
+
* with the report so the argument happens about the rule rather than about
|
|
262
|
+
* the number.
|
|
263
|
+
*/
|
|
264
|
+
owners?: OwnersConfig;
|
|
249
265
|
/** File extensions directory mode treats as prompts. */
|
|
250
266
|
extensions?: string[];
|
|
251
267
|
/**
|
|
@@ -279,6 +295,7 @@ export const CONFIG_KEYS = [
|
|
|
279
295
|
'pricing',
|
|
280
296
|
'outcomes',
|
|
281
297
|
'ladders',
|
|
298
|
+
'owners',
|
|
282
299
|
] as const;
|
|
283
300
|
|
|
284
301
|
export const CONFIG_BASELINE_KEYS = ['path', 'maxGrowthTokens', 'maxGrowthPct'] as const;
|
|
@@ -293,6 +310,8 @@ export const CONFIG_OUTCOME_KEYS = ['values', 'success'] as const;
|
|
|
293
310
|
|
|
294
311
|
export const CONFIG_LADDER_KEYS = ['tiers', 'escalateOn'] as const;
|
|
295
312
|
|
|
313
|
+
export const CONFIG_OWNERS_KEYS = ['patterns', 'shared', 'budgets'] as const;
|
|
314
|
+
|
|
296
315
|
/**
|
|
297
316
|
* The gates a waiver can silence. The list is closed on purpose: a waiver
|
|
298
317
|
* naming a gate that does not exist is a decision about nothing, and the
|
|
@@ -616,6 +635,88 @@ function parseSources(raw: unknown, source: string): Record<string, string[]> {
|
|
|
616
635
|
* declared a *success* are all checked by `validateLadder`, which needs the
|
|
617
636
|
* price catalogue and the vocabulary that this function does not have.
|
|
618
637
|
*/
|
|
638
|
+
/**
|
|
639
|
+
* `owners` — whose budget each workload lands on.
|
|
640
|
+
*
|
|
641
|
+
* Shape only. Whether a shared split sums to one, names an owner that exists,
|
|
642
|
+
* or has a single owner is checked by `validateOwners`, which reports all of
|
|
643
|
+
* it at once rather than failing on the first — a chargeback config fixed one
|
|
644
|
+
* error per run is one somebody abandons halfway and then never trusts.
|
|
645
|
+
*/
|
|
646
|
+
function parseOwners(entry: unknown, source: string): OwnersConfig {
|
|
647
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
648
|
+
throw new ConfigError(
|
|
649
|
+
`"owners" in ${source} must be an object with "patterns", for example {"patterns": {"payments": ["billing-*"]}}.`,
|
|
650
|
+
source,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
const raw = entry as Record<string, unknown>;
|
|
654
|
+
rejectUnknownKeys(raw, CONFIG_OWNERS_KEYS, source, 'owners.');
|
|
655
|
+
|
|
656
|
+
if (raw.patterns === undefined) {
|
|
657
|
+
throw new ConfigError(`"owners.patterns" in ${source} is required.`, source);
|
|
658
|
+
}
|
|
659
|
+
if (typeof raw.patterns !== 'object' || raw.patterns === null || Array.isArray(raw.patterns)) {
|
|
660
|
+
throw new ConfigError(`"owners.patterns" in ${source} must be an object keyed by owner.`, source);
|
|
661
|
+
}
|
|
662
|
+
const patterns: Record<string, string[]> = {};
|
|
663
|
+
for (const [owner, globs] of Object.entries(raw.patterns as Record<string, unknown>)) {
|
|
664
|
+
if (
|
|
665
|
+
!Array.isArray(globs) ||
|
|
666
|
+
globs.length === 0 ||
|
|
667
|
+
globs.some((glob) => typeof glob !== 'string' || glob.trim() === '')
|
|
668
|
+
) {
|
|
669
|
+
throw new ConfigError(
|
|
670
|
+
`"owners.patterns["${owner}"]" in ${source} must be a non-empty array of label patterns.`,
|
|
671
|
+
source,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
patterns[owner] = globs as string[];
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const config: OwnersConfig = { patterns };
|
|
678
|
+
|
|
679
|
+
if (raw.shared !== undefined) {
|
|
680
|
+
if (typeof raw.shared !== 'object' || raw.shared === null || Array.isArray(raw.shared)) {
|
|
681
|
+
throw new ConfigError(`"owners.shared" in ${source} must be an object keyed by label.`, source);
|
|
682
|
+
}
|
|
683
|
+
const shared: Record<string, Record<string, number>> = {};
|
|
684
|
+
for (const [label, split] of Object.entries(raw.shared as Record<string, unknown>)) {
|
|
685
|
+
if (typeof split !== 'object' || split === null || Array.isArray(split)) {
|
|
686
|
+
throw new ConfigError(`"owners.shared["${label}"]" in ${source} must be an object of owner to share.`, source);
|
|
687
|
+
}
|
|
688
|
+
const parsed: Record<string, number> = {};
|
|
689
|
+
for (const [owner, share] of Object.entries(split as Record<string, unknown>)) {
|
|
690
|
+
if (typeof share !== 'number' || !Number.isFinite(share)) {
|
|
691
|
+
throw new ConfigError(
|
|
692
|
+
`"owners.shared["${label}"]["${owner}"]" in ${source} must be a number between 0 and 1.`,
|
|
693
|
+
source,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
parsed[owner] = share;
|
|
697
|
+
}
|
|
698
|
+
shared[label] = parsed;
|
|
699
|
+
}
|
|
700
|
+
config.shared = shared;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (raw.budgets !== undefined) {
|
|
704
|
+
if (typeof raw.budgets !== 'object' || raw.budgets === null || Array.isArray(raw.budgets)) {
|
|
705
|
+
throw new ConfigError(`"owners.budgets" in ${source} must be an object of owner to dollars.`, source);
|
|
706
|
+
}
|
|
707
|
+
const budgets: Record<string, number> = {};
|
|
708
|
+
for (const [owner, usd] of Object.entries(raw.budgets as Record<string, unknown>)) {
|
|
709
|
+
if (typeof usd !== 'number' || !Number.isFinite(usd) || usd <= 0) {
|
|
710
|
+
throw new ConfigError(`"owners.budgets["${owner}"]" in ${source} must be a positive number of dollars.`, source);
|
|
711
|
+
}
|
|
712
|
+
budgets[owner] = usd;
|
|
713
|
+
}
|
|
714
|
+
config.budgets = budgets;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return config;
|
|
718
|
+
}
|
|
719
|
+
|
|
619
720
|
function parseLadders(entry: unknown, source: string): Record<string, LadderPolicy> {
|
|
620
721
|
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
621
722
|
throw new ConfigError(
|
|
@@ -946,6 +1047,7 @@ export function parseConfig(raw: string, source = CONFIG_FILENAME): TrazumConfig
|
|
|
946
1047
|
if (document.store !== undefined) config.store = parseStore(document.store, source);
|
|
947
1048
|
if (document.waive !== undefined) config.waive = parseWaive(document.waive, source);
|
|
948
1049
|
if (document.ladders !== undefined) config.ladders = parseLadders(document.ladders, source);
|
|
1050
|
+
if (document.owners !== undefined) config.owners = parseOwners(document.owners, source);
|
|
949
1051
|
if (document.outcomes !== undefined) {
|
|
950
1052
|
config.outcomes = parseOutcomes(document.outcomes, source);
|
|
951
1053
|
}
|
package/src/index.ts
CHANGED
|
@@ -63,6 +63,24 @@ 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 { allocate, validateOwners } from './owners.js';
|
|
75
|
+
export type {
|
|
76
|
+
Allocation,
|
|
77
|
+
LabelSpend,
|
|
78
|
+
OwnerLine,
|
|
79
|
+
OwnerProblem,
|
|
80
|
+
OwnersConfig,
|
|
81
|
+
OwnerVerdict,
|
|
82
|
+
SharedSplit,
|
|
83
|
+
} from './owners.js';
|
|
66
84
|
export { runExperiment } from './experiment.js';
|
|
67
85
|
export {
|
|
68
86
|
qualityGate,
|
package/src/owners.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whose budget — the question that decides whether anything on the list gets
|
|
3
|
+
* done.
|
|
4
|
+
*
|
|
5
|
+
* The fleet answered *which service* in 1.37. Nobody has answered *whose
|
|
6
|
+
* money*, and until somebody does, every finding this product makes lands on a
|
|
7
|
+
* desk with no name on it. A report that says "the bill is $40,000 and here is
|
|
8
|
+
* how to save $9,000" is read by four people who each assume it is one of the
|
|
9
|
+
* other three's problem.
|
|
10
|
+
*
|
|
11
|
+
* ## The unallocated is its own line, and it is never spread
|
|
12
|
+
*
|
|
13
|
+
* The one rule worth breaking the module over.
|
|
14
|
+
*
|
|
15
|
+
* Splitting unattributed spend proportionally across the owners you *do* know
|
|
16
|
+
* is the single most common lie in cost reporting. It is attractive because it
|
|
17
|
+
* makes the numbers add up and every line look complete. What it actually does
|
|
18
|
+
* is make **every team's figure wrong**, by an amount nobody can see, in a
|
|
19
|
+
* direction nobody can check — and it does it most to the teams with the
|
|
20
|
+
* cleanest instrumentation, because they are the ones whose known spend is
|
|
21
|
+
* largest and who therefore absorb the biggest share of somebody else's
|
|
22
|
+
* mystery.
|
|
23
|
+
*
|
|
24
|
+
* So the unallocated stays a line of its own, with its own dollar figure, until
|
|
25
|
+
* a human claims it. It is loud on purpose: an unallocated share that grows
|
|
26
|
+
* quietly is a chargeback report becoming fiction one month at a time.
|
|
27
|
+
*
|
|
28
|
+
* ## Shared cost is declared, never inferred
|
|
29
|
+
*
|
|
30
|
+
* A workload two teams use is split by a rule somebody wrote down, and **the
|
|
31
|
+
* rule travels with the report**. That is the whole design: the argument then
|
|
32
|
+
* happens about the rule — "why is search 60/40?" — rather than about the
|
|
33
|
+
* number, which is an argument nobody can win because nobody can see where the
|
|
34
|
+
* number came from.
|
|
35
|
+
*
|
|
36
|
+
* A split that does not sum to one is a configuration error and not a rounding
|
|
37
|
+
* problem, because the alternative is silently losing or inventing money.
|
|
38
|
+
*
|
|
39
|
+
* ## An owner with no measured data is not an owner under budget
|
|
40
|
+
*
|
|
41
|
+
* `fleetBudgetMissing`, from 1.37, applied to people. A team whose logs never
|
|
42
|
+
* arrived passes every budget it has, forever, and a report that renders that
|
|
43
|
+
* as a green tick has told somebody the opposite of the truth.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { mostSpecificMatch } from './glob.js';
|
|
47
|
+
import { UNLABELLED } from './usage.js';
|
|
48
|
+
|
|
49
|
+
/** How one workload's spend is divided between owners. Sums to 1. */
|
|
50
|
+
export type SharedSplit = Record<string, number>;
|
|
51
|
+
|
|
52
|
+
export interface OwnersConfig {
|
|
53
|
+
/** Label patterns per owner. Most specific wins, as everywhere in this tool. */
|
|
54
|
+
patterns: Record<string, string[]>;
|
|
55
|
+
/**
|
|
56
|
+
* Workloads two or more owners share, split by a rule a human wrote.
|
|
57
|
+
*
|
|
58
|
+
* Keyed by the exact label rather than by a pattern: a shared split is a
|
|
59
|
+
* negotiated fact about one workload, and letting it match a glob would mean
|
|
60
|
+
* a new label silently joining somebody's bill.
|
|
61
|
+
*/
|
|
62
|
+
shared?: Record<string, SharedSplit>;
|
|
63
|
+
/** Monthly budgets per owner, in dollars. */
|
|
64
|
+
budgets?: Record<string, number>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type OwnerProblem =
|
|
68
|
+
| { kind: 'split-does-not-sum'; label: string; total: number }
|
|
69
|
+
| { kind: 'split-names-unknown-owner'; label: string; owner: string }
|
|
70
|
+
| { kind: 'split-has-one-owner'; label: string; owner: string }
|
|
71
|
+
| { kind: 'budget-for-unknown-owner'; owner: string }
|
|
72
|
+
| { kind: 'negative-share'; label: string; owner: string; share: number };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Everything wrong with an ownership config, before any money is attributed.
|
|
76
|
+
*
|
|
77
|
+
* Returned rather than thrown, so all of it can be reported at once. A
|
|
78
|
+
* chargeback config fixed one error per run is a chargeback config somebody
|
|
79
|
+
* abandons halfway and then never trusts.
|
|
80
|
+
*/
|
|
81
|
+
export function validateOwners(config: OwnersConfig): OwnerProblem[] {
|
|
82
|
+
const problems: OwnerProblem[] = [];
|
|
83
|
+
const known = new Set(Object.keys(config.patterns));
|
|
84
|
+
|
|
85
|
+
for (const [label, split] of Object.entries(config.shared ?? {})) {
|
|
86
|
+
const entries = Object.entries(split);
|
|
87
|
+
if (entries.length === 1) {
|
|
88
|
+
// A "shared" workload with one owner is a pattern written the long way,
|
|
89
|
+
// and reading it as a share invites a second one to be added without the
|
|
90
|
+
// first being adjusted.
|
|
91
|
+
problems.push({ kind: 'split-has-one-owner', label, owner: entries[0]?.[0] ?? '' });
|
|
92
|
+
}
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (const [owner, share] of entries) {
|
|
95
|
+
if (!known.has(owner)) problems.push({ kind: 'split-names-unknown-owner', label, owner });
|
|
96
|
+
if (share < 0) problems.push({ kind: 'negative-share', label, owner, share });
|
|
97
|
+
total += share;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Summing to one, within a hair for floating point.
|
|
101
|
+
*
|
|
102
|
+
* Not a rounding problem to be normalised away: a split that sums to 0.9
|
|
103
|
+
* loses a tenth of that workload's money and a split that sums to 1.1
|
|
104
|
+
* invents a tenth. Both are silent, and both are the kind of error a
|
|
105
|
+
* chargeback report exists to make impossible.
|
|
106
|
+
*/
|
|
107
|
+
if (entries.length > 0 && Math.abs(total - 1) > 1e-9) {
|
|
108
|
+
problems.push({ kind: 'split-does-not-sum', label, total });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const owner of Object.keys(config.budgets ?? {})) {
|
|
113
|
+
if (!known.has(owner)) problems.push({ kind: 'budget-for-unknown-owner', owner });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return problems;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One workload's spend, as the caller measured it. */
|
|
120
|
+
export interface LabelSpend {
|
|
121
|
+
label: string;
|
|
122
|
+
usd: number;
|
|
123
|
+
calls: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type OwnerVerdict = 'within' | 'over' | 'not-measured' | 'no-budget';
|
|
127
|
+
|
|
128
|
+
export interface OwnerLine {
|
|
129
|
+
owner: string;
|
|
130
|
+
usd: number;
|
|
131
|
+
calls: number;
|
|
132
|
+
/** Which labels landed here, and how — so the attribution is checkable. */
|
|
133
|
+
from: Array<{ label: string; usd: number; via: 'pattern' | 'shared'; share?: number }>;
|
|
134
|
+
budgetUsd: number | null;
|
|
135
|
+
verdict: OwnerVerdict;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface Allocation {
|
|
139
|
+
owners: OwnerLine[];
|
|
140
|
+
/**
|
|
141
|
+
* Spend that matched no owner — its own line, never spread.
|
|
142
|
+
*
|
|
143
|
+
* `labels` names them, because "unallocated: $4,300" invites somebody to
|
|
144
|
+
* divide it and "unallocated: $4,300 across `search-v2` and `internal-eval`"
|
|
145
|
+
* invites somebody to claim it.
|
|
146
|
+
*/
|
|
147
|
+
unallocated: { usd: number; calls: number; labels: string[] };
|
|
148
|
+
/** The shared rules that were applied, carried so the report can print them. */
|
|
149
|
+
sharedApplied: Array<{ label: string; split: SharedSplit }>;
|
|
150
|
+
problems: OwnerProblem[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function allocate(spend: readonly LabelSpend[], config: OwnersConfig): Allocation {
|
|
154
|
+
const problems = validateOwners(config);
|
|
155
|
+
const lines = new Map<string, OwnerLine>();
|
|
156
|
+
const ensure = (owner: string): OwnerLine => {
|
|
157
|
+
let line = lines.get(owner);
|
|
158
|
+
if (line === undefined) {
|
|
159
|
+
const budgetUsd = config.budgets?.[owner] ?? null;
|
|
160
|
+
line = { owner, usd: 0, calls: 0, from: [], budgetUsd, verdict: 'no-budget' };
|
|
161
|
+
lines.set(owner, line);
|
|
162
|
+
}
|
|
163
|
+
return line;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Every declared owner gets a line, measured or not.
|
|
168
|
+
*
|
|
169
|
+
* An owner absent from the report is an owner nobody looks at, and the
|
|
170
|
+
* refusal below — "not measured is not under budget" — cannot be printed for
|
|
171
|
+
* somebody who is not on the page.
|
|
172
|
+
*/
|
|
173
|
+
for (const owner of Object.keys(config.patterns)) ensure(owner);
|
|
174
|
+
|
|
175
|
+
const unallocatedLabels: string[] = [];
|
|
176
|
+
let unallocatedUsd = 0;
|
|
177
|
+
let unallocatedCalls = 0;
|
|
178
|
+
|
|
179
|
+
// Flattened so specificity decides across owners, as `assignSources` does.
|
|
180
|
+
const patterns: Array<{ pattern: string; owner: string }> = [];
|
|
181
|
+
for (const [owner, globs] of Object.entries(config.patterns)) {
|
|
182
|
+
for (const pattern of globs) patterns.push({ pattern, owner });
|
|
183
|
+
}
|
|
184
|
+
const sharedApplied: Allocation['sharedApplied'] = [];
|
|
185
|
+
const brokenSplits = new Set(
|
|
186
|
+
problems
|
|
187
|
+
.filter((p) => p.kind === 'split-does-not-sum' || p.kind === 'negative-share')
|
|
188
|
+
.map((p) => (p as { label: string }).label),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
for (const entry of spend) {
|
|
192
|
+
const split = config.shared?.[entry.label];
|
|
193
|
+
/**
|
|
194
|
+
* A broken split allocates **nothing**, and the workload falls to
|
|
195
|
+
* unallocated.
|
|
196
|
+
*
|
|
197
|
+
* Applying a split that sums to 0.9 would put ten per cent of that
|
|
198
|
+
* workload nowhere while every line still looked complete. Falling to
|
|
199
|
+
* unallocated puts the whole workload somewhere visible, next to the
|
|
200
|
+
* problem that explains it.
|
|
201
|
+
*/
|
|
202
|
+
if (split !== undefined && !brokenSplits.has(entry.label)) {
|
|
203
|
+
sharedApplied.push({ label: entry.label, split });
|
|
204
|
+
for (const [owner, share] of Object.entries(split)) {
|
|
205
|
+
const line = ensure(owner);
|
|
206
|
+
line.usd += entry.usd * share;
|
|
207
|
+
line.calls += entry.calls * share;
|
|
208
|
+
line.from.push({ label: entry.label, usd: entry.usd * share, via: 'shared', share });
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const matched =
|
|
214
|
+
entry.label === UNLABELLED
|
|
215
|
+
? null
|
|
216
|
+
: mostSpecificMatch(
|
|
217
|
+
patterns.map((p) => p.pattern),
|
|
218
|
+
entry.label,
|
|
219
|
+
);
|
|
220
|
+
const owner = matched === null ? null : patterns.find((p) => p.pattern === matched)?.owner ?? null;
|
|
221
|
+
|
|
222
|
+
if (owner === null) {
|
|
223
|
+
unallocatedUsd += entry.usd;
|
|
224
|
+
unallocatedCalls += entry.calls;
|
|
225
|
+
unallocatedLabels.push(entry.label);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const line = ensure(owner);
|
|
229
|
+
line.usd += entry.usd;
|
|
230
|
+
line.calls += entry.calls;
|
|
231
|
+
line.from.push({ label: entry.label, usd: entry.usd, via: 'pattern' });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const line of lines.values()) {
|
|
235
|
+
line.from.sort((a, b) => b.usd - a.usd);
|
|
236
|
+
/**
|
|
237
|
+
* `not-measured` rather than `within`, and they are different words on
|
|
238
|
+
* purpose.
|
|
239
|
+
*
|
|
240
|
+
* A team whose logs never arrived passes every budget it has, forever. A
|
|
241
|
+
* report that renders that as a green tick has told somebody the opposite
|
|
242
|
+
* of the truth — the 1.37 refusal, applied to people rather than services.
|
|
243
|
+
*/
|
|
244
|
+
line.verdict =
|
|
245
|
+
line.budgetUsd === null
|
|
246
|
+
? 'no-budget'
|
|
247
|
+
: line.calls === 0
|
|
248
|
+
? 'not-measured'
|
|
249
|
+
: line.usd > line.budgetUsd
|
|
250
|
+
? 'over'
|
|
251
|
+
: 'within';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
owners: [...lines.values()].sort((a, b) => b.usd - a.usd),
|
|
256
|
+
unallocated: {
|
|
257
|
+
usd: unallocatedUsd,
|
|
258
|
+
calls: unallocatedCalls,
|
|
259
|
+
labels: [...new Set(unallocatedLabels)].sort(),
|
|
260
|
+
},
|
|
261
|
+
sharedApplied,
|
|
262
|
+
problems,
|
|
263
|
+
};
|
|
264
|
+
}
|
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
|
|