@trazum/core 1.50.2 → 1.50.4

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/gateway.ts ADDED
@@ -0,0 +1,381 @@
1
+ /**
2
+ * In the path of the call, and still only able to say yes or no.
3
+ *
4
+ * 1.44 gave a local service that *answers* and 1.45 gave an agent a guard it
5
+ * may consult and ignore. Advice an implementation can skip is advice a budget
6
+ * cannot rely on, and a connector that pulls usage after the fact always
7
+ * reports the runaway after it ran. Standing in the path fixes both: usage is
8
+ * measured at the moment of the call, and a refusal is a refusal.
9
+ *
10
+ * It also makes this the most dangerous module in the product, and the two
11
+ * rules below are the whole design.
12
+ *
13
+ * ## It refuses; it never substitutes
14
+ *
15
+ * A call over budget is **rejected**, with a machine-readable reason and the
16
+ * cheaper alternative named. Silently swapping the model, trimming the prompt
17
+ * or downgrading a request in flight is the one behaviour this product must
18
+ * never have. The caller asked for something specific; a proxy that quietly
19
+ * answers a different question is worse than one that fails, because the
20
+ * failure is visible and the substitution is not.
21
+ *
22
+ * That is enforced in the *type*, not in a comment. A `GatewayDecision` is
23
+ * either `forward` — carrying nothing the caller did not send — or `refuse`,
24
+ * carrying no body at all. There is no shape in which this module hands back a
25
+ * modified request, so no future edit can add one without changing a type that
26
+ * every caller and every test reads.
27
+ *
28
+ * Substitution exists only as an operator's configured, logged decision, and
29
+ * even then it is a *different kind*: `substitute` names what changed and why,
30
+ * and every call that took it is marked so no later report treats it as the
31
+ * call the caller made.
32
+ *
33
+ * ## Failure is a decision made in advance
34
+ *
35
+ * When the gateway cannot tell — no budget, nothing measured, an unpriced
36
+ * model — somebody has to have already decided what happens. **Fail-open** and
37
+ * **fail-closed** are both defensible: one keeps the product working and lets
38
+ * the bill run, the other stops the bill and takes the product down with it.
39
+ * There is deliberately no default. A proxy that picks silently has made the
40
+ * most consequential decision in somebody's architecture on their behalf.
41
+ *
42
+ * ## Nothing about the payload is recorded
43
+ *
44
+ * Prompt and completion pass through. The store has held aggregates since
45
+ * 1.42 and standing in the path changes nothing about that: this module never
46
+ * receives the body text at all — it is handed a *description* of the call,
47
+ * and the shape of its inputs is what makes the promise checkable.
48
+ */
49
+
50
+ import type { PlanAssumption } from './plan.js';
51
+ import { effectivePricing, multipliersFor } from './pricing.js';
52
+ import type { PricingCatalogue } from './pricing.js';
53
+ import type { ModelPricing } from './types.js';
54
+
55
+ /**
56
+ * What the operator has decided happens when the gateway cannot judge.
57
+ *
58
+ * No default, and `cannot-tell` is not one of the values: this is the answer
59
+ * to *what do we do about* not being able to tell, which is a policy and not a
60
+ * measurement.
61
+ */
62
+ export type FailurePolicy = 'fail-open' | 'fail-closed';
63
+
64
+ export const FAILURE_POLICIES: readonly FailurePolicy[] = ['fail-open', 'fail-closed'];
65
+
66
+ /**
67
+ * A call, as the gateway sees it.
68
+ *
69
+ * **No prompt text, and no completion text.** The model, the counts and the
70
+ * label are everything a budget decision needs, and they are everything this
71
+ * module is given — so "nothing about the payload is recorded" is a fact about
72
+ * the interface rather than a discipline somebody has to maintain.
73
+ */
74
+ export interface GatewayCall {
75
+ provider: string;
76
+ model: string;
77
+ /** Input tokens the caller declared, or that the wire format made countable. */
78
+ inputTokens: number | null;
79
+ /** The ceiling the caller asked for, when the request named one. */
80
+ maxOutputTokens: number | null;
81
+ /** The workload, when the caller labelled it. */
82
+ label: string | null;
83
+ }
84
+
85
+ /** Where the budget stands, as the gateway was told at the last refresh. */
86
+ export interface GatewayStanding {
87
+ limitUsd: number;
88
+ consumedUsd: number;
89
+ /** Always measured — a gateway decision never rests on an estimate of spend. */
90
+ provenance: 'measured';
91
+ /** How stale the figure is, so a refusal can say what it rested on. */
92
+ asOfMs: number;
93
+ }
94
+
95
+ export type RefuseReason =
96
+ /** The budget is already past its limit, measured. Nothing was estimated. */
97
+ | 'budget-exhausted'
98
+ /** This call would take it past, on an estimate of this call. */
99
+ | 'call-would-cross'
100
+ /** Cannot tell, and the operator chose fail-closed. */
101
+ | 'cannot-tell-and-closed';
102
+
103
+ export type CannotTellCause = 'no-budget' | 'nothing-measured' | 'model-unpriced';
104
+
105
+ /** A cheaper way to make the same call, named on a refusal. */
106
+ export interface GatewayAlternative {
107
+ kind: 'route' | 'batch';
108
+ model: { id: string; displayName: string } | null;
109
+ savingUsd: number;
110
+ assumes: PlanAssumption[];
111
+ }
112
+
113
+ /**
114
+ * The decision, and the only three shapes it comes in.
115
+ *
116
+ * `forward` deliberately carries **nothing**. Not a rewritten model, not a
117
+ * trimmed prompt, not a header to add — because a field for any of those is
118
+ * how substitution arrives one refactor later, wearing a reasonable name.
119
+ */
120
+ export type GatewayDecision =
121
+ | {
122
+ kind: 'forward';
123
+ /** What this call was priced at, for the record the caller keeps. */
124
+ estimatedUsd: number | null;
125
+ /** Present when the gateway could not judge and the operator fails open. */
126
+ unjudged: CannotTellCause | null;
127
+ }
128
+ | {
129
+ kind: 'refuse';
130
+ reason: RefuseReason;
131
+ /** Which cause, when the reason is `cannot-tell-and-closed`. */
132
+ cause: CannotTellCause | null;
133
+ /** What the refusal rests on. Never `estimated` alone. */
134
+ restsOn: 'measured' | 'measured+estimated' | null;
135
+ standing: GatewayStanding | null;
136
+ estimatedUsd: number | null;
137
+ /** A refusal never arrives bare. Dearest saving first; may be empty. */
138
+ alternatives: GatewayAlternative[];
139
+ because: string;
140
+ }
141
+ | {
142
+ /**
143
+ * The operator configured a substitution, in advance, for this case.
144
+ *
145
+ * A separate kind rather than a `forward` with a changed model, so that
146
+ * nothing downstream can treat a substituted call as the call the caller
147
+ * made. `markedInStore` is not a suggestion: the marker is what stops a
148
+ * later report from attributing this traffic to a model the caller never
149
+ * asked for.
150
+ */
151
+ kind: 'substitute';
152
+ to: { id: string; displayName: string };
153
+ /** The operator's own words for why this rule exists. */
154
+ configuredReason: string;
155
+ estimatedUsd: number | null;
156
+ markedInStore: true;
157
+ };
158
+
159
+ export interface GatewayPolicy {
160
+ /** Required. There is no default — see the module note. */
161
+ onCannotTell: FailurePolicy;
162
+ /**
163
+ * Substitutions the operator configured in advance, by model id.
164
+ *
165
+ * Absent means refuse rather than swap, which is the only safe default for
166
+ * a field whose whole risk is being switched on without anybody noticing.
167
+ */
168
+ substitute?: Record<string, { to: string; reason: string }>;
169
+ }
170
+
171
+ export interface GatewayOptions {
172
+ catalogue: PricingCatalogue;
173
+ policy: GatewayPolicy;
174
+ on?: Date;
175
+ }
176
+
177
+ /** What this call costs at a model's rates, or null when it cannot be priced. */
178
+ function priceCall(
179
+ model: ModelPricing | undefined,
180
+ call: GatewayCall,
181
+ on: Date,
182
+ ): number | null {
183
+ if (model === undefined || call.inputTokens === null) return null;
184
+ const rates = effectivePricing(model, on);
185
+ const output = call.maxOutputTokens ?? 0;
186
+ return (call.inputTokens / 1_000_000) * rates.inputPerMTok + (output / 1_000_000) * rates.outputPerMTok;
187
+ }
188
+
189
+ /**
190
+ * Cheaper models of the same provider that this call fits inside.
191
+ *
192
+ * Same rule as the spend guard's: a model the prompt does not fit in is not a
193
+ * cheaper way to make the call, it is a way not to make it.
194
+ */
195
+ function alternativesFor(
196
+ model: ModelPricing,
197
+ call: GatewayCall,
198
+ catalogue: PricingCatalogue,
199
+ on: Date,
200
+ ): GatewayAlternative[] {
201
+ const mine = priceCall(model, call, on);
202
+ if (mine === null) return [];
203
+ const out: GatewayAlternative[] = [];
204
+ const here = effectivePricing(model, on);
205
+
206
+ for (const candidate of catalogue.byId.values()) {
207
+ if (candidate.id === model.id || candidate.provider !== model.provider) continue;
208
+ const there = effectivePricing(candidate, on);
209
+ if (there.inputPerMTok >= here.inputPerMTok) continue;
210
+ if (call.inputTokens !== null && candidate.contextWindow < call.inputTokens) continue;
211
+ const routed = priceCall(candidate, call, on);
212
+ if (routed === null) continue;
213
+ out.push({
214
+ kind: 'route',
215
+ model: { id: candidate.id, displayName: candidate.displayName },
216
+ savingUsd: mine - routed,
217
+ assumes: [{ kind: 'model-capability', model: candidate.displayName }],
218
+ });
219
+ }
220
+
221
+ /**
222
+ * The batch lever is offered on a refusal and **never as a substitution**.
223
+ *
224
+ * Moving a synchronous call onto a batch window changes when the answer
225
+ * arrives, which is a change to what the caller asked for. It belongs in the
226
+ * list of things a human might do, not in anything the proxy can do.
227
+ */
228
+ const batch = multipliersFor(model).batch;
229
+ if (batch !== null) {
230
+ out.push({ kind: 'batch', model: null, savingUsd: mine - mine * batch, assumes: [{ kind: 'batch-window' }] });
231
+ }
232
+
233
+ return out.sort((a, b) => b.savingUsd - a.savingUsd);
234
+ }
235
+
236
+ function whyCannotTell(
237
+ standing: GatewayStanding | null,
238
+ priced: number | null,
239
+ ): CannotTellCause | null {
240
+ if (standing === null) return 'no-budget';
241
+ if (standing.consumedUsd === 0 && standing.provenance !== 'measured') return 'nothing-measured';
242
+ return priced === null ? 'model-unpriced' : null;
243
+ }
244
+
245
+ /**
246
+ * Yes, no, or the operator's pre-made decision — for one call.
247
+ *
248
+ * Pure, so the rule that matters most (this never rewrites a request) is
249
+ * checkable without a socket, and so the proxy around it has nothing to do but
250
+ * move bytes.
251
+ */
252
+ export function gatewayDecision(
253
+ call: GatewayCall,
254
+ standing: GatewayStanding | null,
255
+ options: GatewayOptions,
256
+ ): GatewayDecision {
257
+ const { catalogue, policy, on = new Date() } = options;
258
+ const model = catalogue.byId.get(call.model);
259
+ const estimatedUsd = priceCall(model, call, on);
260
+
261
+ const cause = whyCannotTell(standing, estimatedUsd);
262
+ if (cause !== null) {
263
+ /**
264
+ * Cannot judge. The operator decided this in advance, and a substitution
265
+ * is **not** consulted here: swapping a model because a *budget* could not
266
+ * be read would be answering a different question for a reason that has
267
+ * nothing to do with the caller's request.
268
+ */
269
+ if (policy.onCannotTell === 'fail-open') {
270
+ return { kind: 'forward', estimatedUsd, unjudged: cause };
271
+ }
272
+ return {
273
+ kind: 'refuse',
274
+ reason: 'cannot-tell-and-closed',
275
+ cause,
276
+ restsOn: null,
277
+ standing,
278
+ estimatedUsd,
279
+ alternatives: [],
280
+ because: becauseCannotTell(cause),
281
+ };
282
+ }
283
+
284
+ // `cause === null` guarantees both of these, and the compiler does not know
285
+ // it — narrowed here rather than asserted, so a change to `whyCannotTell`
286
+ // that stopped guaranteeing them fails the build instead of the request.
287
+ if (standing === null || estimatedUsd === null || model === undefined) {
288
+ return { kind: 'forward', estimatedUsd, unjudged: 'no-budget' };
289
+ }
290
+
291
+ const already = standing.consumedUsd > standing.limitUsd;
292
+ const wouldCross = standing.consumedUsd + estimatedUsd > standing.limitUsd;
293
+ if (!already && !wouldCross) {
294
+ return { kind: 'forward', estimatedUsd, unjudged: null };
295
+ }
296
+
297
+ const configured = policy.substitute?.[call.model];
298
+ const target = configured === undefined ? undefined : catalogue.byId.get(configured.to);
299
+ if (configured !== undefined && target !== undefined) {
300
+ return {
301
+ kind: 'substitute',
302
+ to: { id: target.id, displayName: target.displayName },
303
+ configuredReason: configured.reason,
304
+ estimatedUsd: priceCall(target, call, on),
305
+ markedInStore: true,
306
+ };
307
+ }
308
+
309
+ return {
310
+ kind: 'refuse',
311
+ reason: already ? 'budget-exhausted' : 'call-would-cross',
312
+ cause: null,
313
+ // The two halves, named — the rule since 1.44. An exhausted budget needs
314
+ // no estimate of this call; a crossing does, and says so.
315
+ restsOn: already ? 'measured' : 'measured+estimated',
316
+ standing,
317
+ estimatedUsd,
318
+ alternatives: alternativesFor(model, call, catalogue, on),
319
+ because: already
320
+ ? 'The budget for this period is already spent, measured.'
321
+ : 'This call would take the budget past its limit, on an estimate of this call.',
322
+ };
323
+ }
324
+
325
+ function becauseCannotTell(cause: CannotTellCause): string {
326
+ return cause === 'no-budget'
327
+ ? 'No budget is configured, so there is nothing to judge this against, and this gateway is configured to fail closed.'
328
+ : cause === 'nothing-measured'
329
+ ? 'Nothing has been measured for this period, so how much of the budget is gone is unknown, and this gateway is configured to fail closed.'
330
+ : 'This model is not in the price catalogue, so the call cannot be priced, and this gateway is configured to fail closed.';
331
+ }
332
+
333
+ /**
334
+ * The tokens a provider's own response reports, from the response body.
335
+ *
336
+ * This is the reason the gateway measures better than a connector: the counts
337
+ * are the provider's, arriving with the answer, before any export exists.
338
+ *
339
+ * Returns null rather than zero when the body carries no usage. A response
340
+ * whose usage could not be read is a call whose cost is unknown, and a zero
341
+ * would make the period's total quietly too low — the flattering direction.
342
+ */
343
+ export function usageFromResponse(
344
+ provider: string,
345
+ body: unknown,
346
+ ): { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number } | null {
347
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) return null;
348
+ const usage = (body as { usage?: unknown }).usage;
349
+ if (typeof usage !== 'object' || usage === null || Array.isArray(usage)) return null;
350
+ const u = usage as Record<string, unknown>;
351
+ const num = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
352
+
353
+ if (provider === 'anthropic') {
354
+ if (typeof u.input_tokens !== 'number' && typeof u.output_tokens !== 'number') return null;
355
+ return {
356
+ inputTokens: num(u.input_tokens),
357
+ outputTokens: num(u.output_tokens),
358
+ cacheReadTokens: num(u.cache_read_input_tokens),
359
+ cacheWriteTokens: num(u.cache_creation_input_tokens),
360
+ };
361
+ }
362
+ if (provider === 'openai') {
363
+ if (typeof u.prompt_tokens !== 'number' && typeof u.completion_tokens !== 'number') return null;
364
+ const details = u.prompt_tokens_details;
365
+ const cached =
366
+ typeof details === 'object' && details !== null
367
+ ? num((details as Record<string, unknown>).cached_tokens)
368
+ : 0;
369
+ return {
370
+ // `prompt_tokens` includes the cached ones, so they are subtracted
371
+ // before the fresh input is reported — counting them twice would put
372
+ // the period's total above the invoice, and in the flattering
373
+ // direction. Same correction the connector has made since 1.41.
374
+ inputTokens: Math.max(0, num(u.prompt_tokens) - cached),
375
+ outputTokens: num(u.completion_tokens),
376
+ cacheReadTokens: cached,
377
+ cacheWriteTokens: 0,
378
+ };
379
+ }
380
+ return null;
381
+ }
package/src/index.ts CHANGED
@@ -49,6 +49,28 @@ export type { PlanParseFailure, PlanParseResult } from './plan.js';
49
49
  export type { PlanAction, PlanActionKind, PlanAssumption, PlanDocument } from './plan.js';
50
50
  export { verifyPlan } from './verify.js';
51
51
  export { buildHistory, storedReportFrom, MIN_RUN } from './history.js';
52
+ export { outcomeReport, judgeOutcome, OUTCOME_UNLOCKS } from './outcome.js';
53
+ export type {
54
+ OutcomeCoverage,
55
+ OutcomeReport,
56
+ OutcomeSlice,
57
+ OutcomeTally,
58
+ OutcomeUnlock,
59
+ OutcomeVerdict,
60
+ OutcomeVocabulary,
61
+ } from './outcome.js';
62
+ export { gatewayDecision, usageFromResponse, FAILURE_POLICIES } from './gateway.js';
63
+ export type {
64
+ CannotTellCause,
65
+ FailurePolicy,
66
+ GatewayAlternative,
67
+ GatewayCall,
68
+ GatewayDecision,
69
+ GatewayOptions,
70
+ GatewayPolicy,
71
+ GatewayStanding,
72
+ RefuseReason,
73
+ } from './gateway.js';
52
74
  export { conform } from './conform.js';
53
75
  export type {
54
76
  ConformOptions,
package/src/outcome.ts ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The counterpart every figure in this product has been missing.
3
+ *
4
+ * Everything Trazum reports is a cost. It can say a workload got forty per cent
5
+ * cheaper and cannot say whether it stopped working — a denominator with no
6
+ * numerator, since the beginning. The missing field is not something this tool
7
+ * can compute. It is something only the caller knows.
8
+ *
9
+ * ## Recorded, never inferred
10
+ *
11
+ * The whole module rests on one refusal. **No absence of complaint counts as
12
+ * success. No short conversation counts as resolution. No retry counts as
13
+ * failure on its own.** Every one of those is a plausible-looking heuristic
14
+ * that would turn a guess into a metric, and a metric somebody would then
15
+ * optimise against — which is how a tool ends up rewarding conversations that
16
+ * end early because the user gave up.
17
+ *
18
+ * Where nothing was recorded, the report says so and names what recording one
19
+ * would unlock. That is the `fieldCoverage` discipline from 1.19, applied to
20
+ * the question that matters most.
21
+ *
22
+ * ## The vocabulary is declared, not guessed
23
+ *
24
+ * `resolved`, `escalated`, `deflected`, `abandoned`, `thumbs-down` — every
25
+ * product has its own words, and **which of them count as success is a product
26
+ * judgement this tool has no standing to make**. A tool that decided
27
+ * `escalated` was a failure would be wrong at every company where escalation is
28
+ * the correct, designed outcome for a whole class of request.
29
+ *
30
+ * So the config names the values and names which are successes. An outcome in
31
+ * the log that the config never declared is **named as undeclared**, not
32
+ * quietly bucketed into one side or the other — a typo in an exporter should
33
+ * show up as a typo, not as a shift in the success rate.
34
+ *
35
+ * ## The privacy line does not move
36
+ *
37
+ * An outcome is a small enumerated value. The store keeps it the way it has
38
+ * kept everything since 1.42: aggregated, never alongside content.
39
+ */
40
+
41
+ /** What the config declares. Both halves are required; see the module note. */
42
+ export interface OutcomeVocabulary {
43
+ /** Every value this product records. Anything else in a log is undeclared. */
44
+ values: string[];
45
+ /**
46
+ * Which of them count as success.
47
+ *
48
+ * A subset of `values`, and it may be empty — a vocabulary that records only
49
+ * failures is a legitimate thing to declare, and it still gives a rate this
50
+ * tool can report against a cost.
51
+ */
52
+ success: string[];
53
+ }
54
+
55
+ export type OutcomeVerdict = 'success' | 'other' | 'undeclared';
56
+
57
+ /**
58
+ * How a single recorded value is judged. Three outcomes, never two.
59
+ *
60
+ * `other` is a declared value that is not a success. `undeclared` is a value
61
+ * nobody wrote down — which is a data-quality finding and not a result, and is
62
+ * reported as one.
63
+ */
64
+ export function judgeOutcome(value: string | null, vocabulary: OutcomeVocabulary): OutcomeVerdict | null {
65
+ if (value === null) return null;
66
+ if (!vocabulary.values.includes(value)) return 'undeclared';
67
+ return vocabulary.success.includes(value) ? 'success' : 'other';
68
+ }
69
+
70
+ /** One value, and what it cost. */
71
+ export interface OutcomeSlice {
72
+ value: string;
73
+ verdict: OutcomeVerdict;
74
+ calls: number;
75
+ usd: number;
76
+ }
77
+
78
+ export interface OutcomeCoverage {
79
+ /** Records that carried an outcome at all. */
80
+ recorded: number;
81
+ /** Every record that parsed — the denominator. */
82
+ parsed: number;
83
+ /**
84
+ * Spend on calls that carried no outcome.
85
+ *
86
+ * The figure that decides whether a success rate means anything. A rate
87
+ * computed over eight per cent of the bill is a rate about eight per cent of
88
+ * the bill, and printing it beside the total without this number is how a
89
+ * sample becomes a claim about the whole.
90
+ */
91
+ unrecordedUsd: number;
92
+ }
93
+
94
+ export interface OutcomeReport {
95
+ /** Per recorded value, dearest first. Empty when nothing was recorded. */
96
+ slices: OutcomeSlice[];
97
+ coverage: OutcomeCoverage;
98
+ /**
99
+ * Values found in the log that the config never declared, with what they
100
+ * cost. Named rather than bucketed: a typo in an exporter should surface as
101
+ * a typo and not as a shift in the success rate.
102
+ */
103
+ undeclared: OutcomeSlice[];
104
+ /**
105
+ * The success rate **by spend**, or null when it cannot honestly be stated.
106
+ *
107
+ * By spend rather than by call, because this product's whole subject is
108
+ * money: a success rate weighted by call count says one thing while the
109
+ * expensive half of the traffic fails, and the two figures diverge exactly
110
+ * when it matters.
111
+ *
112
+ * Null has one meaning and it is not zero: nothing was recorded. A rate of
113
+ * zero is a real, terrible measurement, and a tool that spells "nobody told
114
+ * me" the same way has destroyed the difference between them.
115
+ */
116
+ successShareOfRecordedUsd: number | null;
117
+ /**
118
+ * Why there is no rate, when there is none. A refusal never arrives bare.
119
+ */
120
+ noRate: 'nothing-recorded' | 'no-success-values-declared' | null;
121
+ }
122
+
123
+ /**
124
+ * What was recorded, before anybody judged it.
125
+ *
126
+ * Deliberately split from the report: `profileUsage` produces this while it
127
+ * reads a log, knowing nothing about which values mean success, and the
128
+ * judgement happens where the config is. Measurement and product judgement are
129
+ * different jobs and this is the seam between them.
130
+ *
131
+ * It is also an **aggregate**, never a list of records — the same shape the
132
+ * store has kept since 1.42. Counting outcomes never means keeping calls.
133
+ */
134
+ export interface OutcomeTally {
135
+ /** Per recorded value: how many calls carried it and what they cost. */
136
+ byValue: Array<{ value: string; calls: number; usd: number }>;
137
+ recorded: number;
138
+ parsed: number;
139
+ unrecordedUsd: number;
140
+ }
141
+
142
+ export function outcomeReport(
143
+ tally: OutcomeTally,
144
+ vocabulary: OutcomeVocabulary | null,
145
+ ): OutcomeReport {
146
+ const coverage: OutcomeCoverage = {
147
+ recorded: tally.recorded,
148
+ parsed: tally.parsed,
149
+ unrecordedUsd: tally.unrecordedUsd,
150
+ };
151
+
152
+ /**
153
+ * With no vocabulary declared, every recorded value is `undeclared`.
154
+ *
155
+ * That is the honest reading rather than an inconvenience: somebody has been
156
+ * writing outcomes into a log the config never described, so this tool knows
157
+ * what happened and not what any of it means. It reports the values and their
158
+ * cost, and declines the rate.
159
+ */
160
+ const declared = vocabulary ?? { values: [], success: [] };
161
+
162
+ const all: OutcomeSlice[] = [...tally.byValue]
163
+ .map((entry) => ({
164
+ value: entry.value,
165
+ verdict: judgeOutcome(entry.value, declared) as OutcomeVerdict,
166
+ calls: entry.calls,
167
+ usd: entry.usd,
168
+ }))
169
+ .sort((a, b) => b.usd - a.usd);
170
+
171
+ const slices = all.filter((slice) => slice.verdict !== 'undeclared');
172
+ const undeclared = all.filter((slice) => slice.verdict === 'undeclared');
173
+
174
+ /**
175
+ * The rate's denominator is **declared, recorded spend** — not the whole
176
+ * bill, and not every recorded value.
177
+ *
178
+ * Undeclared values are left out of both halves rather than counted as
179
+ * failures. A misspelled `resolvd` is a broken exporter, and folding it into
180
+ * the failure side would report a product regression that never happened —
181
+ * the direction that gets somebody paged at four in the morning.
182
+ */
183
+ const declaredUsd = slices.reduce((sum, slice) => sum + slice.usd, 0);
184
+ const successUsd = slices
185
+ .filter((slice) => slice.verdict === 'success')
186
+ .reduce((sum, slice) => sum + slice.usd, 0);
187
+
188
+ let successShareOfRecordedUsd: number | null = null;
189
+ let noRate: OutcomeReport['noRate'] = null;
190
+ if (declared.success.length === 0) {
191
+ noRate = 'no-success-values-declared';
192
+ } else if (declaredUsd <= 0) {
193
+ noRate = 'nothing-recorded';
194
+ } else {
195
+ successShareOfRecordedUsd = successUsd / declaredUsd;
196
+ }
197
+
198
+ return { slices, coverage, undeclared, successShareOfRecordedUsd, noRate };
199
+ }
200
+
201
+ /**
202
+ * What recording an outcome would unlock, for a report that has none.
203
+ *
204
+ * Named rather than implied. "No outcome recorded" on its own reads as a
205
+ * missing feature; the point is that one small enumerated field turns every
206
+ * cost figure in this product into a cost *per* something.
207
+ */
208
+ export const OUTCOME_UNLOCKS = [
209
+ 'cost-per-outcome',
210
+ 'success-rate-by-spend',
211
+ 'cheaper-and-still-working',
212
+ ] as const;
213
+
214
+ export type OutcomeUnlock = (typeof OUTCOME_UNLOCKS)[number];