fraim 2.0.340 → 2.0.342

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.
@@ -230,6 +230,13 @@ function normalizeModelName(model) {
230
230
  function emptyStore() {
231
231
  return { version: STORE_VERSION, updatedAt: new Date(0).toISOString(), entries: {}, foldedThroughBytes: {}, offersEraStart: null };
232
232
  }
233
+ function withEvidence(store, evidence) {
234
+ Object.defineProperty(store, 'evidence', { value: evidence, enumerable: false, configurable: true });
235
+ return store;
236
+ }
237
+ function unavailableStore(observation) {
238
+ return withEvidence(emptyStore(), { status: 'unavailable', excludedKeys: [], observations: [observation] });
239
+ }
233
240
  /**
234
241
  * Is this parsed value a record every consumer can read without crashing?
235
242
  *
@@ -254,14 +261,21 @@ function isUsableRecord(value) {
254
261
  const fired = record.fired;
255
262
  if (!offered || typeof offered !== 'object' || !fired || typeof fired !== 'object')
256
263
  return false;
257
- return Array.isArray(offered.log)
258
- && typeof offered.total === 'number'
259
- && typeof offered.aboveThreshold === 'number'
260
- && !!offered.byModel && typeof offered.byModel === 'object'
261
- && Array.isArray(fired.log)
262
- && typeof fired.total === 'number'
263
- && !!fired.byOutcome && typeof fired.byOutcome === 'object'
264
- && Array.isArray(record.titles);
264
+ const count = (v) => Number.isSafeInteger(v) && v >= 0;
265
+ const counts = (v) => !!v && typeof v === 'object' && !Array.isArray(v)
266
+ && Object.values(v).every(count);
267
+ const nullableString = (v) => v === null || typeof v === 'string';
268
+ return typeof record.key === 'string' && (record.family === 'learning' || record.family === 'rule')
269
+ && Array.isArray(offered.log) && offered.log.every(r => r && isValidDate(r.date)
270
+ && typeof r.job === 'string' && nullableString(r.model))
271
+ && count(offered.total) && count(offered.aboveThreshold)
272
+ && counts(offered.byModel)
273
+ && Array.isArray(fired.log) && fired.log.every(r => r && isValidDate(r.date)
274
+ && typeof r.job === 'string' && nullableString(r.model) && exports.FIRING_OUTCOMES.includes(r.outcome))
275
+ && count(fired.total) && counts(fired.byOutcome) && counts(fired.byModel)
276
+ && exports.FIRING_OUTCOMES.every(outcome => count(fired.byOutcome[outcome]))
277
+ && Array.isArray(fired.seenSources) && fired.seenSources.every(s => typeof s === 'string')
278
+ && Array.isArray(record.titles) && record.titles.every(t => typeof t === 'string');
265
279
  }
266
280
  /**
267
281
  * The earliest offer date a pre-#1752 snapshot can evidence.
@@ -301,15 +315,23 @@ function readCheckpoints(value) {
301
315
  }
302
316
  /** Read the normal snapshot, recovering a missing or torn copy from its checkpoint. */
303
317
  function readUsageSnapshot(storePath = resolveUsageStorePath()) {
304
- for (const candidate of [storePath, resolveUsageCheckpointPath(storePath)]) {
305
- const snapshot = parseUsageSnapshot(candidate);
306
- if (snapshot)
307
- return snapshot;
308
- }
318
+ const primary = parseUsageSnapshot(storePath);
319
+ if (primary && primary.evidence?.status !== 'unavailable')
320
+ return primary;
321
+ // An unknown schema is not evidence that an older checkpoint can replace it.
322
+ if (primary?.evidence?.observations.includes('unsupported-schema'))
323
+ return primary;
324
+ const backup = parseUsageSnapshot(resolveUsageCheckpointPath(storePath));
325
+ if (backup && !backup.evidence)
326
+ return withEvidence(backup, {
327
+ status: 'reliable', excludedKeys: [],
328
+ observations: [(0, fs_1.existsSync)(storePath) ? 'primary-unreadable' : 'primary-missing'],
329
+ recovery: 'checkpoint', persisted: false,
330
+ });
309
331
  if ((0, fs_1.existsSync)(resolveUsageCheckpointPath(storePath))) {
310
332
  throw new Error('Usage snapshot and checkpoint backup are unreadable. Restore a matching backup; do not delete the usage directory.');
311
333
  }
312
- return emptyStore();
334
+ return primary ?? emptyStore();
313
335
  }
314
336
  function resolveUsageCheckpointPath(storePath) {
315
337
  return storePath + '.checkpoint';
@@ -324,19 +346,29 @@ function parseUsageSnapshot(storePath) {
324
346
  // `entry.offered` throws on it.
325
347
  if (!parsed || typeof parsed !== 'object' || !parsed.entries
326
348
  || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
327
- return null;
349
+ return unavailableStore('malformed-snapshot');
350
+ }
351
+ if (parsed.version !== undefined && parsed.version !== STORE_VERSION)
352
+ return unavailableStore('unsupported-schema');
353
+ if (parsed.foldedThroughBytes !== undefined && (!parsed.foldedThroughBytes
354
+ || typeof parsed.foldedThroughBytes !== 'object' || Array.isArray(parsed.foldedThroughBytes)
355
+ || Object.values(parsed.foldedThroughBytes).some(v => !Number.isSafeInteger(v) || v < 0))) {
356
+ return unavailableStore('malformed-checkpoints');
328
357
  }
329
358
  // Drop any value that is not a usable record rather than handing a caller
330
359
  // something it will crash on. A partially corrupt store degrades to the records
331
360
  // that are still readable before consolidation. Consolidated snapshots must be complete.
332
361
  const entries = {};
362
+ const excludedKeys = [];
333
363
  for (const [key, value] of Object.entries(parsed.entries)) {
334
- if (isUsableRecord(value))
364
+ if (isUsableRecord(value) && value.key === key)
335
365
  entries[key] = value;
336
366
  else if (parsed.consolidated)
337
- return null;
367
+ return unavailableStore('malformed-consolidated-snapshot');
368
+ else
369
+ excludedKeys.push(key);
338
370
  }
339
- return {
371
+ const store = {
340
372
  consolidated: parsed.consolidated === true,
341
373
  version: parsed.version ?? STORE_VERSION,
342
374
  updatedAt: parsed.updatedAt ?? emptyStore().updatedAt,
@@ -346,9 +378,12 @@ function parseUsageSnapshot(storePath) {
346
378
  ? parsed.offersEraStart
347
379
  : deriveOffersEraStart(entries),
348
380
  };
381
+ return excludedKeys.length ? withEvidence(store, {
382
+ status: 'partial', excludedKeys, observations: ['malformed-records'],
383
+ }) : store;
349
384
  }
350
385
  catch {
351
- return null;
386
+ return unavailableStore('unreadable-snapshot');
352
387
  }
353
388
  }
354
389
  /**
@@ -357,9 +392,12 @@ function parseUsageSnapshot(storePath) {
357
392
  * journals, so a torn ordinary refresh can recover without replaying old history.
358
393
  */
359
394
  function writeUsageSnapshot(store, storePath = resolveUsageStorePath()) {
395
+ if (store.evidence && store.evidence.status !== 'reliable')
396
+ return false;
360
397
  try {
361
398
  (0, fs_1.mkdirSync)((0, path_1.dirname)(storePath), { recursive: true });
362
- (0, fs_1.writeFileSync)(storePath, `${JSON.stringify(store, null, 2)}\n`, { encoding: 'utf8', flush: true });
399
+ const { evidence: _evidence, ...snapshot } = store;
400
+ (0, fs_1.writeFileSync)(storePath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', flush: true });
363
401
  return true;
364
402
  }
365
403
  catch {
@@ -385,7 +423,10 @@ function foldUsageEvents(options) {
385
423
  throw new Error('Usage journal no longer matches its consolidated snapshot. Restore matching checkpoint files before continuing.');
386
424
  if (tail.restarted) {
387
425
  const cold = (0, learning_usage_events_1.readEventsSince)(options.eventDir, {});
388
- return applyTail(emptyStore(), cold, limits, now, true);
426
+ return applyTail(withEvidence(emptyStore(), {
427
+ status: 'unavailable', excludedKeys: base.evidence?.excludedKeys ?? [],
428
+ observations: [...(base.evidence?.observations ?? []), 'journal-restarted'],
429
+ }), cold, limits, now, true);
389
430
  }
390
431
  return applyTail(base, tail, limits, now, false);
391
432
  }
@@ -398,12 +439,19 @@ function applyTail(base, tail, limits, now, restarted) {
398
439
  foldedThroughBytes: { ...(base.foldedThroughBytes ?? {}) },
399
440
  offersEraStart: base.offersEraStart ?? null,
400
441
  };
442
+ if (base.evidence)
443
+ withEvidence(store, base.evidence);
401
444
  let tailBytes = 0;
445
+ let unsupportedEvents = false;
402
446
  const touched = new Set();
403
447
  for (const read of tail.reads) {
404
448
  const from = restarted ? 0 : (store.foldedThroughBytes[read.machineId] ?? 0);
405
449
  tailBytes += Math.max(0, read.through - from);
406
450
  for (const event of read.records) {
451
+ if (!isSupportedUsageEvent(event)) {
452
+ unsupportedEvents = true;
453
+ continue;
454
+ }
407
455
  const key = applyEvent(store, event, limits, now);
408
456
  if (key)
409
457
  touched.add(key);
@@ -418,17 +466,63 @@ function applyTail(base, tail, limits, now, restarted) {
418
466
  // E: the invariant is evaluated on the read path, where it is always current
419
467
  // and costs the write path nothing.
420
468
  flagIntegrity(store);
469
+ const incompleteJournal = tail.skippedLines > 0
470
+ || tail.reads.some(read => read.through < read.baseOffset + read.size - read.headerBytes);
471
+ if (incompleteJournal || unsupportedEvents) {
472
+ withEvidence(store, {
473
+ status: 'unavailable', excludedKeys: store.evidence?.excludedKeys ?? [],
474
+ observations: [...(store.evidence?.observations ?? []),
475
+ ...(incompleteJournal ? ['incomplete-journal'] : []),
476
+ ...(unsupportedEvents ? ['unsupported-journal-events'] : [])],
477
+ });
478
+ }
421
479
  return { store, skippedLines: tail.skippedLines, restarted, tailBytes, logs: tail.reads };
422
480
  }
423
481
  /**
424
482
  * The checkpointed read every consumer uses. Snapshot plus the bytes appended
425
483
  * past it, republished once the tail is worth re-checkpointing.
426
484
  */
485
+ /** Metadata detects observed writes/sync during a read; it is not a writer lock. */
486
+ function usageReadState(storePath, eventDir) {
487
+ const dir = (0, path_1.dirname)(storePath);
488
+ const copies = (0, fs_1.existsSync)(dir) ? (0, fs_1.readdirSync)(dir)
489
+ .filter(name => /^learning-usage(-.+)?\.json$/.test(name) && name !== STORE_FILE_NAME).sort() : [];
490
+ const paths = [storePath, resolveUsageCheckpointPath(storePath),
491
+ ...(0, learning_usage_events_1.listEventLogs)(eventDir).map(log => log.path), ...copies.map(name => (0, path_1.join)(dir, name))];
492
+ const fingerprint = JSON.stringify(paths.map(file => {
493
+ try {
494
+ const stat = (0, fs_1.statSync)(file);
495
+ return [file, stat.size, stat.mtimeMs, stat.ctimeMs, stat.ino];
496
+ }
497
+ catch {
498
+ return [file, 'missing'];
499
+ }
500
+ }));
501
+ return { fingerprint, copies };
502
+ }
427
503
  function readUsageStore(storePath = resolveUsageStorePath(), options = {}) {
428
- const snapshot = readUsageSnapshot(storePath);
429
504
  const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
430
- if ((0, learning_usage_events_1.listEventLogs)(eventDir).length === 0)
431
- return snapshot;
505
+ const before = usageReadState(storePath, eventDir);
506
+ const snapshot = readUsageSnapshot(storePath);
507
+ const logs = (0, learning_usage_events_1.listEventLogs)(eventDir);
508
+ const finish = (store) => {
509
+ const after = usageReadState(storePath, eventDir);
510
+ const observations = [...(store.evidence?.observations ?? [])];
511
+ if (before.fingerprint !== after.fingerprint)
512
+ observations.push('inputs-changed-during-read');
513
+ if (after.copies.length)
514
+ observations.push('unmerged-store-copies');
515
+ if (store.consolidated && Object.keys(store.foldedThroughBytes ?? {}).some(machineId => !logs.some(log => log.machineId === machineId)))
516
+ observations.push('consolidated-journal-missing');
517
+ if (observations.length > (store.evidence?.observations.length ?? 0))
518
+ withEvidence(store, {
519
+ status: 'unavailable', excludedKeys: store.evidence?.excludedKeys ?? [], observations,
520
+ });
521
+ flagIntegrity(store);
522
+ return store;
523
+ };
524
+ if (logs.length === 0 || snapshot.evidence?.observations.includes('unsupported-schema'))
525
+ return finish(snapshot);
432
526
  const fold = (base) => foldUsageEvents({
433
527
  eventDir,
434
528
  limits: options.limits,
@@ -442,14 +536,20 @@ function readUsageStore(storePath = resolveUsageStorePath(), options = {}) {
442
536
  }
443
537
  catch (error) {
444
538
  const backup = parseUsageSnapshot(resolveUsageCheckpointPath(storePath));
445
- if (!backup)
539
+ if (!backup || backup.evidence)
446
540
  throw error;
447
- folded = fold(backup);
541
+ folded = fold(withEvidence(backup, {
542
+ status: 'reliable', excludedKeys: [], observations: ['primary-journal-mismatch'],
543
+ recovery: 'checkpoint', persisted: false,
544
+ }));
448
545
  }
546
+ finish(folded.store);
449
547
  const mode = options.refreshSnapshot ?? 'auto';
450
548
  const shouldRefresh = mode === 'always'
451
549
  || (mode === 'auto' && (folded.tailBytes >= SNAPSHOT_REFRESH_BYTES || !(0, fs_1.existsSync)(storePath) || folded.restarted));
452
- if (shouldRefresh) {
550
+ // Recovery is usable in memory. Persisting a primary replacement requires the
551
+ // explicit maintenance interval; an ordinary reader never establishes it.
552
+ if (shouldRefresh && !folded.store.evidence) {
453
553
  folded.store.updatedAt = (options.now ?? new Date()).toISOString();
454
554
  writeUsageSnapshot(folded.store, storePath);
455
555
  }
@@ -636,6 +736,20 @@ function applyEvent(store, event, limits, now) {
636
736
  void now;
637
737
  return null;
638
738
  }
739
+ function isSupportedUsageEvent(event) {
740
+ if (typeof event.key !== 'string' || !event.key || typeof event.date !== 'string' || !isValidDate(event.date))
741
+ return false;
742
+ if (event.family !== 'learning' && event.family !== 'rule')
743
+ return false;
744
+ if (typeof event.job !== 'string' || (event.model !== null && typeof event.model !== 'string'))
745
+ return false;
746
+ if (event.kind === 'offer')
747
+ return typeof event.aboveThreshold === 'boolean';
748
+ if (event.kind === 'firing')
749
+ return exports.FIRING_OUTCOMES.includes(event.outcome)
750
+ && (event.source === undefined || event.source === 'attested' || event.source === 'backfill-legacy');
751
+ return false;
752
+ }
639
753
  function applyOfferEvent(store, event, common) {
640
754
  const record = ensureRecord(store, common.key, common.family, common.fileType, common.level);
641
755
  rememberTitle(record, typeof event.title === 'string' ? event.title : undefined);
@@ -883,19 +997,17 @@ function noteTitleChange(key, newTitle, options) {
883
997
  store.updatedAt = (options.now ?? new Date()).toISOString();
884
998
  writeUsageSnapshot(store, storePath);
885
999
  }
886
- /**
887
- * Apply retention and log bounds across the whole store, and compact the event
888
- * log behind it.
889
- *
890
- * Compaction is a requirement rather than an optimization: S4 measured a cold
891
- * full fold at 84 ms on today's volume but 794 ms at ten times it, and the
892
- * checkpoint only makes a cold fold rare, not bounded.
893
- */
894
1000
  function pruneUsageStore(limits, options = {}) {
895
1001
  const storePath = options.storePath ?? resolveUsageStorePath();
896
1002
  const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
897
1003
  const now = options.now ?? new Date();
898
1004
  const store = readUsageStore(storePath, { limits, now, eventDir });
1005
+ if (store.evidence && store.evidence.status !== 'reliable')
1006
+ return {
1007
+ maintenanceStatus: 'deferred', evidence: store.evidence,
1008
+ prunedOfferRecords: 0, prunedFiringRecords: 0, entries: Object.keys(store.entries).length,
1009
+ compaction: compactUsageEvents({ eventDir, snapshotPath: storePath, limits, now, store }),
1010
+ };
899
1011
  let prunedOfferRecords = 0;
900
1012
  let prunedFiringRecords = 0;
901
1013
  for (const record of Object.values(store.entries)) {
@@ -906,7 +1018,10 @@ function pruneUsageStore(limits, options = {}) {
906
1018
  prunedFiringRecords += firingsBefore - record.fired.log.length;
907
1019
  }
908
1020
  const compaction = compactUsageEvents({ eventDir, snapshotPath: storePath, limits, now, store });
909
- return { prunedOfferRecords, prunedFiringRecords, entries: Object.keys(store.entries).length, compaction };
1021
+ return {
1022
+ maintenanceStatus: compaction.maintenanceStatus, evidence: compaction.evidence,
1023
+ prunedOfferRecords, prunedFiringRecords, entries: Object.keys(store.entries).length, compaction,
1024
+ };
910
1025
  }
911
1026
  /** Save totals and source bookmarks in both copies, then clear processed journals.
912
1027
  * Requires an interval without concurrent writers or sync activity.
@@ -915,6 +1030,14 @@ function compactUsageEvents(options) {
915
1030
  const now = options.now ?? new Date();
916
1031
  const store = options.store
917
1032
  ?? readUsageStore(options.snapshotPath, { limits: options.limits, now, eventDir: options.eventDir });
1033
+ // Do not turn a partial read into a durable replacement or advance bookmarks
1034
+ // over evidence this reader could not understand. Independent review can run.
1035
+ if (store.evidence && store.evidence.status !== 'reliable')
1036
+ return {
1037
+ maintenanceStatus: 'deferred', evidence: store.evidence,
1038
+ scanned: 0, kept: 0, dropped: 0, skippedLines: 0, logs: [],
1039
+ checkpoints: { ...(store.foldedThroughBytes ?? {}) },
1040
+ };
918
1041
  for (const log of (0, learning_usage_events_1.listEventLogs)(options.eventDir)) {
919
1042
  if ((store.foldedThroughBytes?.[log.machineId] ?? 0) < log.baseOffset) {
920
1043
  throw new Error('Cannot consolidate from a snapshot older than the journal checkpoint.');
@@ -931,7 +1054,8 @@ function compactUsageEvents(options) {
931
1054
  const temp = checkpoint + '.tmp';
932
1055
  try {
933
1056
  (0, fs_1.mkdirSync)((0, path_1.dirname)(checkpoint), { recursive: true });
934
- (0, fs_1.writeFileSync)(temp, JSON.stringify(store, null, 2) + '\n', { encoding: 'utf8', flush: true });
1057
+ const { evidence: _evidence, ...snapshot } = store;
1058
+ (0, fs_1.writeFileSync)(temp, JSON.stringify(snapshot, null, 2) + '\n', { encoding: 'utf8', flush: true });
935
1059
  (0, fs_1.renameSync)(temp, checkpoint);
936
1060
  }
937
1061
  catch {
@@ -944,7 +1068,7 @@ function compactUsageEvents(options) {
944
1068
  if (!writeUsageSnapshot(store, options.snapshotPath)) {
945
1069
  throw new Error('Cannot consolidate usage events: aggregate snapshot could not be saved.');
946
1070
  }
947
- return (0, learning_usage_events_1.compactEventLogs)(options.eventDir, (event) => {
1071
+ const compaction = (0, learning_usage_events_1.compactEventLogs)(options.eventDir, (event) => {
948
1072
  if (event.kind !== 'offer' && event.kind !== 'firing')
949
1073
  return true;
950
1074
  const date = typeof event.date === 'string' ? event.date : '';
@@ -952,4 +1076,5 @@ function compactUsageEvents(options) {
952
1076
  return true;
953
1077
  return event.kind === 'firing' && !exports.FIRING_OUTCOMES.includes(event.outcome);
954
1078
  }, store.foldedThroughBytes ?? {});
1079
+ return { ...compaction, maintenanceStatus: 'completed', evidence: store.evidence };
955
1080
  }
@@ -32,6 +32,7 @@ const path_1 = require("path");
32
32
  const learning_usage_store_1 = require("./learning-usage-store");
33
33
  const learning_usage_events_1 = require("./learning-usage-events");
34
34
  const learning_usage_projection_1 = require("./learning-usage-projection");
35
+ const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
35
36
  const CANONICAL_FILE_NAME = 'learning-usage.json';
36
37
  /** `learning-usage.json` plus anything a sync client forked off it. */
37
38
  const STORE_COPY_PATTERN = /^learning-usage(-.+)?\.json$/;
@@ -164,7 +165,7 @@ function scanRecordsForIntegrity(records, offersEraStart) {
164
165
  // unrecorded. An entry whose outcomes are all `ignored` has an empty bucket
165
166
  // set correctly, and that is information rather than a gap.
166
167
  const clockAdvancing = record.fired.byOutcome.prevented + record.fired.byOutcome.applied;
167
- if (clockAdvancing > 0 && Object.keys(record.fired.byModel).length === 0) {
168
+ if ((0, learning_usage_eligibility_1.modelAttributionIncomplete)(record)) {
168
169
  unknownModelAttribution.push({ key: record.key, title, fired: clockAdvancing });
169
170
  }
170
171
  }
@@ -183,6 +184,7 @@ function buildVerifyReport(options) {
183
184
  const checkpoints = snapshot.foldedThroughBytes ?? {};
184
185
  const totalLogBytes = logs.reduce((sum, log) => sum + log.size, 0);
185
186
  return {
187
+ evidence: store.evidence,
186
188
  snapshotPath: options.snapshotPath,
187
189
  eventDir,
188
190
  currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel),