@trazum/core 1.47.0 → 1.49.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,133 @@
1
+ /**
2
+ * What a team has decided to live with, and how long they have been deciding it.
3
+ *
4
+ * 1.40 named this gap and could not fill it: *no document stores past waivers,
5
+ * and a history invented from the current config would be a guess presented as
6
+ * a record*. That was the right refusal — a config says what is waived **now**,
7
+ * and nothing in it says whether the same finding was waived last quarter under
8
+ * a different reason, or whether the expiry has been pushed forward four times
9
+ * by four people who each assumed somebody else had looked.
10
+ *
11
+ * It is fixable by **recording**, not by inferring. A waiver silences a gate;
12
+ * the moment it does, that use is a fact with a date on it, and this module
13
+ * reads those facts back.
14
+ *
15
+ * Three rules hold the whole thing up:
16
+ *
17
+ * **Nothing is back-filled.** The history starts the day the recording did,
18
+ * and `since` says which day that was. Reconstructing a past from the present
19
+ * config is exactly what 1.40 refused, and it would be worse here than
20
+ * nowhere: a fabricated "waived four times" is an accusation.
21
+ *
22
+ * **A use is recorded when the waiver silences something, not when it is
23
+ * written.** A waiver nobody's build has ever hit is not a habit — it is dead
24
+ * config, and the report says which it is rather than folding the two together.
25
+ *
26
+ * **The verdict describes the record, never the team.** "Renewed without being
27
+ * revisited" is a statement about dates and reasons in a file. Whether that was
28
+ * the right call is a conversation this tool does not get to have.
29
+ */
30
+ import type { WaiveEntry } from './config-schema.js';
31
+ /** One occasion on which a waiver silenced a gate. */
32
+ export interface WaiverUse {
33
+ schemaVersion: 1;
34
+ /** The UTC day it happened, `YYYY-MM-DD`. */
35
+ day: string;
36
+ /** The gate silenced — a `WAIVABLE_GATES` entry or `byLabel:<label>`. */
37
+ gate: string;
38
+ /** The reason the config gave **at that moment**, not today's. */
39
+ reason: string;
40
+ /** The expiry the waiver carried at that moment. */
41
+ until: string;
42
+ /**
43
+ * The commit that carried it, when the run could see one.
44
+ *
45
+ * Null is common and honest: a waiver used outside a repository, or in a CI
46
+ * job that did not export the SHA. Null never becomes "unknown commit" in a
47
+ * count — a use with no commit is still a use.
48
+ */
49
+ commit: string | null;
50
+ /** What the gate measured, so a recorded use is checkable rather than asserted. */
51
+ measuredUsd: number | null;
52
+ limitUsd: number | null;
53
+ }
54
+ export type WaiverVerdict =
55
+ /** One recorded use. Nothing to say about it yet. */
56
+ 'used-once'
57
+ /** Used repeatedly under one unchanging reason and one unchanging expiry. */
58
+ | 'recurring'
59
+ /**
60
+ * The expiry moved while the reason stayed the same.
61
+ *
62
+ * The shape a decision takes when nobody is revisiting it: the same sentence
63
+ * carried forward past its own deadline. Named, and never called wrong —
64
+ * plenty of real constraints outlive their first estimate.
65
+ */
66
+ | 'renewed-without-revisiting'
67
+ /**
68
+ * The reason changed between uses.
69
+ *
70
+ * Somebody looked. Worth telling apart from the case above, because it is
71
+ * the opposite behaviour and would otherwise be counted as the same habit.
72
+ */
73
+ | 'reason-changed';
74
+ export interface WaiverHabit {
75
+ gate: string;
76
+ /** How many recorded uses, never an estimate. */
77
+ uses: number;
78
+ firstDay: string;
79
+ lastDay: string;
80
+ /** Distinct days it fired on — a gate hit twice on one day is one day. */
81
+ days: number;
82
+ /** Every distinct reason given, oldest first. */
83
+ reasons: string[];
84
+ /** Every distinct expiry carried, oldest first. More than one means it moved. */
85
+ expiries: string[];
86
+ verdict: WaiverVerdict;
87
+ /** Whether this gate is still waived in the config as it stands today. */
88
+ stillConfigured: boolean;
89
+ }
90
+ export interface WaiverHistory {
91
+ schemaVersion: 1;
92
+ /**
93
+ * The first day any use was recorded, or null when none has been.
94
+ *
95
+ * Rendered as "the history starts here", because a reader looking at two
96
+ * uses needs to know whether that is two in the project's life or two since
97
+ * Tuesday.
98
+ */
99
+ since: string | null;
100
+ /** Most-used first — the order somebody would read in. */
101
+ habits: WaiverHabit[];
102
+ /**
103
+ * Waivers in the config today that no recorded run has ever hit.
104
+ *
105
+ * Dead config, not habit. Either the gate stopped failing — which is good
106
+ * news nobody wrote down — or the waiver names a situation that never
107
+ * arises. Both are worth deleting; neither is a team living with a finding.
108
+ */
109
+ neverUsed: string[];
110
+ /** Total recorded uses across every gate. */
111
+ totalUses: number;
112
+ }
113
+ /** `YYYY-MM-DD` for a moment, UTC — the same day boundary the store uses. */
114
+ export declare function waiverDay(at: Date): string;
115
+ /**
116
+ * Whether a value is a use record this module can read.
117
+ *
118
+ * Same posture as the plan validator: a line that is not a record is skipped
119
+ * and counted by the caller, never coerced into one. A malformed line in an
120
+ * append-only file is a fact about the file, and a reader that silently
121
+ * repairs it produces a history that is wrong by an unknown amount.
122
+ */
123
+ export declare function isWaiverUse(value: unknown): value is WaiverUse;
124
+ /**
125
+ * The habits, from the record and the config as it stands.
126
+ *
127
+ * `configured` is used for exactly one thing — telling a gate somebody is
128
+ * still waiving from one they stopped — and never to invent a use. A waiver in
129
+ * the config with no recorded use appears in `neverUsed`, with a count of
130
+ * nothing, which is the honest shape.
131
+ */
132
+ export declare function waiverHistory(uses: readonly WaiverUse[], configured?: readonly WaiveEntry[]): WaiverHistory;
133
+ //# sourceMappingURL=waivers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"waivers.d.ts","sourceRoot":"","sources":["../src/waivers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAErD,sDAAsD;AACtD,MAAM,WAAW,SAAS;IACxB,aAAa,EAAE,CAAC,CAAC;IACjB,6CAA6C;IAC7C,GAAG,EAAE,MAAM,CAAC;IACZ,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,MAAM,EAAE,MAAM,CAAC;IACf,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,mFAAmF;IACnF,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,MAAM,aAAa;AACvB,qDAAqD;AACnD,WAAW;AACb,6EAA6E;GAC3E,WAAW;AACb;;;;;;GAMG;GACD,4BAA4B;AAC9B;;;;;GAKG;GACD,gBAAgB,CAAC;AAErB,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,iFAAiF;IACjF,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,EAAE,aAAa,CAAC;IACvB,0EAA0E;IAC1E,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE,CAAC,CAAC;IACjB;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,0DAA0D;IAC1D,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,6CAA6C;IAC7C,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,6EAA6E;AAC7E,wBAAgB,SAAS,CAAC,EAAE,EAAE,IAAI,GAAG,MAAM,CAE1C;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,SAAS,CAU9D;AAQD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,SAAS,SAAS,EAAE,EAC1B,UAAU,GAAE,SAAS,UAAU,EAAO,GACrC,aAAa,CAgDf"}
@@ -0,0 +1,114 @@
1
+ /**
2
+ * What a team has decided to live with, and how long they have been deciding it.
3
+ *
4
+ * 1.40 named this gap and could not fill it: *no document stores past waivers,
5
+ * and a history invented from the current config would be a guess presented as
6
+ * a record*. That was the right refusal — a config says what is waived **now**,
7
+ * and nothing in it says whether the same finding was waived last quarter under
8
+ * a different reason, or whether the expiry has been pushed forward four times
9
+ * by four people who each assumed somebody else had looked.
10
+ *
11
+ * It is fixable by **recording**, not by inferring. A waiver silences a gate;
12
+ * the moment it does, that use is a fact with a date on it, and this module
13
+ * reads those facts back.
14
+ *
15
+ * Three rules hold the whole thing up:
16
+ *
17
+ * **Nothing is back-filled.** The history starts the day the recording did,
18
+ * and `since` says which day that was. Reconstructing a past from the present
19
+ * config is exactly what 1.40 refused, and it would be worse here than
20
+ * nowhere: a fabricated "waived four times" is an accusation.
21
+ *
22
+ * **A use is recorded when the waiver silences something, not when it is
23
+ * written.** A waiver nobody's build has ever hit is not a habit — it is dead
24
+ * config, and the report says which it is rather than folding the two together.
25
+ *
26
+ * **The verdict describes the record, never the team.** "Renewed without being
27
+ * revisited" is a statement about dates and reasons in a file. Whether that was
28
+ * the right call is a conversation this tool does not get to have.
29
+ */
30
+ /** `YYYY-MM-DD` for a moment, UTC — the same day boundary the store uses. */
31
+ export function waiverDay(at) {
32
+ return at.toISOString().slice(0, 10);
33
+ }
34
+ /**
35
+ * Whether a value is a use record this module can read.
36
+ *
37
+ * Same posture as the plan validator: a line that is not a record is skipped
38
+ * and counted by the caller, never coerced into one. A malformed line in an
39
+ * append-only file is a fact about the file, and a reader that silently
40
+ * repairs it produces a history that is wrong by an unknown amount.
41
+ */
42
+ export function isWaiverUse(value) {
43
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
44
+ return false;
45
+ const record = value;
46
+ return (record.schemaVersion === 1 &&
47
+ typeof record.day === 'string' &&
48
+ typeof record.gate === 'string' &&
49
+ typeof record.reason === 'string' &&
50
+ typeof record.until === 'string');
51
+ }
52
+ function verdictFor(reasons, expiries, uses) {
53
+ if (uses <= 1)
54
+ return 'used-once';
55
+ if (reasons.length > 1)
56
+ return 'reason-changed';
57
+ return expiries.length > 1 ? 'renewed-without-revisiting' : 'recurring';
58
+ }
59
+ /**
60
+ * The habits, from the record and the config as it stands.
61
+ *
62
+ * `configured` is used for exactly one thing — telling a gate somebody is
63
+ * still waiving from one they stopped — and never to invent a use. A waiver in
64
+ * the config with no recorded use appears in `neverUsed`, with a count of
65
+ * nothing, which is the honest shape.
66
+ */
67
+ export function waiverHistory(uses, configured = []) {
68
+ const byGate = new Map();
69
+ for (const use of uses) {
70
+ const list = byGate.get(use.gate);
71
+ if (list === undefined)
72
+ byGate.set(use.gate, [use]);
73
+ else
74
+ list.push(use);
75
+ }
76
+ const configuredGates = new Set(configured.map((entry) => entry.gate));
77
+ const habits = [];
78
+ for (const [gate, list] of byGate) {
79
+ const sorted = [...list].sort((a, b) => a.day.localeCompare(b.day));
80
+ // Distinct values in the order they were first seen, so "the reason
81
+ // changed" reads chronologically rather than alphabetically.
82
+ const reasons = [...new Set(sorted.map((u) => u.reason))];
83
+ const expiries = [...new Set(sorted.map((u) => u.until))];
84
+ const days = new Set(sorted.map((u) => u.day)).size;
85
+ const first = sorted[0];
86
+ const last = sorted[sorted.length - 1];
87
+ if (first === undefined || last === undefined)
88
+ continue;
89
+ habits.push({
90
+ gate,
91
+ uses: sorted.length,
92
+ firstDay: first.day,
93
+ lastDay: last.day,
94
+ days,
95
+ reasons,
96
+ expiries,
97
+ verdict: verdictFor(reasons, expiries, sorted.length),
98
+ stillConfigured: configuredGates.has(gate),
99
+ });
100
+ }
101
+ habits.sort((a, b) => b.uses - a.uses || a.gate.localeCompare(b.gate));
102
+ const allDays = uses.map((use) => use.day).sort((a, b) => a.localeCompare(b));
103
+ return {
104
+ schemaVersion: 1,
105
+ since: allDays[0] ?? null,
106
+ habits,
107
+ neverUsed: configured
108
+ .filter((entry) => !byGate.has(entry.gate))
109
+ .map((entry) => entry.gate)
110
+ .sort(),
111
+ totalUses: uses.length,
112
+ };
113
+ }
114
+ //# sourceMappingURL=waivers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"waivers.js","sourceRoot":"","sources":["../src/waivers.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trazum/core",
3
- "version": "1.47.0",
3
+ "version": "1.49.0",
4
4
  "description": "Trazum core: priced advisories for LLM prompts (caching, model tier, batching, schemas), plus deterministic trimming, token counting and pricing.",
