@rulvar/core 1.102.0 → 1.104.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/dist/index.d.ts CHANGED
@@ -828,14 +828,18 @@ interface EntryBillingFold {
828
828
  /**
829
829
  * The billing fold over one terminal entry (RV504), shared by the
830
830
  * CostReport and invoice folds so the total, every breakdown, and the
831
- * per-row prices can never disagree. When the entry's per-dispatch
832
- * `providerCalls` exactly cover its usage, each call is priced
831
+ * per-row prices can never disagree. Coverage is decided per MODEL with
832
+ * the symmetric key (RV604): for every model whose per-dispatch
833
+ * `providerCalls` sum to exactly its usage, each call is priced
833
834
  * individually, so a nonlinear long-context tier fires per REQUEST,
834
835
  * which is the pricing contract's stated semantics; an aggregate that
835
836
  * crossed a threshold no single request crossed no longer re-prices
836
- * the whole entry (the ninth-experiment 52% overreport). An entry with
837
- * no records, or records that do not cover its usage, folds exactly as
838
- * before: the per-model aggregate slices of {@link priceEntryUsage}.
837
+ * that model (the ninth-experiment 52% overreport, and the round-52
838
+ * multi-role default). A model with no records, or records that do not
839
+ * cover its usage, folds exactly as before: the per-model aggregate
840
+ * slices of {@link priceEntryUsage}. `fullyAttributed` is true only
841
+ * when every slice model is covered and no record names a model absent
842
+ * from the slices.
839
843
  */
840
844
  declare function priceEntryBilling(entry: JournalEntry, priceUsd: (servedBy: ModelRef, usage: Usage, seq?: number) => number | undefined): EntryBillingFold;
841
845
  /**
@@ -9563,6 +9567,16 @@ interface InvoiceExport {
9563
9567
  }>;
9564
9568
  /** Rows whose reconciliation is not 'provider-id-present'. */
9565
9569
  reconciliationFailures: number;
9570
+ /**
9571
+ * USD of allocation pools that had a target and no row to carry it
9572
+ * (RV605). The dust pass refuses to move such dollars onto another
9573
+ * model's rows just to make the column sum, so on the (pathological)
9574
+ * journals where this happens the flat `allocatedUsd` sum reproduces
9575
+ * `totalUsd` minus this amount. Absent when zero, which is every
9576
+ * well-formed journal: the per-slice remainder rows guarantee a row
9577
+ * wherever a slice has usage.
9578
+ */
9579
+ unallocatedUsd?: number;
9566
9580
  /** Rows carrying `usageUnknown`; present when at least one does. */
9567
9581
  usageUnknownRows?: number;
9568
9582
  /** Present and true when any contributing entry carried approximate usage. */
package/dist/index.js CHANGED
@@ -2401,12 +2401,11 @@ const BILLING_FIELDS = [
2401
2401
  "cacheReadTokens",
2402
2402
  "cacheWriteTokens"
2403
2403
  ];
2404
- /** Exact per-model coverage: records sum to every slice, counter for counter. */
2405
- function callsCoverSlices(slices, records) {
2406
- if (slices.length === 0 || records.length === 0) return false;
2404
+ /** Per-model billing-counter sums of either side of the coverage test. */
2405
+ function sumUsageByModel(items) {
2407
2406
  const sums = /* @__PURE__ */ new Map();
2408
- for (const record of records) {
2409
- let sum = sums.get(record.servedBy);
2407
+ for (const item of items) {
2408
+ let sum = sums.get(item.servedBy);
2410
2409
  if (sum === void 0) {
2411
2410
  sum = {
2412
2411
  fields: {
@@ -2417,54 +2416,61 @@ function callsCoverSlices(slices, records) {
2417
2416
  },
2418
2417
  reasoning: 0
2419
2418
  };
2420
- sums.set(record.servedBy, sum);
2419
+ sums.set(item.servedBy, sum);
2421
2420
  }
2422
- for (const field of BILLING_FIELDS) sum.fields[field] = (sum.fields[field] ?? 0) + record.usage[field];
2423
- sum.reasoning += record.usage.reasoningTokens ?? 0;
2421
+ for (const field of BILLING_FIELDS) sum.fields[field] = (sum.fields[field] ?? 0) + item.usage[field];
2422
+ sum.reasoning += item.usage.reasoningTokens ?? 0;
2424
2423
  }
2425
- for (const slice of slices) {
2426
- const sum = sums.get(slice.servedBy);
2427
- if (sum === void 0) return false;
2428
- for (const field of BILLING_FIELDS) if (sum.fields[field] !== slice.usage[field]) return false;
2429
- if (sum.reasoning !== (slice.usage.reasoningTokens ?? 0)) return false;
2424
+ return sums;
2425
+ }
2426
+ /**
2427
+ * The models whose records exactly cover their usage, counter for
2428
+ * counter. BOTH sides aggregate by `servedBy` (RV604): the usage slices
2429
+ * split one model's spend by role (a schema fires a same-model extract
2430
+ * by default, so several slices of one model are the ordinary shape),
2431
+ * and comparing a per-role slice against the per-model record sum
2432
+ * refused coverage on exactly that default, re-tiering aggregates no
2433
+ * single request crossed. The symmetric key compares model sum against
2434
+ * model sum, so coverage is per model: a covered model prices per call
2435
+ * while an uncovered one honestly keeps the aggregate basis.
2436
+ */
2437
+ function coveredModels(slices, records) {
2438
+ const covered = /* @__PURE__ */ new Set();
2439
+ if (slices.length === 0 || records.length === 0) return covered;
2440
+ const recordSums = sumUsageByModel(records);
2441
+ const sliceSums = sumUsageByModel(slices);
2442
+ for (const [model, sliceSum] of sliceSums) {
2443
+ const recordSum = recordSums.get(model);
2444
+ if (recordSum === void 0) continue;
2445
+ if (BILLING_FIELDS.every((field) => recordSum.fields[field] === sliceSum.fields[field]) && recordSum.reasoning === sliceSum.reasoning) covered.add(model);
2430
2446
  }
2431
- for (const model of sums.keys()) if (!slices.some((slice) => slice.servedBy === model)) return false;
2432
- return true;
2447
+ return covered;
2433
2448
  }
2434
2449
  /**
2435
2450
  * The billing fold over one terminal entry (RV504), shared by the
2436
2451
  * CostReport and invoice folds so the total, every breakdown, and the
2437
- * per-row prices can never disagree. When the entry's per-dispatch
2438
- * `providerCalls` exactly cover its usage, each call is priced
2452
+ * per-row prices can never disagree. Coverage is decided per MODEL with
2453
+ * the symmetric key (RV604): for every model whose per-dispatch
2454
+ * `providerCalls` sum to exactly its usage, each call is priced
2439
2455
  * individually, so a nonlinear long-context tier fires per REQUEST,
2440
2456
  * which is the pricing contract's stated semantics; an aggregate that
2441
2457
  * crossed a threshold no single request crossed no longer re-prices
2442
- * the whole entry (the ninth-experiment 52% overreport). An entry with
2443
- * no records, or records that do not cover its usage, folds exactly as
2444
- * before: the per-model aggregate slices of {@link priceEntryUsage}.
2458
+ * that model (the ninth-experiment 52% overreport, and the round-52
2459
+ * multi-role default). A model with no records, or records that do not
2460
+ * cover its usage, folds exactly as before: the per-model aggregate
2461
+ * slices of {@link priceEntryUsage}. `fullyAttributed` is true only
2462
+ * when every slice model is covered and no record names a model absent
2463
+ * from the slices.
2445
2464
  */
2446
2465
  function priceEntryBilling(entry, priceUsd) {
2447
2466
  const slices = entryUsageSlices(entry);
2448
2467
  const records = entry.providerCalls ?? [];
2449
- if (!callsCoverSlices(slices, records)) {
2450
- const aggregate = priceEntryUsage(entry, priceUsd);
2451
- return {
2452
- units: aggregate.priced.map((slice) => ({
2453
- source: "slice",
2454
- servedBy: slice.servedBy,
2455
- usage: slice.usage,
2456
- ...slice.role === void 0 ? {} : { role: slice.role },
2457
- usd: slice.usd
2458
- })),
2459
- usd: aggregate.usd,
2460
- unpriced: aggregate.unpriced,
2461
- fullyAttributed: false
2462
- };
2463
- }
2468
+ const covered = coveredModels(slices, records);
2464
2469
  const units = [];
2465
2470
  const unpricedByModel = /* @__PURE__ */ new Map();
2466
2471
  let usd = 0;
2467
2472
  for (const record of records) {
2473
+ if (!covered.has(record.servedBy)) continue;
2468
2474
  const price = priceUsd(record.servedBy, record.usage, entry.seq);
2469
2475
  if (price === void 0 || !Number.isFinite(price) || price < 0) {
2470
2476
  const sum = unpricedByModel.get(record.servedBy) ?? {
@@ -2489,6 +2495,32 @@ function priceEntryBilling(entry, priceUsd) {
2489
2495
  usd: price
2490
2496
  });
2491
2497
  }
2498
+ for (const slice of slices) {
2499
+ if (covered.has(slice.servedBy)) continue;
2500
+ const price = priceUsd(slice.servedBy, slice.usage, entry.seq);
2501
+ if (price === void 0 || !Number.isFinite(price) || price < 0) {
2502
+ const sum = unpricedByModel.get(slice.servedBy) ?? {
2503
+ inputTokens: 0,
2504
+ outputTokens: 0,
2505
+ cacheReadTokens: 0,
2506
+ cacheWriteTokens: 0
2507
+ };
2508
+ for (const field of BILLING_FIELDS) sum[field] += slice.usage[field];
2509
+ const reasoning = (sum.reasoningTokens ?? 0) + (slice.usage.reasoningTokens ?? 0);
2510
+ if (reasoning > 0) sum.reasoningTokens = reasoning;
2511
+ unpricedByModel.set(slice.servedBy, sum);
2512
+ continue;
2513
+ }
2514
+ usd += price;
2515
+ units.push({
2516
+ source: "slice",
2517
+ servedBy: slice.servedBy,
2518
+ usage: slice.usage,
2519
+ ...slice.role === void 0 ? {} : { role: slice.role },
2520
+ usd: price
2521
+ });
2522
+ }
2523
+ const fullyAttributed = slices.length > 0 && slices.every((slice) => covered.has(slice.servedBy)) && records.every((record) => slices.some((slice) => slice.servedBy === record.servedBy));
2492
2524
  return {
2493
2525
  units,
2494
2526
  usd,
@@ -2496,7 +2528,7 @@ function priceEntryBilling(entry, priceUsd) {
2496
2528
  servedBy,
2497
2529
  usage
2498
2530
  })),
2499
- fullyAttributed: true
2531
+ fullyAttributed
2500
2532
  };
2501
2533
  }
2502
2534
  /**
@@ -8404,16 +8436,29 @@ const USAGE_FIELDS = [
8404
8436
  "cacheReadTokens",
8405
8437
  "cacheWriteTokens"
8406
8438
  ];
8407
- /** entry.usage minus the records' sum, clamped at zero per field. */
8408
- function usageRemainder(total, records) {
8439
+ /**
8440
+ * One usage slice minus ITS OWN records' sum, clamped at zero per field
8441
+ * (RV605). A record belongs to a slice when it names the same serving
8442
+ * model, and the same role when the slice carries one; a slice written
8443
+ * without a role (the legacy whole-entry fallback) absorbs every record
8444
+ * of its model, which is exactly the pre-RV605 arithmetic for
8445
+ * single-model entries. Computing the remainder per slice instead of
8446
+ * per entry is what keeps an orphaned model's spend on a row of that
8447
+ * model: the whole-entry remainder was published under `entry.servedBy`,
8448
+ * so a slice with no records left its allocation pool rowless and the
8449
+ * dust pass moved its dollars onto another model's row.
8450
+ */
8451
+ function sliceRemainder(slice, records) {
8409
8452
  const remainder = {
8410
- inputTokens: total.inputTokens,
8411
- outputTokens: total.outputTokens,
8412
- cacheReadTokens: total.cacheReadTokens,
8413
- cacheWriteTokens: total.cacheWriteTokens
8453
+ inputTokens: slice.usage.inputTokens,
8454
+ outputTokens: slice.usage.outputTokens,
8455
+ cacheReadTokens: slice.usage.cacheReadTokens,
8456
+ cacheWriteTokens: slice.usage.cacheWriteTokens
8414
8457
  };
8415
- let reasoning = total.reasoningTokens ?? 0;
8458
+ let reasoning = slice.usage.reasoningTokens ?? 0;
8416
8459
  for (const record of records) {
8460
+ if (record.servedBy !== slice.servedBy) continue;
8461
+ if (slice.role !== void 0 && record.role !== slice.role) continue;
8417
8462
  for (const field of USAGE_FIELDS) remainder[field] = Math.max(0, remainder[field] - record.usage[field]);
8418
8463
  reasoning = Math.max(0, reasoning - (record.usage.reasoningTokens ?? 0));
8419
8464
  }
@@ -8437,9 +8482,16 @@ function totalTokens(usage) {
8437
8482
  * association so the flat sum over `rows` reproduces `totalUsd`
8438
8483
  * exactly. Rows on unpriced models keep zero: their spend is in
8439
8484
  * `unpriced`, not in `totalUsd`.
8485
+ *
8486
+ * A pool with a target and NO rows refuses the transfer (RV605): its
8487
+ * dollars are excluded from the dust reconciliation and returned as the
8488
+ * unallocated share, because dumping them on the globally largest row
8489
+ * moved one model's spend onto another model's line just to make the
8490
+ * column sum. The returned amount is zero on every well-formed journal
8491
+ * (the per-slice remainder rows above guarantee a row wherever a slice
8492
+ * has usage), so the flat sum still reproduces `totalUsd` exactly there.
8440
8493
  */
8441
8494
  function allocateRows(rows, entries, priceUsd, totalUsd) {
8442
- if (rows.length === 0) return;
8443
8495
  const targets = /* @__PURE__ */ new Map();
8444
8496
  for (const entry of entries) {
8445
8497
  if (entry.status === "running" || entry.usage === void 0) continue;
@@ -8455,6 +8507,9 @@ function allocateRows(rows, entries, priceUsd, totalUsd) {
8455
8507
  if (pool === void 0) pools.set(key, [row]);
8456
8508
  else pool.push(row);
8457
8509
  }
8510
+ let unallocated = 0;
8511
+ for (const [key, target] of targets) if (target !== 0 && !pools.has(key)) unallocated += target;
8512
+ if (rows.length === 0) return unallocated;
8458
8513
  for (const [key, members] of pools) {
8459
8514
  const target = targets.get(key) ?? 0;
8460
8515
  if (target === 0) continue;
@@ -8471,12 +8526,14 @@ function allocateRows(rows, entries, priceUsd, totalUsd) {
8471
8526
  }
8472
8527
  let absorber;
8473
8528
  for (const row of rows) if (absorber === void 0 || row.allocatedUsd > absorber.allocatedUsd) absorber = row;
8474
- if (absorber === void 0) return;
8529
+ if (absorber === void 0) return unallocated;
8530
+ const goal = totalUsd - unallocated;
8475
8531
  for (let pass = 0; pass < 8; pass += 1) {
8476
8532
  const flat = rows.reduce((acc, row) => acc + row.allocatedUsd, 0);
8477
- if (flat === totalUsd) break;
8478
- absorber.allocatedUsd += totalUsd - flat;
8533
+ if (flat === goal) break;
8534
+ absorber.allocatedUsd += goal - flat;
8479
8535
  }
8536
+ return unallocated;
8480
8537
  }
8481
8538
  /** A single row priced at its own model's rate; broken rates fold as unpriced. */
8482
8539
  function rowUsd(priceUsd, servedBy, usage, seq) {
@@ -8548,13 +8605,16 @@ function invoiceFromJournal(entries, priceUsd, options) {
8548
8605
  });
8549
8606
  continue;
8550
8607
  }
8551
- const remainder = usageRemainder(entry.usage, records);
8552
- if (remainder !== void 0 && entry.servedBy !== void 0) {
8553
- const usd = rowUsd(priceUsd, entry.servedBy, remainder, entry.seq);
8608
+ let remainderOrdinal = records.length + 1;
8609
+ for (const slice of entryUsageSlices(entry)) {
8610
+ const remainder = sliceRemainder(slice, records);
8611
+ if (remainder === void 0) continue;
8612
+ const usd = rowUsd(priceUsd, slice.servedBy, remainder, entry.seq);
8554
8613
  rows.push({
8555
8614
  ...base,
8556
- ordinal: records.length + 1,
8557
- servedBy: entry.servedBy,
8615
+ ordinal: remainderOrdinal,
8616
+ servedBy: slice.servedBy,
8617
+ ...slice.role === void 0 ? {} : { role: slice.role },
8558
8618
  outcome: "unattributed",
8559
8619
  usage: remainder,
8560
8620
  ...entry.usageApprox === true ? { usageApprox: true } : {},
@@ -8563,9 +8623,10 @@ function invoiceFromJournal(entries, priceUsd, options) {
8563
8623
  ...mark,
8564
8624
  reconciliation: "unattributed"
8565
8625
  });
8626
+ remainderOrdinal += 1;
8566
8627
  }
8567
8628
  }
8568
- allocateRows(rows, entries, priceUsd, report.grossUsd);
8629
+ const unallocatedUsd = allocateRows(rows, entries, priceUsd, report.grossUsd);
8569
8630
  const usageApprox = report.usageApprox === true || report.abandoned.usageApprox === true;
8570
8631
  return {
8571
8632
  rows,
@@ -8574,6 +8635,7 @@ function invoiceFromJournal(entries, priceUsd, options) {
8574
8635
  abandonedUsd: report.abandoned.usd,
8575
8636
  pricingBasis: "per-call",
8576
8637
  rowUsdNonAdditive: !everyEntryFullyAttributed,
8638
+ ...unallocatedUsd === 0 ? {} : { unallocatedUsd },
8577
8639
  unpriced: [...report.unpriced, ...report.abandoned.unpriced],
8578
8640
  reconciliationFailures: rows.filter((row) => row.reconciliation !== "provider-id-present").length,
8579
8641
  ...(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/core",
3
- "version": "1.102.0",
3
+ "version": "1.104.0",
4
4
  "description": "Rulvar core: L0 contracts, journal kernel, ctx primitives, agent runtime, model router, tool system, dynamic orchestrator, InMemory and JSONL stores, event stream.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",