/** * Several records, one roll-up, and every contributor's gaps still visible. * * Everything else in this repository assumes one operator with files on disk. * `--by-source` and `owners` divide a bill somebody already collected; * `fleetRollup` compares services whose logs one machine could open. None of * them answers the question a team actually has: **four people measured four * things, and nobody wants to email logs around.** * * So this module merges *documents*, not logs. Each contributor runs * `trazum profile --json` wherever their traffic already is, and hands over a * profile document — which carries no prompt text, no completion text, no * session keys and no credentials, and never has. The roll-up is a format and * a merge rather than a service: the transport is somebody else's problem, * deliberately, because a tool whose argument is that it reads your bill * without uploading it cannot also be the place everybody's bill is uploaded. * * **The merge is the easy half.** The half worth building carefully is what * *cannot* be merged, and this module refuses in four different ways: * * - **Findings that need the records.** Percentile shapes, conversation * growth, repeated turns, truncation retries — every one of them is computed * from individual calls, and a summary of a summary cannot reproduce them. * They are named, with the contributors that had them, rather than dropped. * - **A day's dearest label.** Each contributor states its own; the merged * answer needs per-label-per-day figures no document carries. Where two * contributors share a day the answer is `null` with the reason attached, * not the louder contributor's. * - **Overlap between contributors.** Two people exporting the same traffic * double the bill, and nothing here can see it: the roll-up never sees a raw * line, so the duplicate detection a single profile does is structurally out * of reach. Every roll-up of more than one contributor says so. * - **Each contributor's own blind spots.** Unreadable lines, unpriced calls, * a log with no clock. Summing them into one figure would say "3% of this * roll-up is unpriced" when the truth is "one of your four machines is 90% * unpriced and the other three are clean" — the same averaging-away this arc * exists to refuse. They stay per contributor. * * No I/O: the caller hands over text it read, so this stays browser-safe and * the CLI keeps its monopoly on the filesystem. */ import type { OutcomeTally } from './outcome.js'; import type { FieldCoverage, UsageBreakdown } from './usage.js'; /** A document somebody handed over, under the name it should answer to. */ export interface RollupInput { /** How this contributor is named in the roll-up. The caller's choice. */ name: string; /** The profile document, as text — parsed and checked here, never trusted. */ text: string; } /** * One contributor's own gap, kept whole. * * `usd` and `calls` are `null` where the kind does not have one, never `0`: a * gap with no money attached and a gap that cost nothing are different * statements, and the second one is a measurement. */ export interface ContributorGap { kind: /** Lines this contributor's parser could not read at all. */ 'unreadable-lines' /** Calls whose model the price catalogue does not know. */ | 'unpriced-calls' /** No record carried a timestamp, so this contributor is in no day. */ | 'no-clock' /** Some records carried a timestamp and some did not. */ | 'partial-clock' /** No record carried a session. */ | 'no-sessions' /** No record carried a label. */ | 'no-labels' /** Duplicate lines this contributor found inside its own log. */ | 'duplicate-lines' /** Days inside the window it asked for on which it recorded nothing. */ | 'silent-days'; detail: string; usd: number | null; calls: number | null; } /** * A stretch inside a claimed window that recorded nothing at all. * * Contiguous runs rather than a list of dates: a contributor claiming a year * and recording three days of traffic produces one entry per gap instead of * three hundred and sixty-two strings, and the reader can still see exactly * which days are missing. */ export interface SilentRun { /** First silent day, `YYYY-MM-DD` UTC. */ from: string; /** Last silent day, inclusive. */ to: string; days: number; } export interface RollupContributor { name: string; /** * The roll-up this contributor arrived through, or null when it was handed * over directly. * * Contributors are **flattened, never collapsed**: a roll-up of three * roll-ups lists twelve machines rather than three, because collapsing them * would average twelve sets of gaps into three and that is the averaging * this whole arc exists to refuse. */ via: string | null; totalUsd: number; calls: number; /** The period this contributor's log covers, or null when it carried no clock. */ span: { fromMs: number; toMs: number; calls: number; } | null; /** The same span in days, or null. Stated, never extrapolated from. */ spanDays: number | null; /** * The window this contributor **asked for**, when it filtered by one. * * A claim, not a measurement, and the distinction is the point. `span` says * what the records showed; this says what was gone looking for. A log whose * latest record is the 5th may be a log of a quiet week or a log that * stopped being written on the 5th, and only a claim can tell those apart — * so a contributor that made none gets `null` here and the roll-up says that * out loud rather than reading its span as a period. * * The window is half-open, `[sinceMs, untilMs)`, as `profileUsage` applies * it. */ claimed: { sinceMs: number | null; untilMs: number | null; } | null; /** * Days inside a fully bounded claim on which this contributor recorded * nothing, and how many there are. * * **Named rather than interpolated**, the way a year report names its * missing months. Whether a silent stretch is a quiet week or a broken * export is the reader's to know; that it is silent is this tool's to say, * and a roll-up that folded it into a smaller total would be wrong by an * unknown amount in the flattering direction. * * Null when there is nothing to measure against: no claim, a claim with only * one end, or a claim too long to enumerate. */ silence: { runs: SilentRun[]; days: number; } | null; /** * Records the contributor's own window could not place, because they carried * no clock — the honesty cost of filtering by one. * * Null when there was no window, never 0: zero would say a window excluded * nothing, and no window is a different statement. */ undatedExcluded: number | null; gaps: ContributorGap[]; } /** A finding that exists per contributor and does not roll up. */ export interface UnmergedFinding { finding: string; because: string; /** The contributors that had one, so the reader knows where to go and look. */ presentIn: string[]; } /** * What a roll-up cannot say about itself. * * String codes rather than prose, so a consumer can branch on them and the * renderings can carry the sentences. `annual-record` established the shape. */ export type RollupCaveat = /** More than one contributor, so overlap between them is unmeasurable. */ 'overlap-invisible' /** Contributors cover meaningfully different periods. */ | 'mismatched-spans' /** Some contributor carried no clock at all. */ | 'contributor-without-clock' /** A day drew from more than one contributor, so its dearest label is unknown. */ | 'day-top-label-unknown' /** Two contributions were the same document. */ | 'identical-contributions' /** A contribution was handed over and not merged. */ | 'contribution-rejected' /** A contribution carried a numeric field this version cannot classify. */ | 'unknown-fields-dropped' /** A contributor stated no window, so its span is all that is known of it. */ | 'no-claimed-period' /** A contributor claimed days on which it recorded nothing. */ | 'silence-inside-a-claim' /** A contributor claimed one end of a window and not the other. */ | 'claim-not-bounded' /** A claim was too long to enumerate day by day, and was not. */ | 'claim-too-long-to-enumerate' /** A contributor name appears more than once, so its money may be counted twice. */ | 'contributor-named-twice'; export interface RollupDay { /** `YYYY-MM-DD`, UTC — the contributors' own bucketing, never re-derived. */ day: string; usd: number; calls: number; /** How many contributors saw traffic on this day. */ contributors: number; byModel: Array<{ model: string; usd: number; calls: number; }>; /** * The dearest label of the day, or **null** when more than one contributor * covered it. * * A profile knows its own day's dearest label; the merged answer needs each * contributor's per-label-per-day spend, which no document carries. Picking * the larger of two contributors' answers is what a helpful implementation * would do, and it is wrong whenever a runner-up in both adds up to more * than either winner. */ topLabel: string | null; topLabelUsd: number | null; } export interface RollupDocument { schemaVersion: 1; contributors: RollupContributor[]; /** * Handed over and not merged, each with why. Never dropped in silence. * * `via` names the roll-up a rejection arrived through, when it came from * one. A rejection that stopped travelling at a nesting boundary would mean * a broken export could be made to disappear by adding a layer, which is the * one thing a format built out of other people's measurements must not * allow. */ rejected: Array<{ name: string; via: string | null; because: string; }>; /** * Contributions that were the same document, grouped, and what the repeats * added to the total. * * Merged rather than discarded, and stated rather than repaired — the rule a * single profile already applies to duplicate lines. Whether it is one export * handed over twice or two machines that genuinely produced identical * documents is the reader's to know, and this tool does not decide it by * throwing money away. * * The comparison is over the whole text, not a hash of it: a hash collision * would report a duplicate that is not one, and this figure exists to make * somebody distrust a total. */ identicalContributions: { groups: string[][]; usd: number; }; total: UsageBreakdown; unpriced: UsageBreakdown; unpricedModels: string[]; byLabel: Array<{ label: string; breakdown: UsageBreakdown; }>; byModel: Array<{ model: string; breakdown: UsageBreakdown; }>; byLabelAndModel: Array<{ label: string; model: string; breakdown: UsageBreakdown; }>; spendByDay: RollupDay[]; /** Earliest start to latest end, over contributors that carried a clock. */ span: { fromMs: number; toMs: number; calls: number; } | null; /** * Earliest claimed start to latest claimed end, over contributors that * stated a fully bounded window. * * **Kept apart from `span`, deliberately.** One is what the records showed * and the other is what somebody went looking for, and a roll-up that merged * them would answer "what period does this cover" with a number that is half * measurement and half intention. Null when no contributor claimed one. */ claimedSpan: { fromMs: number; toMs: number; contributors: number; } | null; fieldCoverage: FieldCoverage; outcomeTally: OutcomeTally; /** Summed **within-contributor** duplicates. Overlap between them is elsewhere. */ duplicateLines: { count: number; usd: number; }; /** * Contributor names that appear more than once, across nesting. * * Handing over both a roll-up and one of the machines inside it counts that * machine's money twice, and unlike the identical-document check this one can * see it — the documents differ, but the name is the same. **Named, never * subtracted**: two machines genuinely called `api.json` in two teams is * possible, and deciding which case this is by removing money would be the * repair this tool never makes. */ repeatedContributors: string[]; notMerged: UnmergedFinding[]; cannotSay: RollupCaveat[]; } /** * Merges profile documents into one roll-up. * * Every input is checked against the `profile` contract before a figure of it * is used. A document that does not conform is **rejected with its reason and * not merged** — a roll-up that quietly skipped a malformed contribution would * report a total missing one machine's entire bill, and the reader would have * no way to tell that from that machine having spent nothing. */ export declare function rollUp(inputs: RollupInput[]): RollupDocument; //# sourceMappingURL=rollup.d.ts.map