@dzhechkov/harness-core 0.3.144 → 0.3.146

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/guard.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  // PURE: `evaluateGuard` operates over INJECTED FACTS (package.json deps, a drift result, lesson text, README
12
12
  // counts, store size) that the CLI gathers. No filesystem here → deterministic + unit-testable without a repo.
13
13
 
14
+ import { type RuleTemplate, type TemplateParams, type ChangeSet, templateFires, validTemplateParams } from './guard-promotion.js';
15
+
14
16
  export type GuardSeverity = 'hard' | 'soft';
15
17
  export type GuardOp = 'publish' | 'teach' | 'consolidate' | 'reindex';
16
18
  export type GuardVerdict = 'pass' | 'warn' | 'block';
@@ -23,6 +25,13 @@ export interface GuardRule {
23
25
  readonly description: string;
24
26
  /** false ⇒ the rule is disabled (config override). */
25
27
  readonly enabled?: boolean;
28
+ /**
29
+ * A PROMOTED rule (`dz guard promote`) carries a template + params instead of a built-in checker.
30
+ * This is the ONLY way a rule id the engine does not know may enter the rule set — and such a rule
31
+ * is forced SOFT unconditionally (see {@link resolveRules}).
32
+ */
33
+ readonly template?: RuleTemplate;
34
+ readonly params?: TemplateParams;
26
35
  }
27
36
 
