@phosphorco/bb-thread-metrics 0.1.0 → 0.2.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.
package/CONTRACT.md CHANGED
@@ -1,4 +1,4 @@
1
- # Thread metrics wire v1 (@phosphorco/bb-thread-metrics 0.1.0)
1
+ # Thread metrics wire v1 (@phosphorco/bb-thread-metrics 0.2.0 candidate)
2
2
 
3
3
  | Supplier owns | Package owns | Host owns |
4
4
  | --- | --- | --- |
@@ -316,3 +316,85 @@ obsolete outcomes. These post-await fences are required even when the SDK claims
316
316
  cancellation support. Keep last good data during failure according to the host's
317
317
  staleness policy; a late response cannot revive a disposed/replaced demand.
318
318
  No package-global lifecycle or client cancellation machinery is introduced.
319
+
320
+
321
+ ## Additive recent snapshot capability (package0.2)
322
+
323
+ The new optional boolean `recentSnapshotV1` capability requires the exact fixed
324
+ `threadMetricsRecentSnapshotV1` handler; registration rejects either mismatch.
325
+ Input is strict `{ifRevision?:string}` within1024 canonical UTF-8 JSON bytes.
326
+ Full replies have wire version1, `unchanged:false`, opaque revision, immutable
327
+ publication time, rank time,72h recent window, a published config revision/value,
328
+ explicit coverage/materialization-pending/cap state and rows. Unchanged replies
329
+ have version1, `unchanged:true`, revision and no recognized full-only payload.
330
+ Recognized rows/config/times/population/coverage/cap fields are rejected BEFORE
331
+ stripping; unrelated future additive fields remain allowed within the byte cap. The revision
332
+ must exactly match requested ifRevision. A new supplier instance must not reuse a
333
+ revision for different content. Reads do not advance revision or run fold,
334
+ closure, rank or materializer work; suppliers serve a held published object.
335
+ Adapters validate shape/bytes but cannot prove supplier implementation purity.
336
+
337
+ Each row has independent own/family last-fold timestamps, asOf and content
338
+ revision; at least one activity timestamp must be in the envelope's72h window.
339
+ The row score-config revision must match the published envelope config. Own and
340
+ family each carry memberCount/coverage/priceVersion, compact approximation
341
+ provenance, four raw dollar windows and
342
+ provider-attributed dollar windows, omitted-provider count, four weighted-score
343
+ windows, and separately named dollar/score percentile ranks. `estimate`/`floor`
344
+ values are finite nonnegative amounts; `unavailable` has null amount. Explicit
345
+ floor0 is a supplier attestation; unsupported root zeros must be unavailable,
346
+ never inferred from a partial population or a missing row. Family providers must
347
+ represent deduplicated actual members, not sums of other family rows.
348
+
349
+ Full canonical JSON is capped at4MiB including additive unknown fields,1000 rows,
350
+ 8KiB per raw row before stripping, and32 provider entries per scope. Omitted
351
+ providers/rows, uncertain omission counts (`exact:false`, even lower-bound0),
352
+ and pending materialization cannot claim complete coverage. These
353
+ are post-transport decoded bounds, not raw-network/preparse allocation limits.
354
+ Providers select most recent rows within both row/byte caps, publish cutoff and
355
+ honest omission state; row omission alone proves neither absence nor zero. The
356
+ codec always checks finite known provider subtotals and weighted arithmetic,
357
+ including when other providers are unavailable or omitted. Available totals must
358
+ cover every known subtotal within eight relative machine epsilons (no absolute
359
+ small-dollar epsilon); incomplete provider sets or contributing floors
360
+ require a floor rather than an estimate. Unknown components may yield unavailable
361
+ totals, never invented zero. Complete provider sets reconcile exact totals.
362
+
363
+ Each scope AND provider has `approximation:{approximate,reasons}` independently
364
+ of observation coverage. Compact bounded reasons retain `closest-model`,
365
+ `provider-default`, `model-attribution`, `bucket-boundary`, `missing-price`, and
366
+ `source-gap`; aggregate provenance must include contributing provider reasons.
367
+ Unavailable prices require `missing-price`. Unavailable observations are different:
368
+ a partial/unknown projection with source-gap approximation and source-gap,
369
+ root-unattested, history-unavailable or missing-thread coverage may retain
370
+ unavailable values without claiming a price failure. Any explicit unpriced
371
+ coverage still requires missing-price, and provider missing-price reasons must
372
+ remain in aggregate approximation. Suppliers must not label an actual pricing
373
+ gap as an observation gap. This is the lossless reason mapping
374
+ from readV1; per-model rate associations remain in on-demand read/detail and are
375
+ not copied into listing rows. `priceVersion` still identifies the price table.
376
+ Real materializer conformance remains necessary before release.
377
+
378
+ Scoring-config schemaVersion2 is versioned separately from normalization semantics.
379
+ Default mode is percentile with gates .9 m20/h24/d7; omitted provider weights mean1.
380
+ Absolute mode supplies thresholds for all four windows. `weightedScoreV2` sums
381
+ provider dollars times weights, returns unknown when a component is unavailable,
382
+ and rejects invalid/duplicate provider values and arithmetic overflow. Weighting
383
+ never reprices the raw-dollar fields. UI, settings CAS and scoring policy remain
384
+ ledger/surface-owned; this package contains no settings storage or sliders.
385
+
386
+ Ranks cannot be non-null for an empty population; a positive singleton ranks0.
387
+ When rankAsOf is at or after the row value epoch, zero or unavailable amounts require null rank.
388
+ Older rank epochs may deliberately remain while newer amounts change; consumers
389
+ must gate heat on supported positive CURRENT amounts as well as ranks.
390
+ `recentPercentileHeat` applies that current weighted-score gate; legacy
391
+ `heat(ranks)` alone is insufficient for recent rows. This codec does not rebuild
392
+ or prove actual population percentiles.
393
+
394
+ Supplier ranks recompute at most once/minute outside reads. A publication binds
395
+ configuration and scored content atomically; pending changes retain explicit
396
+ coverage. Consumers hold the object by SDK-owner identity and use keyed row
397
+ selectors. They may conditionally poll at30s while interested/visible/connected,
398
+ coalescing existing hints without a per-thread request. Covered listing rows need
399
+ no readV1; explicit inspection outside the window may use the existing bounded
400
+ read/detail lane. The contract package itself never polls or retries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @phosphorco/bb-thread-metrics
2
2
 
