@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.
@@ -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