5
5
  "license": "MIT",
6
6
  "author": "David Mu\u00f1oz Rey",
package/src/budget.ts ADDED
@@ -0,0 +1,321 @@
1
+ /**
2
+ * One measured number, wherever it is asked for.
3
+ *
4
+ * By 1.48 there are four ways to ask Trazum about money — a gate in CI, the
5
+ * terminal, the local endpoint an agent consults, the browser — and no
6
+ * guarantee any two of them agree about how much of a budget is left. Each
7
+ * computed its own answer from whatever it happened to be holding: a log, a
8
+ * store, a request body. Four right answers to four slightly different
9
+ * questions is how a CI failure and an agent's refusal come to disagree in
10
+ * front of somebody.
11
+ *
12
+ * This is that number. A budget becomes a **position**: a limit, a period, the
13
+ * measured spend inside it, and — the part that makes it honest — how much of
14
+ * that period was measured at all.
15
+ *
16
+ * **Nothing here forecasts.** "Sixty-one per cent of the budget, consumed over
17
+ * eleven of thirty days" is a measurement. "You will run out on the 24th" is a
18
+ * prediction, and this product has refused those since 1.27 at every scale it
19
+ * operates on. The burn-down below compares two shares that both already
20
+ * happened, and names the shape; it never produces a date.
21
+ *
22
+ * **A period nobody measured is not a period under budget.** The rule
23
+ * `fleetBudgetMissing` established for services in 1.37, applied to time: days
24
+ * inside the period with no measurement are counted and named, and a position
25
+ * standing on three measured days out of thirty says so rather than reporting
26
+ * a comfortable ninety per cent remaining.
27
+ */
28
+
29
+ import type { SpendConfig } from './config-schema.js';
30
+ import type { StoreRecord } from './store.js';
31
+ import { effectivePricing } from './pricing.js';
32
+ import type { PricingCatalogue } from './pricing.js';
33
+
34
+ const DAY_MS = 86_400_000;
35
+
36
+ /** The window a budget is spent over. Calendar months, UTC, like everything else. */
37
+ export interface BudgetPeriod {
38
+ kind: 'month';
39
+ /** `YYYY-MM`, UTC. */
40
+ id: string;
41
+ fromMs: number;
42
+ /** Half-open: the first instant of the next month. */
43
+ toMs: number;
44
+ days: number;
45
+ }
46
+
47
+ export type BudgetScope =
48
+ | { kind: 'total' }
49
+ | { kind: 'label'; label: string }
50
+ | { kind: 'source'; source: string };
51
+
52
+ /**
53
+ * How much of the period the measurement actually covers.
54
+ *
55
+ * Three values rather than a percentage, because the three lead to different
56
+ * decisions: act on it, act on it knowing it is a floor, or go and find out
57
+ * why nothing was measured.
58
+ */
59
+ export type BudgetCoverage = 'complete' | 'partial' | 'none';
60
+
61
+ /**
62
+ * The shape of the burn, named — never a date.
63
+ *
64
+ * A comparison of two shares that have both already happened: how much of the
65
+ * budget is gone against how much of the period is gone. `ahead` means the
66
+ * money is going faster than the calendar, which is a fact about the past
67
+ * eleven days and not a claim about the next nineteen.
68
+ */
69
+ export type BurnShape = 'ahead' | 'on-pace' | 'behind' | 'cannot-tell';
70
+
71
+ export interface BurnDown {
72
+ /** Share of the limit consumed, 0-1. Null when the limit is zero. */
73
+ consumedShare: number | null;
74
+ /** Share of the period elapsed at the instant this was computed, 0-1. */
75
+ elapsedShare: number;
76
+ shape: BurnShape;
77
+ /**
78
+ * Deliberately absent: any field naming a date the budget runs out.
79
+ *
80
+ * Stated here rather than left to be noticed, because it is the single most
81
+ * requested number this module will ever be asked for, and every future
82
+ * reader of this file will be tempted to add it. It cannot be measured; it
83
+ * can only be projected from a rate that the log has no reason to keep.
84
+ */
85
+ readonly forecast?: never;
86
+ }
87
+
88
+ export interface BudgetStanding {
89
+ schemaVersion: 1;
90
+ scope: BudgetScope;
91
+ limitUsd: number;
92
+ period: BudgetPeriod;
93
+ /** Measured spend inside the period. Never an estimate — see `provenance`. */
94
+ consumedUsd: number;
95
+ remainingUsd: number;
96
+ provenance: 'measured';
97
+ /** Distinct UTC days inside the period that carry any measurement. */
98
+ measuredDays: number;
99
+ /** Days of the period that have already elapsed at the instant asked. */
100
+ elapsedDays: number;
101
+ /**
102
+ * Elapsed days with no measurement at all, oldest first, capped for
103
+ * rendering. A day missing from a series is the thing a total cannot show.
104
+ */
105
+ unmeasuredDays: string[];
106
+ coverage: BudgetCoverage;
107
+ burn: BurnDown;
108
+ /**
109
+ * `cannot-tell` when nothing in the period was measured. A budget with no
110
+ * measurement behind it is not a budget under control, and reporting
111
+ * `within` would be the flattering direction — the one this repository
112
+ * refuses everywhere it can occur.
113
+ */
114
+ verdict: 'within' | 'over' | 'cannot-tell';
115
+ }
116
+
117
+ export interface BudgetReport {
118
+ schemaVersion: 1;
119
+ period: BudgetPeriod;
120
+ positions: BudgetStanding[];
121
+ /**
122
+ * Budgets configured with a scope nothing measured touches.
123
+ *
124
+ * Not a position of zero: a label that has no records may have been renamed,
125
+ * or may simply not have run. Both are worth knowing and neither is "under
126
+ * budget".
127
+ */
128
+ unmeasuredScopes: BudgetScope[];
129
+ }
130
+
131
+ /** The UTC month containing `at`, as a period. */
132
+ export function monthOf(at: Date): BudgetPeriod {
133
+ const year = at.getUTCFullYear();
134
+ const month = at.getUTCMonth();
135
+ const fromMs = Date.UTC(year, month, 1);
136
+ const toMs = Date.UTC(year, month + 1, 1);
137
+ return {
138
+ kind: 'month',
139
+ id: `${year}-${String(month + 1).padStart(2, '0')}`,
140
+ fromMs,
141
+ toMs,
142
+ days: Math.round((toMs - fromMs) / DAY_MS),
143
+ };
144
+ }
145
+
146
+ /** What a store record's tokens cost, at the catalogue's rates for its window. */
147
+ function priceOf(record: StoreRecord, catalogue: PricingCatalogue): number | null {
148
+ const model = catalogue.byId.get(record.model);
149
+ if (model === undefined) return null;
150
+ const rates = effectivePricing(model, new Date(record.fromMs));
151
+ const write5m = record.write5m * rates.inputPerMTok * 1.25;
152
+ const write1h = record.write1h * rates.inputPerMTok * 2;
153
+ return (
154
+ (record.input * rates.inputPerMTok +
155
+ record.cacheRead * rates.inputPerMTok * 0.1 +
156
+ write5m +
157
+ write1h +
158
+ record.output * rates.outputPerMTok) /
159
+ 1_000_000
160
+ );
161
+ }
162
+
163
+ /**
164
+ * The share of a record's window that falls inside the period.
165
+ *
166
+ * A store record covers a bucket, and a bucket can straddle a month boundary.
167
+ * Counting it wholly in or wholly out would move real money between months by
168
+ * up to a day; apportioning by overlap is the only answer that keeps two
169
+ * adjacent months summing to the same total as the pair.
170
+ *
171
+ * This is arithmetic about a window, not an estimate of anything: the record
172
+ * says what the window cost, and the overlap is exact.
173
+ */
174
+ function overlapShare(record: StoreRecord, period: BudgetPeriod): number {
175
+ const span = record.toMs - record.fromMs;
176
+ if (span <= 0) return record.fromMs >= period.fromMs && record.fromMs < period.toMs ? 1 : 0;
177
+ const from = Math.max(record.fromMs, period.fromMs);
178
+ const to = Math.min(record.toMs, period.toMs);
179
+ return to <= from ? 0 : (to - from) / span;
180
+ }
181
+
182
+ /** Every UTC day a record touches inside the period, as `YYYY-MM-DD`. */
183
+ function daysTouched(record: StoreRecord, period: BudgetPeriod): string[] {
184
+ const from = Math.max(record.fromMs, period.fromMs);
185
+ const to = Math.min(Math.max(record.toMs, record.fromMs + 1), period.toMs);
186
+ if (to <= from) return [];
187
+ const days: string[] = [];
188
+ for (let ms = Math.floor(from / DAY_MS) * DAY_MS; ms < to; ms += DAY_MS) {
189
+ days.push(new Date(ms).toISOString().slice(0, 10));
190
+ }
191
+ return days;
192
+ }
193
+
194
+ function shapeOf(consumedShare: number | null, elapsedShare: number, coverage: BudgetCoverage): BurnShape {
195
+ // Nothing measured, or nothing to measure against: the comparison has no
196
+ // meaning, and inventing one from an elapsed share alone would be a shape
197
+ // drawn from the calendar rather than from the bill.
198
+ if (coverage === 'none' || consumedShare === null) return 'cannot-tell';
199
+
200
+ // A five-point band, so a budget tracking the calendar to within a rounding
201
+ // error is not reported as drifting every time somebody looks.
202
+ const drift = consumedShare - elapsedShare;
203
+ const ahead = drift > 0.05;
204
+
205
+ /**
206
+ * **A floor can prove `ahead` and can never prove `behind`.**
207
+ *
208
+ * Partial coverage means the consumed figure is a floor on the period: the
209
+ * unmeasured days spent *something*, and nobody knows how much. A floor that
210
+ * has already outrun the calendar is unarguably ahead — the real figure is
211
+ * higher still. A floor that looks comfortable proves nothing at all, and
212
+ * reporting it as `behind` would turn missing measurement into good news,
213
+ * which is the flattering direction this repository refuses everywhere.
214
+ *
215
+ * The first version of this returned `behind` for three measured days out of
216
+ * twenty, beside a warning that the figure was a floor. Two sentences that
217
+ * contradicted each other, and the reassuring one came second.
218
+ */
219
+ if (coverage === 'partial') return ahead ? 'ahead' : 'cannot-tell';
220
+
221
+ if (ahead) return 'ahead';
222
+ if (drift < -0.05) return 'behind';
223
+ return 'on-pace';
224
+ }
225
+
226
+ /** How many unmeasured days are worth naming before the list becomes noise. */
227
+ export const MAX_UNMEASURED_NAMED = 10;
228
+
229
+ export interface BudgetOptions {
230
+ catalogue: PricingCatalogue;
231
+ /** The instant the position is taken. Every share below is as of this moment. */
232
+ now?: Date;
233
+ /** Which period. Defaults to the month containing `now`. */
234
+ period?: BudgetPeriod;
235
+ }
236
+
237
+ /**
238
+ * The live position of every configured budget, from measured records alone.
239
+ *
240
+ * Per-label and per-source budgets are **not** computed here, and their
241
+ * absence is the honest answer rather than an omission: a store record carries
242
+ * a provider, a model and the account's own grouping, and it does not carry a
243
+ * workload label — labels live in a per-call usage log, which a bucketed
244
+ * provider API does not serve. Reporting a per-label position from records
245
+ * that cannot distinguish labels would be a number assembled from the wrong
246
+ * denominator. They appear in `unmeasuredScopes` instead, which says what is
247
+ * true: nothing measured here can answer for them.
248
+ */
249
+ export function budgetPositions(
250
+ records: readonly StoreRecord[],
251
+ spend: SpendConfig | undefined,
252
+ options: BudgetOptions,
253
+ ): BudgetReport {
254
+ const { catalogue, now = new Date() } = options;
255
+ const period = options.period ?? monthOf(now);
256
+
257
+ const positions: BudgetStanding[] = [];
258
+ const unmeasuredScopes: BudgetScope[] = [];
259
+
260
+ const elapsedMs = Math.min(Math.max(0, now.getTime() - period.fromMs), period.toMs - period.fromMs);
261
+ const elapsedDays = Math.max(1, Math.ceil(elapsedMs / DAY_MS));
262
+ const elapsedShare = elapsedMs / (period.toMs - period.fromMs);
263
+
264
+ /**
265
+ * `monthlyUsd`, never `maxUsd`. The two are the same units over different
266
+ * denominators — one gates whatever period a log happens to cover, this
267
+ * gates a calendar month — and reading one for the other is exactly the
268
+ * disagreement this module exists to end.
269
+ */
270
+ if (spend?.monthlyUsd !== undefined) {
271
+ let consumedUsd = 0;
272
+ const measured = new Set<string>();
273
+ for (const record of records) {
274
+ const share = overlapShare(record, period);
275
+ if (share === 0) continue;
276
+ const usd = priceOf(record, catalogue);
277
+ // An unpriced model contributes no dollars **and no measured day**:
278
+ // counting the day would report the period as covered by money nobody
279
+ // can see, which is the same flattering omission in a different place.
280
+ if (usd === null) continue;
281
+ consumedUsd += usd * share;
282
+ for (const day of daysTouched(record, period)) measured.add(day);
283
+ }
284
+
285
+ const unmeasured: string[] = [];
286
+ for (let d = 0; d < elapsedDays; d += 1) {
287
+ const day = new Date(period.fromMs + d * DAY_MS).toISOString().slice(0, 10);
288
+ if (!measured.has(day)) unmeasured.push(day);
289
+ }
290
+
291
+ const coverage: BudgetCoverage =
292
+ measured.size === 0 ? 'none' : unmeasured.length === 0 ? 'complete' : 'partial';
293
+ const limitUsd = spend.monthlyUsd;
294
+ const consumedShare = limitUsd > 0 ? consumedUsd / limitUsd : null;
295
+
296
+ positions.push({
297
+ schemaVersion: 1,
298
+ scope: { kind: 'total' },
299
+ limitUsd,
300
+ period,
301
+ consumedUsd,
302
+ remainingUsd: limitUsd - consumedUsd,
303
+ provenance: 'measured',
304
+ measuredDays: measured.size,
305
+ elapsedDays,
306
+ unmeasuredDays: unmeasured.slice(0, MAX_UNMEASURED_NAMED),
307
+ coverage,
308
+ burn: { consumedShare, elapsedShare, shape: shapeOf(consumedShare, elapsedShare, coverage) },
309
+ verdict: coverage === 'none' ? 'cannot-tell' : consumedUsd > limitUsd ? 'over' : 'within',
310
+ });
311
+ }
312
+
313
+ for (const label of Object.keys(spend?.byLabel ?? {})) {
314
+ unmeasuredScopes.push({ kind: 'label', label });
315
+ }
316
+ for (const source of Object.keys(spend?.bySource ?? {})) {
317
+ unmeasuredScopes.push({ kind: 'source', source });
318
+ }
319
+
320
+ return { schemaVersion: 1, period, positions, unmeasuredScopes };
321
+ }
@@ -68,6 +68,23 @@ export interface WaiveEntry {
68
68
  export interface SpendConfig {
69
69
  /** Whole-log budget. `--max-usd` overrides it. */
70
70
  maxUsd?: number;
71
+ /**
72
+ * The calendar-month budget, spent against **measured** store records.
73
+ *
74
+ * A separate key from `maxUsd` on purpose, and the reason is worth stating
75
+ * because reusing one would have been so much less code. `maxUsd` gates
76
+ * *this log* — whatever period the file somebody passed happens to cover.
77
+ * This gates *this month*. Same units, different denominators, and one key
78
+ * carrying both is precisely how two surfaces of the same product come to
79
+ * disagree about how much is left: `serve` read `maxUsd` and compared it
80
+ * against the whole store, which could be a year, and reported the result as
81
+ * a budget position with a straight face.
82
+ *
83
+ * Nothing infers one from the other. A repository with a per-log gate and no
84
+ * monthly budget has no monthly position, and the tools say so rather than
85
+ * picking a number that is the right shape.
86
+ */
87
+ monthlyUsd?: number;
71
88
  /**
72
89
  * Per-day budget — the gate a whole-log total cannot arm. `--max-day-usd`
73
90
  * overrides it, and it inherits that flag's refusals: a log with no clock
@@ -217,7 +234,7 @@ export const CONFIG_KEYS = [
217
234
 
218
235
  export const CONFIG_BASELINE_KEYS = ['path', 'maxGrowthTokens', 'maxGrowthPct'] as const;
219
236
 
220
- export const CONFIG_SPEND_KEYS = ['maxUsd', 'maxDayUsd', 'maxSessionUsd', 'maxCacheLossUsd', 'byLabel', 'bySource'] as const;
237
+ export const CONFIG_SPEND_KEYS = ['maxUsd', 'monthlyUsd', 'maxDayUsd', 'maxSessionUsd', 'maxCacheLossUsd', 'byLabel', 'bySource'] as const;
221
238
 
222
239
  export const CONFIG_WAIVE_KEYS = ['gate', 'reason', 'until'] as const;
223
240
 
@@ -420,6 +437,9 @@ function parseSpend(raw: unknown, source: string): SpendConfig {
420
437
  if (raw.maxUsd !== undefined) {
421
438
  spend.maxUsd = requireNonNegativeNumber(raw.maxUsd, 'spend.maxUsd', source);
422
439
  }
440
+ if (raw.monthlyUsd !== undefined) {
441
+ spend.monthlyUsd = requireNonNegativeNumber(raw.monthlyUsd, 'spend.monthlyUsd', source);
442
+ }
423
443
  if (raw.maxCacheLossUsd !== undefined) {
424
444
  spend.maxCacheLossUsd = requireNonNegativeNumber(raw.maxCacheLossUsd, 'spend.maxCacheLossUsd', source);
425
445
  }
package/src/index.ts CHANGED
@@ -49,6 +49,19 @@ 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 { budgetPositions, monthOf, MAX_UNMEASURED_NAMED } from './budget.js';
53
+ export type {
54
+ BudgetCoverage,
55
+ BudgetOptions,
56
+ BudgetPeriod,
57
+ BudgetStanding,
58
+ BudgetReport,
59
+ BudgetScope,
60
+ BurnDown,
61
+ BurnShape,
62
+ } from './budget.js';
63
+ export { waiverHistory, waiverDay, isWaiverUse } from './waivers.js';
64
+ export type { WaiverHabit, WaiverHistory, WaiverUse, WaiverVerdict } from './waivers.js';
52
65
  export { answerCost } from './answer.js';
53
66
  export { guardSpend } from './guard.js';
54
67
  export type { GuardAlternative, GuardAnswer, GuardRequest, GuardVerdict } from './guard.js';