3
- Version 0.1.0: bounded dollar summaries, family closure, heat ranks and optional
3
+ Version0.2.0 candidate (not released): adds a bounded recent snapshot and scoring-configv2. Existing wirev1 summaries, family closure, heat ranks and optional
4
4
  on-demand breakdowns for independent BB plugins. See [CONTRACT.md](CONTRACT.md)
5
5
  for ownership, wire semantics and proof limits.
6
6
 
@@ -29,3 +29,30 @@ checks emitted declaration compatibility with server and browser SDKs. The
29
29
  `/testing` entry's `runSupplierConformance` accepts the adopter's real registration
30
30
  factory and representative read/detail cases. It returns explicit source
31
31
  conformance checks, never live host acceptance.
32
+
33
+
34
+ ## Recent snapshot (0.2 candidate)
35
+
36
+ Owners may advertise `capabilities.recentSnapshotV1:true` with a matching
37
+ `recentSnapshot(input)` handler. `createThreadMetricsClient(...).recentSnapshot({ifRevision?})`
38
+ returns the immutable precomputed object or an unchanged revision. Absence of this
39
+ optional capability leaves the0.1 read/detail contract intact. Old consumers may
40
+ ignore it. The adapter does not add refresh timers, caches or materialization.
41
+
42
+ The full response holds own/family raw estimated-dollar windows and provider
43
+ contributions, separately named weighted scores, raw dollar ranks and score ranks,
44
+ activity/materialization/rank times, coverage/cap/pending flags and the published
45
+ scoring configuration. Zero is explicit supplier evidence; omission/unavailable is
46
+ not zero. Config weights affect heat scores, never retained dollars. Defaults are
47
+ weight1 and current p90 percentile gates. Raw/scored populations remain distinct.
48
+
49
+ Bounds:72h recent activity,1000 rows,4MiB canonical JSON and8KiB/row,32 provider
50
+ entries per projection/config. Bounds include unknown additive fields before
51
+ schema stripping; the SDK has already parsed transport JSON. Providers enforce
52
+ materializer work/storage and publication bounds independently. Clients must not
53
+ turn row omission into an eager per-ID read storm. The planned coordinator uses
54
+ one held snapshot/mount+30s conditional cadence; the package schedules nothing.
55
+
56
+ Current source tests are package/adapter proof only. Registry release, ledger
57
+ materialization, live byte/time measurement, all consumer adoptions and native
58
+ host/freshness proof remain separate gates.
package/dist/bb.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/bb.ts
2
2
  import { z } from "zod";