28
37
  export interface Violation {
@@ -61,6 +70,16 @@ export interface GuardFacts {
61
70
  * importer. `parsed:false` (or the fact absent) ⇒ the rule reports nothing — fail-open by construction,
62
71
  * because a lockfile we could not read is not evidence of a defect.
63
72
  */
73
+ /**
74
+ * for TEMPLATE rules (promoted by `dz guard promote`): the change under evaluation — the file list
75
+ * of the working-tree diff, plus the text of those files when a `format-match` rule needs it.
76
+ * ABSENT ⇒ every template rule reports NOTHING (fail-open on missing evidence, the same contract
77
+ * `lockfile-in-sync` follows).
78
+ */
79
+ readonly change?: {
80
+ readonly files: readonly string[];
81
+ readonly contents?: Readonly<Record<string, string>>;
82
+ };
64
83
  readonly lockfile?: {
65
84
  readonly parsed: boolean;
66
85
  readonly importers?: readonly {
@@ -308,13 +327,63 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
308
327
  */
309
328
  export const SOFT_ONLY_RULES: readonly string[] = ['lockfile-in-sync'];
310
329
 
311
- /** Merge a user config over the defaults: override severity, disable (enabled:false), never add an un-checked rule. */
330
+ /**
331
+ * A well-formed PROMOTED rule: an id the engine does not know, made enforceable by a template +
332
+ * params from the fixed `dz guard promote` vocabulary. Anything half-formed is NOT one, so a
333
+ * hand-edited config cannot smuggle an id past the un-enforceable-rule fail-safe by sprinkling a
334
+ * `template` key on it.
335
+ */
336
+ export function isTemplateRule(r: Partial<GuardRule> | null | undefined): r is GuardRule & { template: RuleTemplate; params: TemplateParams } {
337
+ return !!r && typeof r === 'object' && validTemplateParams(r.template, r.params);
338
+ }
339
+
340
+ /**
341
+ * The template checker: ONE predicate (`templateFires`) shared with the promoter's historical
342
+ * replay, so the rule the promoter promised and the rule the guard enforces can never diverge.
343
+ * Fail-open on missing evidence (no `change` fact ⇒ nothing reported) and on `undecidable`
344
+ * (a `format-match` whose file contents were not gathered is not a clean change, it is no evidence).
345
+ */
346
+ function templateChecker(rule: GuardRule & { template: RuleTemplate; params: TemplateParams }): (f: GuardFacts, sev: GuardSeverity) => Violation[] {
347
+ return (f) => {
348
+ const ch = f.change;
349
+ if (!ch || typeof ch !== 'object' || !Array.isArray(ch.files)) return [];
350
+ const change: ChangeSet = { id: 'working-tree', ts: '', files: ch.files, ...(ch.contents !== undefined ? { contents: ch.contents } : {}) };
351
+ const r = templateFires(rule.template, rule.params, change);
352
+ if (Object.hasOwn(r, 'undecidable') || !(r as { fired?: boolean }).fired) return [];
353
+ // A promoted rule is ALWAYS soft, whatever severity reaches this point (belt to resolveRules' braces).
354
+ return [{ rule: rule.id, severity: 'soft', detail: `${(r as { detail?: string }).detail ?? 'template rule fired'} (promoted rule — advisory)` }];
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Merge a user config over the defaults: override severity, disable (enabled:false), never add an
360
+ * un-checked rule — EXCEPT a well-formed template rule (a `dz guard promote` promotion), which is
361
+ * enforceable by construction and is forced SOFT.
362
+ */
312
363
  export function resolveRules(userRules?: readonly Partial<GuardRule>[]): GuardRule[] {
313
364
  const byId = new Map<string, GuardRule>(DEFAULT_RULES.map((r) => [r.id, r]));
314
365
  for (const u of Array.isArray(userRules) ? userRules : []) {
315
366
  if (!u || typeof u.id !== 'string') continue;
316
367
  const base = byId.get(u.id);
317
- if (!base) continue; // a config rule with no built-in checker is ignored (fail-safe: no un-enforceable rules)
368
+ if (!base) {
369
+ // A PROMOTED rule may introduce a new id — but only fully formed, and only SOFT. A promoted
370
+ // rule is derived by a text heuristic from an agent-written lesson: strictly weaker provenance
371
+ // than `lockfile-in-sync`'s tolerant parser, which is already SOFT-only. "I might be wrong"
372
+ // plus "block the publish" is the wrong pair (ADR-004).
373
+ if (isTemplateRule(u)) {
374
+ const ops = Array.isArray(u.ops) && u.ops.every((o) => ['publish', 'teach', 'consolidate', 'reindex'].includes(o as string)) && u.ops.length > 0 ? (u.ops as readonly GuardOp[]) : (['publish'] as const);
375
+ byId.set(u.id, {
376
+ id: u.id,
377
+ severity: 'soft',
378
+ ops,
379
+ description: typeof u.description === 'string' ? u.description : `promoted rule (${u.template})`,
380
+ ...(typeof u.enabled === 'boolean' ? { enabled: u.enabled } : {}),
381
+ template: u.template,
382
+ params: u.params,
383
+ });
384
+ }
385
+ continue; // a config rule with no built-in checker is ignored (fail-safe: no un-enforceable rules)
386
+ }
318
387
  // A SOFT-ONLY rule keeps its severity even when the config asks for hard (see SOFT_ONLY_RULES).
319
388
  const severity = u.severity === 'hard' || u.severity === 'soft' ? u.severity : undefined;
320
389
  const allowedSeverity = severity !== undefined && !(severity === 'hard' && SOFT_ONLY_RULES.includes(u.id)) ? severity : undefined;
@@ -343,7 +412,11 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
343
412
  const checked: string[] = [];
344
413
  for (const r of active) {
345
414
  checked.push(r.id);
346
- const checker = CHECKERS[r.id];
415
+ // A promoted (template) rule has no built-in checker by design — it is enforceable through the
416
+ // shared `templateFires` predicate instead. Without this branch a promoted rule written into
417
+ // `.dz/guard.json` would be INERT: present in the config, listed as checked, enforcing nothing —
418
+ // the exact false-green shape this feature exists to remove (ADR-004).
419
+ const checker = CHECKERS[r.id] ?? (isTemplateRule(r) ? templateChecker(r) : undefined);
347
420
  if (!checker) {
348
421
  // A rule the caller asked for that has no checker CANNOT silently pass while reporting as checked —
349
422
  // that is the smuggled-rule hole. Fail closed: unenforceable ⇒ a HARD violation.
package/src/index.ts CHANGED
@@ -156,11 +156,13 @@ export {
156
156
  RECALL_USAGE_LOG_MAX_BYTES,
157
157
  RECALL_USAGE_COMPACT_TARGET_BYTES,
158
158
  formatRecallUsageRecord,
159
+ buildRecallUsageRecord,
159
160
  parseRecallUsageLog,
160
161
  aggregateRecallUsage,
161
162
  buildRecallUsageReport,
162
163
  shouldCompactRecallUsageLogSize,
163
164
  compactRecallUsageLog,
165
+ compactRecallUsageLogChecked,
164
166
  } from './recall-usage.js';
165
167
  export type {
166
168
  RecallUsageReadRecord,
@@ -171,7 +173,53 @@ export type {
171
173
  RecallPatternUsageRef,
172
174
  RecallUsagePatternRow,
173
175
  RecallUsageReport,
176
+ RecallUsageRecordInput,
177
+ CompactRecallUsageOptions,
178
+ CompactRecallUsageResult,
179
+ CompactRecallUsageStatus,
174
180
  } from './recall-usage.js';
181
+ export {
182
+ EVENT_CHAIN_SCOPE,
183
+ EVENT_CHAIN_GENESIS_HASH,
184
+ EVENT_CHAIN_TAIL_BYTES,
185
+ EVENT_CHAIN_FIELD_OVERHEAD_BYTES,
186
+ EVENT_CHAIN_LEDGER_KIND,
187
+ EVENT_CHAIN_DEFECT_KINDS,
188
+ fnv1a32,
189
+ chainHashOf,
190
+ chainLinesOf,
191
+ lastChainLine,
192
+ readTailInfo,
193
+ appendChainedLines,
194
+ EMPTY_LOG_TAIL,
195
+ nextChainFields,
196
+ withChainFields,
197
+ chainRecordLines,
198
+ chainRewrite,
199
+ defaultEventWeight,
200
+ eventWeightOfText,
201
+ verifyEventChain,
202
+ verifyEventChainText,
203
+ renderEventChainVerification,
204
+ rewriteSnapshot,
205
+ rewriteSnapshotUnchanged,
206
+ guardedRewrite,
207
+ DEFAULT_REWRITE_ATTEMPTS,
208
+ } from './event-chain.js';
209
+ export type {
210
+ ChainFields,
211
+ LogTail,
212
+ EventChainLedger,
213
+ RewriteSnapshot,
214
+ GuardedRewriteIo,
215
+ GuardedRewriteResult,
216
+ GuardedRewriteStatus,
217
+ RewriteProposal,
218
+ EventChainDefect,
219
+ EventChainDefectKind,
220
+ EventChainVerification,
221
+ VerifyEventChainOptions,
222
+ } from './event-chain.js';
175
223
  export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, orderByDependencies, syncReadmeVersion } from './publish.js';
176
224
  export { fetchAllDownloads } from './downloads.js';
177
225
  export type { PackageDownloads, DownloadsReport } from './downloads.js';
@@ -316,6 +364,10 @@ export * from './routing-outcomes.js';
316
364
  export * from './bto-optimize.js';
317
365
  export * from './discrimination-gate.js';
318
366
  export * from './guard.js';
367
+ // Lesson → guard-rule PROMOTION (feature guard-promotion, scout idea #1) — the cost-of-detection
368
+ // ladder's elevator: moves a lesson from layer 5 (agent memory) to layer 1 (a deterministic rule),
369
+ // but only after TWO consecutive shadow wins replayed over REAL commits. Never synthesises rule code.
370
+ export * from './guard-promotion.js';
319
371
  export * from './delivery-check.js';
320
372
 
321
373
  // Skill-registration gate (feature skills-verify, ADR-001) — static layout scan + the deterministic
package/src/operations.ts CHANGED
@@ -788,6 +788,31 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
788
788
  } catch { /* either side absent — covered by other checks */ }
789
789
  }
790
790
 
791
+ // 8b. EVIDENCE-CHAIN INTEGRITY (feature event-chain, ADR-001). `.dz/recall-usage.jsonl` and
792
+ // `.dz/guard-audit.jsonl` are what `dz compounding` and `dz guard promote` decide on; a rewrite
793
+ // that loses or duplicates a record there is a wrong verdict with no symptom. Deliberately OUTSIDE
794
+ // the agentdb-writer branch — the evidence base exists whether or not that writer is deployed.
795
+ // Silent when a log is absent or has never been chained: an unchained file is legal (FR-5), not a
796
+ // fault, and reporting it would train the reader to ignore this line.
797
+ try {
798
+ const { verifyEventChainText, EVENT_CHAIN_SCOPE } = await import('./event-chain.js');
799
+ for (const rel of ['recall-usage.jsonl', 'guard-audit.jsonl']) {
800
+ const p = join(root, '.dz', rel);
801
+ if (!existsSync(p)) continue;
802
+ const v = verifyEventChainText(readFileSync(p, 'utf-8'));
803
+ if (v.chained === 0 || v.ok) continue;
804
+ checks.push({
805
+ name: `evidence chain (.dz/${rel})`,
806
+ ok: false,
807
+ detail:
808
+ `${v.defects.length} defect(s): ${v.defects.slice(0, 3).map((d) => `${d.kind}@L${d.line}`).join(', ')}` +
809
+ ` — learning verdicts computed from this log are unsafe. Scope: ${EVENT_CHAIN_SCOPE}`,
810
+ });
811
+ }
812
+ } catch {
813
+ /* doctor never throws on a diagnostic */
814
+ }
815
+
791
816
  // 9. Vector-tier mirror divergence (dz-rvf-vector-bridge FR-2/ADR R4). INFORMATIONAL, never an
792
817
  // error exit: lexical is the source of truth and `dz consolidate` backfills the mirror. Per
793
818
  // Constraint 7 (QR-10) the line reports BOTH counts so a diverged mirror is not misdiagnosed
@@ -6,9 +6,22 @@
6
6
  * into aggregate JSONL rows when it crosses a bounded size. It deliberately knows nothing about the
7
7
  * filesystem; callers own reads/writes so the hook and statusline can keep their never-block rules.
8
8
  *
9
+ * Compaction RE-CHAINS everything it writes and records what it measured in its input, so that a
10
+ * rewrite which counts an event twice fails `verifyEventChain` instead of producing a well-formed
11
+ * lie — the 2 → 4 → 6 defect below is the reason (see `event-chain.ts`, ADR-002).
12
+ *
9
13
  * @packageDocumentation
10
14
  */
11
15
 
16
+ import {
17
+ EVENT_CHAIN_FIELD_OVERHEAD_BYTES,
18
+ EVENT_CHAIN_LEDGER_KIND,
19
+ chainRewrite,
20
+ defaultEventWeight,
21
+ verifyEventChainText,
22
+ type EventChainDefect,
23
+ } from './event-chain.js';
24
+
12
25
  export const RECALL_USAGE_LOG_RELATIVE = '.dz/recall-usage.jsonl';
13
26
  export const RECALL_USAGE_LOG_MAX_BYTES = 1_048_576;
14
27
  export const RECALL_USAGE_COMPACT_TARGET_BYTES = Math.floor(RECALL_USAGE_LOG_MAX_BYTES * 0.75);
@@ -104,7 +117,7 @@ interface Acc {
104
117
  totalScore: number;
105
118
  }
106
119
 
107
- export function formatRecallUsageRecord(input: {
120
+ export interface RecallUsageRecordInput {
108
121
  readonly dzId?: unknown;
109
122
  readonly score?: unknown;
110
123
  readonly ts?: unknown;
@@ -112,8 +125,18 @@ export function formatRecallUsageRecord(input: {
112
125
  readonly runId?: unknown;
113
126
  readonly eventId?: unknown;
114
127
  readonly queryTruncated?: unknown;
115
- }): string | undefined {
116
- const rec = normalizeReadRecord(input);
128
+ }
129
+
130
+ /**
131
+ * The normalized RECORD, before serialization — the writer needs the object so it can hang the
132
+ * event-chain fields off it (`seq`/`prevHash`, ADR-001) instead of string-splicing a finished line.
133
+ */
134
+ export function buildRecallUsageRecord(input: RecallUsageRecordInput): RecallUsageReadRecord | undefined {
135
+ return normalizeReadRecord(input);
136
+ }
137
+
138
+ export function formatRecallUsageRecord(input: RecallUsageRecordInput): string | undefined {
139
+ const rec = buildRecallUsageRecord(input);
117
140
  return rec === undefined ? undefined : `${JSON.stringify(rec)}\n`;
118
141
  }
119
142
 
@@ -125,6 +148,10 @@ export function parseRecallUsageLog(text: string): ParsedRecallUsageLog {
125
148
  if (trimmed === '') continue;
126
149
  try {
127
150
  const parsed = JSON.parse(trimmed) as unknown;
151
+ // The compaction ledger (ADR-002) is chain bookkeeping, not a usage record. Counting it as an
152
+ // invalid line would make `invalidLines` — a health number the report prints — lie by one per
153
+ // compaction generation.
154
+ if (isRecord(parsed) && parsed['kind'] === EVENT_CHAIN_LEDGER_KIND) continue;
128
155
  const record = normalizeRecord(parsed);
129
156
  if (record === undefined) {
130
157
  invalidLines += 1;
@@ -213,10 +240,68 @@ export function shouldCompactRecallUsageLogSize(
213
240
  return Number.isFinite(sizeBytes) && sizeBytes > validMax(maxBytes);
214
241
  }
215
242
 
216
- export function compactRecallUsageLog(
243
+ export interface CompactRecallUsageOptions {
244
+ readonly maxBytes?: number;
245
+ readonly targetBytes?: number;
246
+ readonly compactedAt?: string;
247
+ /**
248
+ * Compact even when the input's chain is already defective. OFF by default and never set by any
249
+ * automatic caller — see {@link compactRecallUsageLogChecked} for why.
250
+ */
251
+ readonly force?: boolean;
252
+ }
253
+
254
+ export type CompactRecallUsageStatus = 'compacted' | 'refused-dirty' | 'too-large';
255
+
256
+ export interface CompactRecallUsageResult {
257
+ readonly status: CompactRecallUsageStatus;
258
+ /** Empty unless `status === 'compacted'`. */
259
+ readonly text: string;
260
+ /** The input defects that caused a refusal. */
261
+ readonly defects: readonly EventChainDefect[];
262
+ }
263
+
264
+ /**
265
+ * Compaction with its verdict attached.
266
+ *
267
+ * AM-2 (Codex QE HIGH-2) — A REWRITER MUST NOT LAUNDER. Compaction parses the input, drops what it
268
+ * cannot read and re-chains from genesis, so a file carrying a `BrokenLink` or a `DoubleCounted`
269
+ * came out the other side verifying `ok: true`. The strongest evidence check in the system was
270
+ * being erased by the routine that runs automatically at a size threshold — corruption converted
271
+ * into a clean chain, with no record that it ever existed.
272
+ *
273
+ * So: the input is VERIFIED FIRST, and a defective chained region REFUSES. The pre-chain prefix is
274
+ * legal and never blocks anything (FR-5); only real defects do.
275
+ *
276
+ * ACCEPTED CONSEQUENCE, stated because it is the cost: a log that stays defective stops being
277
+ * compacted and grows past its cap. That is the right way round — the size cap is a convenience,
278
+ * the evidence is the product — and it is not silent: `dz doctor` and `dz compounding` both report
279
+ * the chain defect, and the caller logs the refusal.
280
+ */
281
+ export function compactRecallUsageLogChecked(
217
282
  text: string,
218
- opts: { readonly maxBytes?: number; readonly targetBytes?: number; readonly compactedAt?: string } = {},
219
- ): string {
283
+ opts: CompactRecallUsageOptions = {},
284
+ ): CompactRecallUsageResult {
285
+ if (opts.force !== true) {
286
+ const v = verifyEventChainText(typeof text === 'string' ? text : '');
287
+ if (!v.ok) return { status: 'refused-dirty', text: '', defects: v.defects };
288
+ }
289
+ const out = compactVerifiedRecallUsageLog(text, opts);
290
+ return out === ''
291
+ ? { status: 'too-large', text: '', defects: [] }
292
+ : { status: 'compacted', text: out, defects: [] };
293
+ }
294
+
295
+ /**
296
+ * Back-compatible wrapper: the compacted text, or `''` when the rewrite is REFUSED (a defective
297
+ * input) or cannot fit. Callers that need to tell those apart use
298
+ * {@link compactRecallUsageLogChecked}.
299
+ */
300
+ export function compactRecallUsageLog(text: string, opts: CompactRecallUsageOptions = {}): string {
301
+ return compactRecallUsageLogChecked(text, opts).text;
302
+ }
303
+
304
+ function compactVerifiedRecallUsageLog(text: string, opts: CompactRecallUsageOptions): string {
220
305
  const maxBytes = validMax(opts.maxBytes ?? RECALL_USAGE_LOG_MAX_BYTES);
221
306
  const targetBytes = validTarget(opts.targetBytes ?? Math.floor(maxBytes * 0.75), maxBytes);
222
307
  const compactedAt = validTs(opts.compactedAt) ? opts.compactedAt : new Date(0).toISOString();
@@ -233,23 +318,57 @@ export function compactRecallUsageLog(
233
318
  const toAggregate = parsed.filter((r) => !retained.has(r as RecallUsageReadRecord));
234
319
  const stats = aggregateRecallUsage(toAggregate);
235
320
  // Replay rows are budgeted FIRST: they are irreplaceable (the corpus), aggregates are re-derivable.
236
- const lines = [...replayRows.map((r) => JSON.stringify(r)), ...stats.map((s) => aggregateLine(s, compactedAt))];
237
- let out = joinLines(lines);
238
- if (byteLength(out) <= maxBytes) return out;
239
-
240
- const kept: string[] = [];
241
- let used = 0;
242
- for (const line of lines) {
243
- const cost = byteLength(`${line}\n`);
244
- if (kept.length > 0 && used + cost > targetBytes) continue;
245
- if (cost > maxBytes) continue;
246
- if (used + cost <= maxBytes) {
247
- kept.push(line);
248
- used += cost;
321
+ const candidates: RecallUsageRecord[] = [...replayRows, ...stats.map((s) => aggregateRecord(s, compactedAt))];
322
+
323
+ // The event weight of the INPUT, measured before aggregation. It must come from the input — a
324
+ // total derived from the output could never disagree with it (ADR-002), which is exactly how a
325
+ // double-counting rewrite went unseen until a human re-read the code.
326
+ let sourceEvents = 0;
327
+ for (const rec of parsed) sourceEvents += defaultEventWeight(rec as unknown as Record<string, unknown>);
328
+
329
+ // SELECT, then chain (ADR-002 decision 5): trimming a chain after building it punches holes in it.
330
+ // Each candidate is charged its own bytes plus a fixed allowance for the chain fields it will get.
331
+ let selected = candidates;
332
+ if (byteLength(joinLines(candidates.map(serializeWithChainAllowance))) > maxBytes) {
333
+ const kept: RecallUsageRecord[] = [];
334
+ let used = 0;
335
+ for (const rec of candidates) {
336
+ const cost = byteLength(`${serializeWithChainAllowance(rec)}\n`);
337
+ if (kept.length > 0 && used + cost > targetBytes) continue;
338
+ if (cost > maxBytes) continue;
339
+ if (used + cost <= maxBytes) {
340
+ kept.push(rec);
341
+ used += cost;
342
+ }
249
343
  }
344
+ selected = kept;
345
+ }
346
+
347
+ // Final shrink: drop from the TAIL and re-chain, so the survivors are always a valid chain.
348
+ for (;;) {
349
+ const out = joinLines(chainRewrite(selected, { sourceEvents, droppedEvents: droppedEvents(sourceEvents, selected), compactedAt }));
350
+ if (byteLength(out) <= maxBytes) return out;
351
+ if (selected.length === 0) return '';
352
+ selected = selected.slice(0, -1);
250
353
  }
251
- out = joinLines(kept);
252
- return byteLength(out) <= maxBytes ? out : '';
354
+ }
355
+
356
+ /**
357
+ * What the byte budget discarded — so a deliberate trim is never mistaken for lost records.
358
+ *
359
+ * The `Math.max(0, …)` is LOAD-BEARING, not defensive tidiness: it is what stops a rewrite that
360
+ * emits MORE events than it read from explaining its own inflation away with a negative "dropped"
361
+ * figure. Over-accounting therefore always reaches {@link verifyEventChain} as `DoubleCounted`.
362
+ */
363
+ function droppedEvents(sourceEvents: number, selected: readonly RecallUsageRecord[]): number {
364
+ let accounted = 0;
365
+ for (const rec of selected) accounted += defaultEventWeight(rec as unknown as Record<string, unknown>);
366
+ return Math.max(0, sourceEvents - accounted);
367
+ }
368
+
369
+ /** A record's serialized size plus the allowance for the chain fields it will carry. */
370
+ function serializeWithChainAllowance(rec: RecallUsageRecord): string {
371
+ return JSON.stringify(rec) + ' '.repeat(EVENT_CHAIN_FIELD_OVERHEAD_BYTES);
253
372
  }
254
373
 
255
374
  function normalizeRecord(value: unknown): RecallUsageRecord | undefined {
@@ -356,8 +475,8 @@ function mergeAggregate(byId: Map<string, Acc>, rec: RecallUsageAggregateRecord)
356
475
  }
357
476
  }
358
477
 
359
- function aggregateLine(stat: RecallUsageStat, compactedAt: string): string {
360
- const rec: RecallUsageAggregateRecord = {
478
+ function aggregateRecord(stat: RecallUsageStat, compactedAt: string): RecallUsageAggregateRecord {
479
+ return {
361
480
  kind: 'aggregate',
362
481
  dzId: stat.dzId,
363
482
  reads: stat.reads,
@@ -367,7 +486,6 @@ function aggregateLine(stat: RecallUsageStat, compactedAt: string): string {
367
486
  totalScore: stat.avgScore * stat.reads,
368
487
  compactedAt,
369
488
  };
370
- return JSON.stringify(rec);
371
489
  }
372
490
 
373
491
  function patternRef(p: RecallPatternUsageRef): RecallPatternUsageRef {