3
- import { LIMITS, METHODS, describeInputSchema, describeEnvelopeSchema, capabilitiesSchema, readInputSchema, detailInputSchema, parseBounded, negotiateVersion, validateReadResult, validateDetailResult, classifyOwnerError } from "./index.js";
3
+ import { RECENT_LIMITS, recentSnapshotInputSchema, validateRecentSnapshot, LIMITS, METHODS, describeInputSchema, describeEnvelopeSchema, capabilitiesSchema, readInputSchema, detailInputSchema, parseBounded, negotiateVersion, validateReadResult, validateDetailResult, classifyOwnerError } from "./index.js";
4
4
  function sdkSchema(schema, maxBytes) {
5
5
  return { "~standard": { version: 1, vendor: "bb-thread-metrics", jsonSchema: { input: () => z.toJSONSchema(schema, { io: "input", unrepresentable: "any" }), output: () => z.toJSONSchema(schema, { io: "output", unrepresentable: "any" }) }, validate(value) {
6
6
  try {
@@ -12,6 +12,8 @@ function sdkSchema(schema, maxBytes) {
12
12
  }
13
13
  function registerThreadMetricsOwner(bb, port) {
14
14
  const capabilities = capabilitiesSchema.parse(port.capabilities);
15
+ if (Boolean(capabilities.recentSnapshotV1) !== Boolean(port.recentSnapshot))
16
+ throw new Error("Recent snapshot capability/handler mismatch");
15
17
  if (capabilities.detail !== Boolean(port.detail))
16
18
  throw new Error("Detail capability/handler mismatch");
17
19
  const describe = () => ({ protocol: "bb-thread-metrics", versions: [1], capabilities });
@@ -32,6 +34,11 @@ function registerThreadMetricsOwner(bb, port) {
32
34
  }
33
35
  };
34
36
  bb.rpc.register(contract, handlers, { experimental_discoverable: true });
37
+ if (port.recentSnapshot)
38
+ bb.rpc.register({ threadMetricsRecentSnapshotV1: { input: sdkSchema(recentSnapshotInputSchema, RECENT_LIMITS.inputBytes), output: sdkSchema(z.unknown()) } }, { threadMetricsRecentSnapshotV1: async (raw) => {
39
+ const input = parseBounded(recentSnapshotInputSchema, raw, RECENT_LIMITS.inputBytes);
40
+ return validateRecentSnapshot(input, await port.recentSnapshot(input));
41
+ } }, { experimental_discoverable: true });
35
42
  if (port.detail)
36
43
  bb.rpc.register({ threadMetricsDetailV1: { input: sdkSchema(detailInputSchema, LIMITS.inputBytes), output: sdkSchema(z.unknown()) } }, { threadMetricsDetailV1: async (raw) => {
37
44
  const input = parseBounded(detailInputSchema, raw, LIMITS.inputBytes);
@@ -50,6 +57,10 @@ function createThreadMetricsClient(sdk, pluginId) {
50
57
  const raw = await call(METHODS.describe, {}, signal);
51
58
  return negotiateVersion(parseBounded(describeEnvelopeSchema, raw, LIMITS.describeBytes));
52
59
  },
60
+ async recentSnapshot(raw = {}, signal) {
61
+ const input = parseBounded(recentSnapshotInputSchema, raw, RECENT_LIMITS.inputBytes);
62
+ return validateRecentSnapshot(input, await call(METHODS.recentSnapshot, input, signal));
63
+ },
53
64
  async read(raw, signal) {
54
65
  const input = parseBounded(readInputSchema, raw, LIMITS.inputBytes);
55
66
  return validateReadResult(input, await call(METHODS.read, input, signal));
package/dist/index.js CHANGED
@@ -1,28 +1,239 @@
1
- // src/index.ts
1
+ // src/recent.ts
2
2
  import { z } from "zod";
3
+ var RECENT_LIMITS = Object.freeze({ rows: 1000, bytes: 4 * 1024 * 1024, rowBytes: 8 * 1024, inputBytes: 1024, providers: 32, windowMs: 3 * 24 * 60 * 60 * 1000 });
4
+ var id = z.string().min(1).max(200).regex(/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/);
5
+ var revision = z.string().min(1).max(256);
6
+ var time = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
7
+ var coverage = z.enum(["complete", "partial", "unknown"]);
8
+ var rank = z.number().finite().min(0).max(1).nullable();
9
+ var count = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
10
+ var windowShape = (value) => z.object({ m20: value, h24: value, d7: value, lifetime: value }).strip();
11
+ var reason = z.string().min(1).max(64);
12
+ var reasons = z.array(reason).max(16);
13
+ var recentDollarSchema = z.discriminatedUnion("kind", [
14
+ z.object({ kind: z.literal("estimate"), amount: z.number().finite().nonnegative() }).strip(),
15
+ z.object({ kind: z.literal("floor"), amount: z.number().finite().nonnegative() }).strip(),
16
+ z.object({ kind: z.literal("unavailable"), amount: z.null() }).strip()
17
+ ]);
18
+ var recentWindowsSchema = windowShape(recentDollarSchema);
19
+ var recentApproximationSchema = z.object({ approximate: z.boolean(), reasons: z.array(z.enum(["closest-model", "provider-default", "model-attribution", "bucket-boundary", "missing-price", "source-gap"])).max(6) }).strip().superRefine((v, c) => {
20
+ if (!v.approximate && v.reasons.length)
21
+ c.addIssue({ code: "custom", message: "Approximation reasons require approximate flag" });
22
+ });
23
+ var provider = z.object({ providerId: id, windows: recentWindowsSchema, approximation: recentApproximationSchema }).strip();
24
+ var recentScopeSchema = z.object({
25
+ memberCount: count,
26
+ coverage,
27
+ coverageReasons: reasons,
28
+ priceVersion: revision,
29
+ approximation: recentApproximationSchema,
30
+ windows: recentWindowsSchema,
31
+ scores: recentWindowsSchema,
32
+ providers: z.array(provider).max(RECENT_LIMITS.providers),
33
+ omittedProviders: count,
34
+ dollarRanks: z.object({ m20: rank, h24: rank, d7: rank }).strip(),
35
+ scoreRanks: z.object({ m20: rank, h24: rank, d7: rank }).strip()
36
+ }).strip().superRefine((v, c) => {
37
+ if (new Set(v.providers.map((x) => x.providerId)).size !== v.providers.length)
38
+ c.addIssue({ code: "custom", message: "Duplicate providers" });
39
+ if (v.omittedProviders && v.coverage === "complete")
40
+ c.addIssue({ code: "custom", message: "Omitted providers require partial coverage" });
41
+ });
42
+ var recentRowSchema = z.object({ threadId: id, revision, configRevision: revision, asOf: time, ownLastFoldAt: time.nullable(), familyLastFoldAt: time.nullable(), own: recentScopeSchema, family: recentScopeSchema }).strip();
43
+ var recentSnapshotInputSchema = z.object({ ifRevision: revision.optional() }).strict();
44
+ var thresholds = windowShape(z.number().finite().nonnegative());
45
+ var weights = z.record(id, z.number().finite().nonnegative()).refine((v) => Object.keys(v).length <= RECENT_LIMITS.providers, "Too many provider weights");
46
+ var configBase = { schemaVersion: z.literal(2), providerWeights: weights };
47
+ var scoringConfigV2Schema = z.discriminatedUnion("mode", [
48
+ z.object({ ...configBase, mode: z.literal("percentile"), gates: z.object({ m20: z.number().finite().min(0).max(1), h24: z.number().finite().min(0).max(1), d7: z.number().finite().min(0).max(1) }).strip() }).strip(),
49
+ z.object({ ...configBase, mode: z.literal("absolute"), thresholds }).strip()
50
+ ]);
51
+ var DEFAULT_SCORING_CONFIG_V2 = Object.freeze({ schemaVersion: 2, mode: "percentile", providerWeights: Object.freeze({}), gates: Object.freeze({ m20: 0.9, h24: 0.9, d7: 0.9 }) });
52
+ function weightedScoreV2(values, providerWeights) {
53
+ if (values.length > RECENT_LIMITS.providers)
54
+ throw new Error("Too many score providers");
55
+ weights.parse(providerWeights);
56
+ let sum = 0, unknown = false;
57
+ const seen = new Set;
58
+ for (const v of values) {
59
+ id.parse(v.providerId);
60
+ if (seen.has(v.providerId))
61
+ throw new Error("Duplicate score provider");
62
+ seen.add(v.providerId);
63
+ if (v.amount === null) {
64
+ unknown = true;
65
+ continue;
66
+ }
67
+ if (!Number.isFinite(v.amount) || v.amount < 0)
68
+ throw new Error("Invalid provider dollars");
69
+ const w = Object.hasOwn(providerWeights, v.providerId) ? providerWeights[v.providerId] : 1;
70
+ sum += w * v.amount;
71
+ if (!Number.isFinite(sum))
72
+ throw new Error("Weighted dollar overflow");
73
+ }
74
+ return unknown ? null : sum;
75
+ }
76
+ var population = z.object({ count, coverage }).strip();
77
+ var populations = z.object({ m20: population, h24: population, d7: population }).strip();
78
+ var scopedPopulations = z.object({ dollars: populations, scores: populations }).strip();
79
+ var recentSnapshotFullSchema = z.object({
80
+ version: z.literal(1),
81
+ revision,
82
+ unchanged: z.literal(false),
83
+ currency: z.literal("USD"),
84
+ estimated: z.literal(true),
85
+ cutoffLastFoldAt: time.nullable(),
86
+ asOf: time,
87
+ rankAsOf: time,
88
+ recentWindowMs: z.literal(RECENT_LIMITS.windowMs),
89
+ config: z.object({ revision, value: scoringConfigV2Schema }).strip(),
90
+ coverage,
91
+ coverageReasons: reasons,
92
+ materializationPending: count,
93
+ omittedRows: z.object({ count, exact: z.boolean() }).strip(),
94
+ population: z.object({ own: scopedPopulations, family: scopedPopulations }).strip(),
95
+ rows: z.array(recentRowSchema).max(RECENT_LIMITS.rows)
96
+ }).strip();
97
+ var recentSnapshotResultSchema = z.union([z.object({ version: z.literal(1), revision, unchanged: z.literal(true) }).strip(), recentSnapshotFullSchema]);
98
+ function validateRecentSnapshot(input, value) {
99
+ recentSnapshotInputSchema.parse(input);
100
+ const bytes = (v) => new TextEncoder().encode(JSON.stringify(v)).byteLength;
101
+ if (bytes(value) > RECENT_LIMITS.bytes)
102
+ throw new Error("Snapshot byte limit");
103
+ const raw = value;
104
+ if (raw?.rows !== undefined) {
105
+ if (!Array.isArray(raw.rows) || raw.rows.length > RECENT_LIMITS.rows)
106
+ throw new Error("Snapshot row limit");
107
+ for (const row of raw.rows)
108
+ if (bytes(row) > RECENT_LIMITS.rowBytes)
109
+ throw new Error("Snapshot row byte limit");
110
+ }
111
+ if (value && typeof value === "object" && value.unchanged === true) {
112
+ for (const field of Object.keys(recentSnapshotFullSchema.shape))
113
+ if (!["version", "revision", "unchanged"].includes(field) && Object.hasOwn(value, field))
114
+ throw new Error("Unchanged snapshot carries recognized full content");
115
+ }
116
+ const out = recentSnapshotResultSchema.parse(value);
117
+ if (out.unchanged) {
118
+ if (input.ifRevision !== out.revision)
119
+ throw new Error("Unmatched snapshot revision");
120
+ return out;
121
+ }
122
+ if (out.rankAsOf > out.asOf)
123
+ throw new Error("Future rank time");
124
+ if ((out.materializationPending || out.omittedRows.count || !out.omittedRows.exact) && out.coverage === "complete")
125
+ throw new Error("Pending/capped snapshot must disclose coverage");
126
+ let previousActivity = Infinity, previousId = "";
127
+ const ids = new Set;
128
+ for (const row of out.rows) {
129
+ if (ids.has(row.threadId))
130
+ throw new Error("Duplicate snapshot thread");
131
+ ids.add(row.threadId);
132
+ if (row.asOf > out.asOf)
133
+ throw new Error("Future row time");
134
+ if (row.configRevision !== out.config.revision)
135
+ throw new Error("Row score configuration mismatch");
136
+ if (row.ownLastFoldAt === null && row.familyLastFoldAt === null)
137
+ throw new Error("Missing fold activity timestamp");
138
+ const activity = Math.max(row.ownLastFoldAt ?? 0, row.familyLastFoldAt ?? 0);
139
+ if (activity > out.asOf || activity < Math.max(0, out.asOf - out.recentWindowMs))
140
+ throw new Error("Row outside recent activity window");
141
+ if (activity > previousActivity || activity === previousActivity && row.threadId < previousId)
142
+ throw new Error("Rows must be newest first with ID tie-break");
143
+ previousActivity = activity;
144
+ previousId = row.threadId;
145
+ for (const scope of ["own", "family"]) {
146
+ validateProjection(row[scope], out.config.value.providerWeights);
147
+ validateRanks(row[scope], out.population[scope], out.rankAsOf >= row.asOf);
148
+ }
149
+ }
150
+ if (out.omittedRows.count && out.rows.length && out.cutoffLastFoldAt !== previousActivity)
151
+ throw new Error("Missing eviction cutoff");
152
+ return out;
153
+ }
154
+ function validateProjection(projection, providerWeights) {
155
+ if (projection.coverageReasons.includes("unpriced") && !projection.approximation.reasons.includes("missing-price"))
156
+ throw new Error("Unpriced coverage requires missing-price provenance");
157
+ const observationGap = projection.coverage !== "complete" && !projection.coverageReasons.includes("unpriced") && projection.approximation.reasons.includes("source-gap") && projection.coverageReasons.some((r) => ["source-gap", "root-unattested", "history-unavailable", "missing-thread"].includes(r));
158
+ const unavailableProvenance = (approximation) => approximation.reasons.includes("missing-price") || observationGap && approximation.reasons.includes("source-gap");
159
+ for (const p of projection.providers) {
160
+ if (p.approximation.approximate && (!projection.approximation.approximate || p.approximation.reasons.some((reason2) => !projection.approximation.reasons.includes(reason2))))
161
+ throw new Error("Aggregate must retain provider approximation provenance");
162
+ if (Object.values(p.windows).some((v) => v.kind === "unavailable") && !unavailableProvenance(p.approximation))
163
+ throw new Error("Unavailable provider requires missing-price provenance");
164
+ }
165
+ if ([...Object.values(projection.windows), ...Object.values(projection.scores)].some((v) => v.kind === "unavailable") && !unavailableProvenance(projection.approximation))
166
+ throw new Error("Unavailable total requires missing-price provenance");
167
+ for (const window of ["m20", "h24", "d7", "lifetime"]) {
168
+ const known = projection.providers.filter((p) => p.windows[window].kind !== "unavailable");
169
+ const parts = known.map((p) => ({ providerId: p.providerId, amount: p.windows[window].amount }));
170
+ const dollars = parts.reduce((sum, p) => sum + p.amount, 0), score = weightedScoreV2(parts, providerWeights);
171
+ if (!Number.isFinite(dollars))
172
+ throw new Error("Provider dollar overflow");
173
+ const incomplete = projection.omittedProviders > 0 || known.length !== projection.providers.length;
174
+ const floor = incomplete || known.some((p) => p.windows[window].kind === "floor");
175
+ const equal = (a, b) => Math.abs(a - b) <= 8 * Number.EPSILON * Math.max(a, b);
176
+ for (const [total, subtotal, label] of [[projection.windows[window], dollars, "dollar"], [projection.scores[window], score, "score"]]) {
177
+ if (total.kind === "unavailable")
178
+ continue;
179
+ if (floor && total.kind === "estimate")
180
+ throw new Error("Partial/provider floor cannot become an exact estimate");
181
+ if (incomplete) {
182
+ if (total.amount < subtotal && !equal(total.amount, subtotal))
183
+ throw new Error(`Known provider ${label} subtotal exceeds total`);
184
+ } else if (!equal(total.amount, subtotal))
185
+ throw new Error(`Provider ${label} total mismatch`);
186
+ }
187
+ }
188
+ }
189
+ function validateRanks(projection, population2, contemporaneous) {
190
+ for (const [values, ranks, pops] of [[projection.windows, projection.dollarRanks, population2.dollars], [projection.scores, projection.scoreRanks, population2.scores]]) {
191
+ for (const w of ["m20", "h24", "d7"]) {
192
+ const r = ranks[w], n = pops[w].count;
193
+ if (r !== null && (n === 0 || n === 1 && r !== 0))
194
+ throw new Error("Rank contradicts empty/singleton population");
195
+ if (contemporaneous && r !== null && (values[w].amount === null || values[w].amount <= 0))
196
+ throw new Error("Rank requires supported positive contemporaneous amount");
197
+ }
198
+ }
199
+ }
200
+ function recentPercentileHeat(projection, gates = { m20: 0.9, h24: 0.9, d7: 0.9 }) {
201
+ for (const w of ["m20", "h24", "d7"])
202
+ if (!Number.isFinite(gates[w]) || gates[w] < 0 || gates[w] > 1)
203
+ throw new Error("Invalid gate");
204
+ for (const [w, label] of [["m20", "on-fire"], ["h24", "hot"], ["d7", "heavy"]]) {
205
+ const amount = projection.scores[w].amount, r = projection.scoreRanks[w];
206
+ if (amount !== null && amount > 0 && r !== null && r >= gates[w])
207
+ return label;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ // src/index.ts
213
+ import { z as z2 } from "zod";
3
214
  import { UsageVectorSchema } from "@phosphorco/bb-usage-algebra/vector";
4
215
  var LIMITS = Object.freeze({ threadIds: 200, seriesPoints: 288, detailRows: 200, inputBytes: 65536, outputBytes: 2097152, describeBytes: 16384, candidates: 128, listedPlugins: 1e4, concurrency: 4, callMs: 2000, discoveryMs: 5000, familyEdges: 50000, familyMembers: 1e4, familyDepth: 256 });
5
216
  var METRIC_BUCKET_MS = 300000;
6
217
  var WINDOW_BUCKETS = { m20: 4, h24: 288, d7: 2016, lifetime: 2016 };
7
218
  function retainedWindowStart(asOf, window) {
8
- time.parse(asOf);
219
+ time2.parse(asOf);
9
220
  windowSchema.parse(window);
10
221
  return Math.max(0, Math.floor(asOf / METRIC_BUCKET_MS) * METRIC_BUCKET_MS - (WINDOW_BUCKETS[window] - 1) * METRIC_BUCKET_MS);
11
222
  }
12
- var METHODS = Object.freeze({ describe: "threadMetricsDescribe", read: "threadMetricsReadV1", detail: "threadMetricsDetailV1" });
13
- var id = z.string().min(1).max(200).regex(/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/);
14
- var text = z.string().min(1).max(256);
15
- var time = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
16
- var money = z.number().finite().nonnegative().nullable();
17
- var scope = z.enum(["own", "family"]);
18
- var windowSchema = z.enum(["m20", "h24", "d7", "lifetime"]);
19
- var coverageSchema = z.enum(["complete", "partial", "unknown"]);
20
- var coverageReasonSchema = z.enum(["source-gap", "root-unattested", "history-unavailable", "member-cap", "depth-cap", "edge-cap", "missing-thread", "unpriced", "retention", "row-cap", "pagination", "population-incomplete"]);
21
- var reasons = z.array(coverageReasonSchema).max(16);
22
- var approximationSchema = z.object({
23
- approximate: z.boolean(),
24
- reasons: z.array(z.enum(["closest-model", "provider-default", "model-attribution", "bucket-boundary", "missing-price", "source-gap"])).max(6),
25
- rates: z.array(z.object({ providerId: id, modelId: text, rateId: text.nullable(), basis: z.enum(["matched", "closest-model", "provider-default", "unpriced"]) }).superRefine((rate, ctx) => {
223
+ var METHODS = Object.freeze({ describe: "threadMetricsDescribe", read: "threadMetricsReadV1", detail: "threadMetricsDetailV1", recentSnapshot: "threadMetricsRecentSnapshotV1" });
224
+ var id2 = z2.string().min(1).max(200).regex(/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/);
225
+ var text = z2.string().min(1).max(256);
226
+ var time2 = z2.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
227
+ var money = z2.number().finite().nonnegative().nullable();
228
+ var scope = z2.enum(["own", "family"]);
229
+ var windowSchema = z2.enum(["m20", "h24", "d7", "lifetime"]);
230
+ var coverageSchema = z2.enum(["complete", "partial", "unknown"]);
231
+ var coverageReasonSchema = z2.enum(["source-gap", "root-unattested", "history-unavailable", "member-cap", "depth-cap", "edge-cap", "missing-thread", "unpriced", "retention", "row-cap", "pagination", "population-incomplete"]);
232
+ var reasons2 = z2.array(coverageReasonSchema).max(16);
233
+ var approximationSchema = z2.object({
234
+ approximate: z2.boolean(),
235
+ reasons: z2.array(z2.enum(["closest-model", "provider-default", "model-attribution", "bucket-boundary", "missing-price", "source-gap"])).max(6),
236
+ rates: z2.array(z2.object({ providerId: id2, modelId: text, rateId: text.nullable(), basis: z2.enum(["matched", "closest-model", "provider-default", "unpriced"]) }).superRefine((rate, ctx) => {
26
237
  if (rate.basis === "unpriced" !== (rate.rateId === null))
27
238
  ctx.addIssue({ code: "custom", path: ["rateId"], message: "Priced basis requires rateId; unpriced basis requires null" });
28
239
  })).max(32).optional()
@@ -30,40 +241,40 @@ var approximationSchema = z.object({
30
241
  if (!v.approximate && (v.reasons.length || v.rates?.some((r) => r.basis !== "matched")))
31
242
  ctx.addIssue({ code: "custom", message: "Approximation provenance requires approximate flag" });
32
243
  });
33
- var rank = z.number().finite().min(0).max(1).nullable();
34
- var ranksSchema = z.object({ m20: rank, h24: rank, d7: rank }).strip();
35
- var population = z.object({ asOf: time, count: z.number().int().nonnegative(), coverage: coverageSchema }).strip();
36
- var rankPopulationSchema = z.object({ m20: population, h24: population, d7: population }).strip();
37
- var windowValueSchema = z.object({ tokens: UsageVectorSchema, amount: money }).strip();
38
- var windowsSchema = z.object({ m20: windowValueSchema, h24: windowValueSchema, d7: windowValueSchema, lifetime: windowValueSchema }).strip();
39
- var bucketSchema = z.object({ start: time, amount: money }).strip();
244
+ var rank2 = z2.number().finite().min(0).max(1).nullable();
245
+ var ranksSchema = z2.object({ m20: rank2, h24: rank2, d7: rank2 }).strip();
246
+ var population2 = z2.object({ asOf: time2, count: z2.number().int().nonnegative(), coverage: coverageSchema }).strip();
247
+ var rankPopulationSchema = z2.object({ m20: population2, h24: population2, d7: population2 }).strip();
248
+ var windowValueSchema = z2.object({ tokens: UsageVectorSchema, amount: money }).strip();
249
+ var windowsSchema = z2.object({ m20: windowValueSchema, h24: windowValueSchema, d7: windowValueSchema, lifetime: windowValueSchema }).strip();
250
+ var bucketSchema = z2.object({ start: time2, amount: money }).strip();
40
251
  var metadata = {
41
- threadId: id,
252
+ threadId: id2,
42
253
  scope,
43
- memberCount: z.number().int().nonnegative().max(LIMITS.familyMembers),
254
+ memberCount: z2.number().int().nonnegative().max(LIMITS.familyMembers),
44
255
  coverage: coverageSchema,
45
- coverageReasons: reasons,
46
- excluded: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
47
- asOf: time,
256
+ coverageReasons: reasons2,
257
+ excluded: z2.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
258
+ asOf: time2,
48
259
  revision: text,
49
260
  priceVersion: text,
50
- currency: z.literal("USD"),
51
- estimated: z.literal(true),
261
+ currency: z2.literal("USD"),
262
+ estimated: z2.literal(true),
52
263
  approximation: approximationSchema,
53
264
  ranks: ranksSchema,
54
265
  rankPopulation: rankPopulationSchema,
55
- reported: z.object({ amount: z.number().finite().nonnegative(), currency: z.string().min(1).max(16), asOf: time }).strip().optional()
266
+ reported: z2.object({ amount: z2.number().finite().nonnegative(), currency: z2.string().min(1).max(16), asOf: time2 }).strip().optional()
56
267
  };
57
- var summaryRowSchema = z.discriminatedUnion("changed", [
58
- z.object({ ...metadata, changed: z.literal(true), windows: windowsSchema, buckets: z.array(bucketSchema).max(LIMITS.seriesPoints).optional() }).strip(),
59
- z.object({ ...metadata, changed: z.literal(false), windows: z.never().optional(), buckets: z.never().optional() }).strip()
268
+ var summaryRowSchema = z2.discriminatedUnion("changed", [
269
+ z2.object({ ...metadata, changed: z2.literal(true), windows: windowsSchema, buckets: z2.array(bucketSchema).max(LIMITS.seriesPoints).optional() }).strip(),
270
+ z2.object({ ...metadata, changed: z2.literal(false), windows: z2.never().optional(), buckets: z2.never().optional() }).strip()
60
271
  ]);
61
- var readInputSchema = z.object({
62
- threadIds: z.array(id).min(1).max(LIMITS.threadIds),
272
+ var readInputSchema = z2.object({
273
+ threadIds: z2.array(id2).min(1).max(LIMITS.threadIds),
63
274
  scope,
64
- includeArchived: z.boolean().optional(),
65
- knownRevisions: z.record(id, text).optional(),
66
- series: z.object({ activeThreadId: id, maxPoints: z.number().int().min(1).max(LIMITS.seriesPoints) }).strict().optional()
275
+ includeArchived: z2.boolean().optional(),
276
+ knownRevisions: z2.record(id2, text).optional(),
277
+ series: z2.object({ activeThreadId: id2, maxPoints: z2.number().int().min(1).max(LIMITS.seriesPoints) }).strict().optional()
67
278
  }).strict().superRefine((v, ctx) => {
68
279
  if (new Set(v.threadIds).size !== v.threadIds.length)
69
280
  ctx.addIssue({ code: "custom", message: "Duplicate thread IDs" });
@@ -72,17 +283,17 @@ var readInputSchema = z.object({
72
283
  if (v.knownRevisions && Object.keys(v.knownRevisions).some((k) => !v.threadIds.includes(k)))
73
284
  ctx.addIssue({ code: "custom", message: "Unrequested revision key" });
74
285
  });
75
- var readResultSchema = z.object({ version: z.literal(1), rows: z.array(summaryRowSchema).max(LIMITS.threadIds) }).strip();
76
- var detailInputSchema = z.object({ threadId: id, scope, includeArchived: z.boolean().optional(), axis: z.enum(["model", "time"]), window: windowSchema, cursor: z.string().min(1).max(1024).optional(), limit: z.number().int().min(1).max(LIMITS.detailRows).optional() }).strict();
286
+ var readResultSchema = z2.object({ version: z2.literal(1), rows: z2.array(summaryRowSchema).max(LIMITS.threadIds) }).strip();
287
+ var detailInputSchema = z2.object({ threadId: id2, scope, includeArchived: z2.boolean().optional(), axis: z2.enum(["model", "time"]), window: windowSchema, cursor: z2.string().min(1).max(1024).optional(), limit: z2.number().int().min(1).max(LIMITS.detailRows).optional() }).strict();
77
288
  var detailBase = { amount: money, tokens: UsageVectorSchema, approximation: approximationSchema };
78
- var detailRowSchema = z.discriminatedUnion("axis", [
79
- z.object({ ...detailBase, axis: z.literal("model"), providerId: id, modelId: text }).strip(),
80
- z.object({ ...detailBase, axis: z.literal("time"), start: time, end: time }).strip().refine((v) => v.end > v.start, { message: "Empty time interval" })
289
+ var detailRowSchema = z2.discriminatedUnion("axis", [
290
+ z2.object({ ...detailBase, axis: z2.literal("model"), providerId: id2, modelId: text }).strip(),
291
+ z2.object({ ...detailBase, axis: z2.literal("time"), start: time2, end: time2 }).strip().refine((v) => v.end > v.start, { message: "Empty time interval" })
81
292
  ]);
82
- var detailResultSchema = z.object({ version: z.literal(1), threadId: id, scope, axis: z.enum(["model", "time"]), window: windowSchema, asOf: time, revision: text, priceVersion: text, currency: z.literal("USD"), estimated: z.literal(true), coverage: coverageSchema, coverageReasons: reasons, excluded: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), approximation: approximationSchema, total: windowValueSchema, rows: z.array(detailRowSchema).max(LIMITS.detailRows), nextCursor: z.string().min(1).max(1024).optional() }).strip();
83
- var describeInputSchema = z.object({}).strict();
84
- var describeEnvelopeSchema = z.object({ protocol: z.literal("bb-thread-metrics"), versions: z.array(z.number().int().positive()).max(16) }).passthrough();
85
- var capabilitiesSchema = z.object({ summary: z.literal(true), series: z.boolean(), detail: z.boolean() }).strip();
293
+ var detailResultSchema = z2.object({ version: z2.literal(1), threadId: id2, scope, axis: z2.enum(["model", "time"]), window: windowSchema, asOf: time2, revision: text, priceVersion: text, currency: z2.literal("USD"), estimated: z2.literal(true), coverage: coverageSchema, coverageReasons: reasons2, excluded: z2.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), approximation: approximationSchema, total: windowValueSchema, rows: z2.array(detailRowSchema).max(LIMITS.detailRows), nextCursor: z2.string().min(1).max(1024).optional() }).strip();
294
+ var describeInputSchema = z2.object({}).strict();
295
+ var describeEnvelopeSchema = z2.object({ protocol: z2.literal("bb-thread-metrics"), versions: z2.array(z2.number().int().positive()).max(16) }).passthrough();
296
+ var capabilitiesSchema = z2.object({ summary: z2.literal(true), series: z2.boolean(), detail: z2.boolean(), recentSnapshotV1: z2.boolean().optional() }).strip();
86
297
  var describeV1Schema = describeEnvelopeSchema.extend({ capabilities: capabilitiesSchema });
87
298
  function parseBounded(schema, value, maxBytes) {
88
299
  const json = JSON.stringify(value);
@@ -100,23 +311,23 @@ function negotiateVersion(value) {
100
311
  const v = describeV1Schema.safeParse(value);
101
312
  return v.success ? { kind: "ready", version: 1, capabilities: v.data.capabilities } : { kind: "incompatible" };
102
313
  }
103
- function dollarPercentiles(population2) {
314
+ function dollarPercentiles(population3) {
104
315
  const seen = new Set;
105
- for (const p of population2) {
316
+ for (const p of population3) {
106
317
  if (seen.has(p.threadId))
107
318
  throw new Error("Duplicate population thread");
108
319
  seen.add(p.threadId);
109
320
  if (p.amount !== null && (!Number.isFinite(p.amount) || p.amount < 0))
110
321
  throw new Error("Invalid dollars");
111
322
  }
112
- const active = population2.filter((p) => p.amount !== null && p.amount > 0);
323
+ const active = population3.filter((p) => p.amount !== null && p.amount > 0);
113
324
  const sorted = active.map((p) => p.amount).sort((a, b) => a - b);
114
325
  const lower = new Map;
115
326
  sorted.forEach((x, i) => {
116
327
  if (!lower.has(x))
117
328
  lower.set(x, i);
118
329
  });
119
- return new Map(population2.map((p) => [p.threadId, p.amount === null || p.amount === 0 ? null : active.length < 2 ? 0 : lower.get(p.amount) / (active.length - 1)]));
330
+ return new Map(population3.map((p) => [p.threadId, p.amount === null || p.amount === 0 ? null : active.length < 2 ? 0 : lower.get(p.amount) / (active.length - 1)]));
120
331
  }
121
332
  function heat(ranks, gates = { m20: 0.9, h24: 0.9, d7: 0.9 }) {
122
333
  for (const w of ["m20", "h24", "d7"])
@@ -222,7 +433,7 @@ function validateDetailResult(input, value) {
222
433
  let priorEnd;
223
434
  const keys = new Set;
224
435
  for (const row of out.rows) {
225
- if (row.approximation.approximate && (!out.approximation.approximate || row.approximation.reasons.some((reason) => !out.approximation.reasons.includes(reason))))
436
+ if (row.approximation.approximate && (!out.approximation.approximate || row.approximation.reasons.some((reason2) => !out.approximation.reasons.includes(reason2))))
226
437
  throw new Error("Aggregate total must retain contributing row approximation provenance");
227
438
  if (row.amount === null && !row.approximation.reasons.includes("missing-price"))
228
439
  throw new Error("Unknown dollars require missing-price provenance");
@@ -273,10 +484,22 @@ export {
273
484
  windowsSchema,
274
485
  windowValueSchema,
275
486
  windowSchema,
487
+ weightedScoreV2,
488
+ validateRecentSnapshot,
276
489
  validateReadResult,
277
490
  validateDetailResult,
278
491
  summaryRowSchema,
492
+ scoringConfigV2Schema,
279
493
  retainedWindowStart,
494
+ recentWindowsSchema,
495
+ recentSnapshotResultSchema,
496
+ recentSnapshotInputSchema,
497
+ recentSnapshotFullSchema,
498
+ recentScopeSchema,
499
+ recentRowSchema,
500
+ recentPercentileHeat,
501
+ recentDollarSchema,
502
+ recentApproximationSchema,
280
503
  readResultSchema,
281
504
  readInputSchema,
282
505
  ranksSchema,
@@ -298,7 +521,9 @@ export {
298
521
  capabilitiesSchema,
299
522
  bucketSchema,
300
523
  approximationSchema,
524
+ RECENT_LIMITS,
301
525
  METRIC_BUCKET_MS,
302
526
  METHODS,
303
- LIMITS
527
+ LIMITS,
528
+ DEFAULT_SCORING_CONFIG_V2
304
529
  };
package/dist/testing.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/testing.ts
2
2
  import { createFakePluginHost } from "@get-bb/plugin-sdk/testing";
3
- import { METHODS, LIMITS, readInputSchema, detailInputSchema, describeEnvelopeSchema, parseBounded, negotiateVersion, validateReadResult, validateDetailResult } from "./index.js";
3
+ import { RECENT_LIMITS, recentSnapshotInputSchema, validateRecentSnapshot, METHODS, LIMITS, readInputSchema, detailInputSchema, describeEnvelopeSchema, parseBounded, negotiateVersion, validateReadResult, validateDetailResult } from "./index.js";
4
4
  async function runSupplierConformance(options) {
5
5
  if (!options.readCases.length)
6
6
  throw new Error("At least one real read case required");
@@ -38,12 +38,13 @@ async function runSupplierConformance(options) {
38
38
  }
39
39
  try {
40
40
  await options.register(host.bb);
41
- let detail = false;
41
+ let detail = false, recent = false;
42
42
  await check("describe-negotiation", async () => {
43
43
  const n = negotiateVersion(parseBounded(describeEnvelopeSchema, await call(METHODS.describe, {}), LIMITS.describeBytes));
44
44
  if (n.kind !== "ready")
45
45
  throw new Error("No compatible v1");
46
46
  detail = n.capabilities.detail;
47
+ recent = Boolean(n.capabilities.recentSnapshotV1);
47
48
  });
48
49
  await check("strict-describe-input", () => rejectsInput(METHODS.describe, { unexpected: true }));
49
50
  for (let i = 0;i < options.readCases.length; i++)
@@ -61,6 +62,16 @@ async function runSupplierConformance(options) {
61
62
  throw new Error("Byte fixture is not oversized");
62
63
  await rejectsInput(METHODS.read, oversized);
63
64
  });
65
+ if (recent) {
66
+ if (!options.recentCases?.length)
67
+ checks.push({ name: "recent-cases-required", passed: false, error: "Advertised recent snapshot needs real cases" });
68
+ for (let i = 0;i < (options.recentCases?.length ?? 0); i++)
69
+ await check(`recent-${i}`, async () => {
70
+ const input = parseBounded(recentSnapshotInputSchema, options.recentCases[i], RECENT_LIMITS.inputBytes);
71
+ validateRecentSnapshot(input, await call(METHODS.recentSnapshot, input));
72
+ });
73
+ await check("strict-recent-input", () => rejectsInput(METHODS.recentSnapshot, { unexpected: true }));
74
+ }
64
75
  if (detail) {
65
76
  if (!options.detailCases?.length)
66
77
  checks.push({ name: "detail-cases-required", passed: false, error: "Advertised detail needs real cases" });