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.
@@ -520,33 +520,60 @@ class LocalRegistryResolver {
520
520
  // publish or sync and resolves at all under `single-machine`).
521
521
  const managerLocalContent = this.readManagerLocalOverlayFile(path);
522
522
  if (managerLocalContent !== null) {
523
+ // Issue #1867: apply inheritance so an extends-based override merges with its parent.
524
+ let resolvedManagerLocal;
525
+ try {
526
+ resolvedManagerLocal = await this.resolveInheritance(managerLocalContent, path);
527
+ }
528
+ catch {
529
+ resolvedManagerLocal = { content: managerLocalContent, imports: [] };
530
+ }
523
531
  return {
524
- content: managerLocalContent,
532
+ content: resolvedManagerLocal.content,
525
533
  source: 'local',
526
534
  personalized: true,
527
- inherited: false,
535
+ inherited: resolvedManagerLocal.imports.length > 0,
536
+ imports: resolvedManagerLocal.imports.length > 0 ? resolvedManagerLocal.imports : undefined,
528
537
  scope: 'manager'
529
538
  };
530
539
  }
531
540
  // Issue #869: manager overlay (precedence 3 — the synced cross-machine copy)
532
541
  const managerOverlayContent = this.readManagerOverlayFile(path);
533
542
  if (managerOverlayContent !== null) {
543
+ // Issue #1867: apply inheritance so an extends-based override merges with its parent.
544
+ let resolvedManagerOverlay;
545
+ try {
546
+ resolvedManagerOverlay = await this.resolveInheritance(managerOverlayContent, path);
547
+ }
548
+ catch {
549
+ resolvedManagerOverlay = { content: managerOverlayContent, imports: [] };
550
+ }
534
551
  return {
535
- content: managerOverlayContent,
552
+ content: resolvedManagerOverlay.content,
536
553
  source: 'local',
537
554
  personalized: true,
538
- inherited: false,
555
+ inherited: resolvedManagerOverlay.imports.length > 0,
556
+ imports: resolvedManagerOverlay.imports.length > 0 ? resolvedManagerOverlay.imports : undefined,
539
557
  scope: 'manager'
540
558
  };
541
559
  }
542
560
  // Issue #869 Phase 2: org overlay (precedence 3 — after manager, before synced baseline)
543
561
  const orgOverlayContent = this.readOrgOverlayFile(path);
544
562
  if (orgOverlayContent !== null) {
563
+ // Issue #1867: apply inheritance so an extends-based override merges with its parent.
564
+ let resolvedOrgOverlay;
565
+ try {
566
+ resolvedOrgOverlay = await this.resolveInheritance(orgOverlayContent, path);
567
+ }
568
+ catch {
569
+ resolvedOrgOverlay = { content: orgOverlayContent, imports: [] };
570
+ }
545
571
  return {
546
- content: orgOverlayContent,
572
+ content: resolvedOrgOverlay.content,
547
573
  source: 'local',
548
574
  personalized: true,
549
- inherited: false,
575
+ inherited: resolvedOrgOverlay.imports.length > 0,
576
+ imports: resolvedOrgOverlay.imports.length > 0 ? resolvedOrgOverlay.imports : undefined,
550
577
  scope: 'org'
551
578
  };
552
579
  }
@@ -24,6 +24,7 @@ exports.readOrgBrand = readOrgBrand;
24
24
  exports.resolveOrgBrandWriteDir = resolveOrgBrandWriteDir;
25
25
  exports.countPreservedLearnings = countPreservedLearnings;
26
26
  exports.collectBrainLearningDots = collectBrainLearningDots;
27
+ exports.readRejectedProposals = readRejectedProposals;
27
28
  exports.readPreservedLearnings = readPreservedLearnings;
28
29
  exports.normalizeLearningDomain = normalizeLearningDomain;
29
30
  exports.applyLearningEntryChange = applyLearningEntryChange;
@@ -579,6 +580,34 @@ function pendingL0SortKey(fileName) {
579
580
  const timestamp = fileName.match(/\d{4}-\d{2}-\d{2}(?:T\d{2}-\d{2}-\d{2})?/);
580
581
  return timestamp?.[0] || fileName;
581
582
  }
583
+ /**
584
+ * Issue #1857 R11: how many proposals are waiting for the manager's review.
585
+ *
586
+ * This counts directory entries and nothing else. It does not open a proposal, and
587
+ * deliberately has no access to one: R10 is an invariant about influence rather
588
+ * than storage, so the employee learns that a backlog exists and which job clears
589
+ * it, never what any pending claim says.
590
+ *
591
+ * The same shape as the pending-L0 line it sits beside, for the same reason: a
592
+ * count plus the job that clears it is actionable; a list of contents is not.
593
+ */
594
+ function countPendingProposals(roots, resolvedUserId) {
595
+ let total = 0;
596
+ for (const base of [roots.repoLearningsBase, roots.globalPersonalBase]) {
597
+ if (!base)
598
+ continue;
599
+ const queueDir = (0, path_1.join)(base, 'review-queue', resolvedUserId);
600
+ if (!(0, fs_1.existsSync)(queueDir))
601
+ continue;
602
+ try {
603
+ total += (0, fs_1.readdirSync)(queueDir).filter((name) => name.endsWith('.json')).length;
604
+ }
605
+ catch {
606
+ // An unreadable queue is not worth failing a job's startup over.
607
+ }
608
+ }
609
+ return total;
610
+ }
582
611
  function collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots) {
583
612
  const sources = [];
584
613
  const rawPath = (0, path_1.join)(roots.repoLearningsBase, 'raw');
@@ -823,7 +852,11 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
823
852
  const l0RetroCount = pendingL0Sources.filter(source => source.kind === 'retrospective').length;
824
853
  const hasL2 = l2Files.length > 0;
825
854
  const hasL1 = l1Files.length > 0;
826
- const hasContent = hasL2 || hasL1 || l0CoachingCount > 0 || l0RetroCount > 0;
855
+ // Issue #1857 R11. Counted before the gate on purpose: a manager with a full
856
+ // review queue and an empty corpus is exactly the case where the backlog most
857
+ // needs saying, and computing it after the early return would silence it there.
858
+ const pendingProposals = countPendingProposals(roots, resolvedUserId);
859
+ const hasContent = hasL2 || hasL1 || l0CoachingCount > 0 || l0RetroCount > 0 || pendingProposals > 0;
827
860
  if (!hasContent)
828
861
  return '';
829
862
  let section = forJob
@@ -862,6 +895,13 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
862
895
  }
863
896
  section += '\n';
864
897
  }
898
+ // Issue #1857 R11. The count, never the content: an unapproved proposal does
899
+ // not influence this job, and the employee is told only that the manager has a
900
+ // backlog and which job clears it.
901
+ if (pendingProposals > 0) {
902
+ const plural = pendingProposals !== 1;
903
+ section += `${pendingProposals} proposal${plural ? 's are' : ' is'} waiting for your manager's review; run \`sleep-on-learnings\` to review ${plural ? 'them' : 'it'}.\n\n`;
904
+ }
865
905
  const coachPresent = resolved.coachPresent;
866
906
  if (forJob) {
867
907
  if (hasL2 || hasL1) {
@@ -1825,6 +1865,93 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1825
1865
  flush();
1826
1866
  return out;
1827
1867
  }
1868
+ /**
1869
+ * Read the rejection history a sleep run writes on completion.
1870
+ *
1871
+ * The file is a sequence of `<!-- sleep-rejection:run:action:revision -->` blocks,
1872
+ * each holding a heading, the manager's reason, an optional revisit condition and
1873
+ * the rejected proposal as JSON. `run-application.ts` writes it; this reads it
1874
+ * back without changing a byte, because the file is also a synthesis input and a
1875
+ * reader that rewrote it would corrupt the next run's history.
1876
+ *
1877
+ * A malformed block is skipped rather than failing the whole read: the manager
1878
+ * losing the view of every rejection because one block is damaged is worse than
1879
+ * the view missing that one.
1880
+ */
1881
+ function readRejectedProposals(workspaceRoot, userId) {
1882
+ const roots = getLearningRoots(workspaceRoot);
1883
+ const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
1884
+ if (!resolvedUserId)
1885
+ return [];
1886
+ const out = [];
1887
+ const seen = new Set();
1888
+ const headingRe = /^## (Rejected|Superseded): (.+)$/m;
1889
+ const reasonRe = /^Reason: (.+)$/m;
1890
+ const revisitRe = /^Revisit when: (.+)$/m;
1891
+ const jsonRe = /```json\r?\n([\s\S]*?)\r?\n```/;
1892
+ for (const base of [roots.repoLearningsBase, roots.globalPersonalBase, roots.managerCacheBase]) {
1893
+ if (!base)
1894
+ continue;
1895
+ const file = (0, path_1.join)(base, `${resolvedUserId}-rejected-proposals.md`);
1896
+ if (!(0, fs_1.existsSync)(file))
1897
+ continue;
1898
+ let body;
1899
+ try {
1900
+ body = (0, fs_1.readFileSync)(file, 'utf8');
1901
+ }
1902
+ catch {
1903
+ continue;
1904
+ }
1905
+ const displayPath = file.split('\\').join('/');
1906
+ for (const raw of body.split('<!-- sleep-rejection:').slice(1)) {
1907
+ const end = raw.indexOf('<!-- /sleep-rejection -->');
1908
+ const chunk = end >= 0 ? raw.slice(0, end) : raw;
1909
+ const marker = chunk.slice(0, Math.max(0, chunk.indexOf('-->')));
1910
+ const parts = marker.trim().split(':');
1911
+ const heading = headingRe.exec(chunk);
1912
+ const reason = reasonRe.exec(chunk);
1913
+ const revisit = revisitRe.exec(chunk);
1914
+ const json = jsonRe.exec(chunk);
1915
+ if (!heading || !reason)
1916
+ continue;
1917
+ let proposal = {};
1918
+ if (json) {
1919
+ try {
1920
+ proposal = JSON.parse(json[1]);
1921
+ }
1922
+ catch {
1923
+ // Keep the human-readable half; the JSON only enriches it.
1924
+ }
1925
+ }
1926
+ const decision = (proposal.decision || {});
1927
+ const content = (proposal.content || {});
1928
+ const trace = (content.trace || {});
1929
+ const claimKey = typeof proposal.claimKey === 'string' && proposal.claimKey
1930
+ ? proposal.claimKey
1931
+ : parts[1] || heading[2];
1932
+ // The same claim can appear in more than one configured base. The first
1933
+ // base that carries it wins, matching how the tier merge resolves.
1934
+ const key = `${claimKey}:${String(decision.at || '')}`;
1935
+ if (seen.has(key))
1936
+ continue;
1937
+ seen.add(key);
1938
+ out.push({
1939
+ claimKey,
1940
+ title: heading[2].trim(),
1941
+ reason: reason[1].trim(),
1942
+ ...(revisit ? { revisitCondition: revisit[1].trim() } : {}),
1943
+ // The heading and the stored decision agree; the heading is what a
1944
+ // manager reading the file sees, so it is the one that governs here.
1945
+ kind: heading[1] === 'Superseded' || decision.kind === 'superseded' ? 'superseded' : 'refused',
1946
+ rejectedAt: typeof decision.at === 'string' ? decision.at : '',
1947
+ ...(typeof trace.recurrences === 'number' ? { recurrencesAtRejection: trace.recurrences } : {}),
1948
+ displayPath,
1949
+ });
1950
+ }
1951
+ }
1952
+ // Newest first: the manager is most often checking what they just said no to.
1953
+ return out.sort((a, b) => String(b.rejectedAt).localeCompare(String(a.rejectedAt)));
1954
+ }
1828
1955
  function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine') {
1829
1956
  const roots = getLearningRoots(workspaceRoot);
1830
1957
  const threshold = getScoreThreshold(workspaceRoot);
@@ -27,6 +27,7 @@ const fs_1 = require("fs");
27
27
  const path_1 = require("path");
28
28
  const learning_usage_store_1 = require("./learning-usage-store");
29
29
  const learning_usage_projection_1 = require("./learning-usage-projection");
30
+ const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
30
31
  function daysBetween(from, to) {
31
32
  const a = new Date(from).getTime();
32
33
  const b = new Date(to).getTime();
@@ -39,6 +40,7 @@ function firedCount(record) {
39
40
  }
40
41
  function classifyRecurrence(store, options) {
41
42
  const record = store.entries[options.key];
43
+ const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
42
44
  const base = {
43
45
  key: options.key,
44
46
  title: record?.titles[record.titles.length - 1] ?? null,
@@ -51,6 +53,14 @@ function classifyRecurrence(store, options) {
51
53
  firedTotal: record ? firedCount(record) : 0,
52
54
  },
53
55
  };
56
+ if (record && excluded.has(record.key)) {
57
+ return {
58
+ ...base, classification: 'undetermined',
59
+ diagnosis: 'The available usage evidence cannot establish this recurrence.',
60
+ recommendation: 'Review independent source evidence without attributing this recurrence to usage.',
61
+ recurrenceBump: false, targetOfFix: 'none',
62
+ };
63
+ }
54
64
  if (!record) {
55
65
  return {
56
66
  ...base,
@@ -94,7 +104,7 @@ function classifyRecurrence(store, options) {
94
104
  // per entry per session and a plain newest-N bound evicted the job-labelled
95
105
  // ones within about a day. Asking about a job the store has never retained an
96
106
  // offer for is a gap in the record, not a delivery failure.
97
- const jobCoverage = Object.values(store.entries).some((entry) => entry.offered.log.some((offer) => offer.job === options.job && daysBetween(offer.date, options.date) <= options.windowDays));
107
+ const jobCoverage = Object.values(store.entries).some((entry) => !excluded.has(entry.key) && entry.offered.log.some((offer) => offer.job === options.job && daysBetween(offer.date, options.date) <= options.windowDays));
98
108
  if (matchingOffers.length > 0) {
99
109
  return {
100
110
  ...base,
@@ -231,7 +241,7 @@ function firingsUnderModel(record, model) {
231
241
  * unrecorded.
232
242
  */
233
243
  function modelAttributionUnknown(record) {
234
- return firedCount(record) > 0 && Object.keys(record.fired.byModel).length === 0;
244
+ return (0, learning_usage_eligibility_1.modelAttributionIncomplete)(record);
235
245
  }
236
246
  function earlierModelFirings(record, currentModel) {
237
247
  return Object.entries(record.fired.byModel)
@@ -261,24 +271,22 @@ function findRetirementCandidates(store, options) {
261
271
  }
262
272
  const candidates = [];
263
273
  const notCandidates = [];
274
+ const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
264
275
  for (const record of Object.values(store.entries)) {
265
276
  if (record.family !== 'learning')
266
277
  continue;
267
278
  const title = latestTitle(record);
279
+ if (excluded.has(record.key) || modelAttributionUnknown(record)) {
280
+ notCandidates.push({ key: record.key, title,
281
+ reason: excluded.get(record.key) ?? 'Model attribution is incomplete; unknown use is not zero use.' });
282
+ continue;
283
+ }
268
284
  const offered = offersUnderModel(record, currentModel);
269
285
  const fired = firingsUnderModel(record, currentModel);
270
286
  if (fired > 0) {
271
287
  notCandidates.push({ key: record.key, title, reason: `It fired ${fired} time${fired === 1 ? '' : 's'} under the current model, so it is still earning its place.` });
272
288
  continue;
273
289
  }
274
- if (modelAttributionUnknown(record)) {
275
- notCandidates.push({
276
- key: record.key,
277
- title,
278
- reason: `Per-model attribution is unknown for this entry: it fired ${firedCount(record)} time${firedCount(record) === 1 ? '' : 's'} and no firing carries a model, so how hard it is working under ${currentModel} cannot be measured. Held back rather than counted as zero.`,
279
- });
280
- continue;
281
- }
282
290
  if (offered < threshold) {
283
291
  notCandidates.push({
284
292
  key: record.key,
@@ -326,6 +334,10 @@ function deriveStanding(record, options) {
326
334
  const offered = record.offered.aboveThreshold;
327
335
  const title = latestTitle(record);
328
336
  const threshold = options.limits.retirementOfferThreshold;
337
+ if ((0, learning_usage_eligibility_1.countEvidenceProblem)(record, options.offersEraStart)) {
338
+ return { key: record.key, title, standing: options.currentStanding?.(record.key) ?? 'standard',
339
+ reason: 'No usage-based change is supported.', offered: null, fired: null, recommendation: null };
340
+ }
329
341
  let standing;
330
342
  let reason;
331
343
  if (fired > 0 && daysSince(record.fired.lastFiredAt, now) <= STANDING_RECENT_DAYS) {
@@ -356,10 +368,11 @@ function deriveStanding(record, options) {
356
368
  */
357
369
  function assessStanding(store, options) {
358
370
  const now = options.now ?? new Date();
371
+ const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
359
372
  const assessments = Object.values(store.entries)
360
- .filter((r) => r.family === 'learning')
361
- .map((r) => deriveStanding(r, { ...options, now }))
362
- .sort((a, b) => b.fired - a.fired || b.offered - a.offered);
373
+ .filter((r) => r.family === 'learning' && !excluded.has(r.key))
374
+ .map((r) => deriveStanding(r, { ...options, now, offersEraStart: store.offersEraStart }))
375
+ .sort((a, b) => (b.fired ?? 0) - (a.fired ?? 0) || (b.offered ?? 0) - (a.offered ?? 0));
363
376
  return {
364
377
  generatedAt: now.toISOString(),
365
378
  assessments,
@@ -374,7 +387,7 @@ function toReportEntry(record) {
374
387
  fired: firedCount(record),
375
388
  ignored: record.fired.byOutcome.ignored,
376
389
  lastFired: record.fired.lastFiredAt,
377
- firedUnder: Object.keys(record.fired.byModel),
390
+ firedUnder: (0, learning_usage_eligibility_1.modelAttributionIncomplete)(record) ? undefined : Object.keys(record.fired.byModel),
378
391
  recentLog: record.fired.log,
379
392
  };
380
393
  }
@@ -388,7 +401,10 @@ function toReportEntry(record) {
388
401
  function buildUsageReport(store, options) {
389
402
  const now = options.now ?? new Date();
390
403
  const limit = options.limit ?? 10;
391
- const learningRecords = Object.values(store.entries).filter((r) => r.family === 'learning');
404
+ const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
405
+ const allRecords = Object.values(store.entries).filter((r) => r.family === 'learning');
406
+ const learningRecords = allRecords.filter(r => !excluded.has(r.key));
407
+ const complete = excluded.size === 0 && (!store.evidence || store.evidence.status === 'reliable');
392
408
  const offered = learningRecords.filter((r) => r.offered.total > 0);
393
409
  const fired = learningRecords.filter((r) => firedCount(r) > 0);
394
410
  const neverFired = offered.filter((r) => firedCount(r) === 0);
@@ -397,11 +413,11 @@ function buildUsageReport(store, options) {
397
413
  generatedAt: now.toISOString(),
398
414
  windowDays: options.windowDays,
399
415
  currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel),
400
- integrityFlagged: learningRecords.filter((r) => Boolean(r.integrity)).length,
416
+ integrityFlagged: excluded.size,
401
417
  totals: {
402
- entriesOffered: offered.length,
403
- entriesWithFirings: fired.length,
404
- entriesNeverFired: neverFired.length,
418
+ entriesOffered: complete ? offered.length : null,
419
+ entriesWithFirings: complete ? fired.length : null,
420
+ entriesNeverFired: complete ? neverFired.length : null,
405
421
  },
406
422
  workingHardest: fired
407
423
  .sort((a, b) => {
@@ -277,7 +277,8 @@ const reportCommand = addModelOption(addCommonOptions(new commander_1.Command('r
277
277
  });
278
278
  emit(report, options.json, () => {
279
279
  const lines = [
280
- `${report.totals.entriesOffered} entries were offered. ${report.totals.entriesWithFirings} fired. ${report.totals.entriesNeverFired} have never fired.`,
280
+ report.totals.entriesOffered === null ? 'Corpus totals are unavailable; listed entries have independent usable evidence.'
281
+ : `${report.totals.entriesOffered} entries were offered. ${report.totals.entriesWithFirings} fired. ${report.totals.entriesNeverFired} have never fired.`,
281
282
  ];
282
283
  if (report.workingHardest.length) {
283
284
  lines.push('', 'Working hardest');
@@ -295,7 +296,7 @@ const reportCommand = addModelOption(addCommonOptions(new commander_1.Command('r
295
296
  report.ignoredWhileInContext.forEach((e, i) => lines.push(`${i + 1}. ${e.title}. Attested ignored ${e.ignored} time${e.ignored === 1 ? '' : 's'} (fired ${e.fired}). In context and the work went against it, so the entry needs work rather than another recurrence.`));
296
297
  }
297
298
  if (report.integrityFlagged > 0) {
298
- lines.push('', `${report.integrityFlagged} entr${report.integrityFlagged === 1 ? 'y has' : 'ies have'} an integrity flag: more attested firings than recorded offers. Run \`learning-usage verify\` before acting on anything below.`);
299
+ lines.push('', `${report.integrityFlagged} entries were excluded from usage conclusions. Run \`learning-usage verify\` for diagnostic details.`);
299
300
  }
300
301
  lines.push('', 'Nothing is retired from this view. Run sleep-on-learnings to decide.');
301
302
  return lines.join('\n');
@@ -450,7 +451,8 @@ const pruneCommand = addCommonOptions(new commander_1.Command('prune'))
450
451
  const workspaceRoot = resolveWorkspaceRoot(options);
451
452
  const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
452
453
  const result = (0, learning_usage_store_1.pruneUsageStore)(limits);
453
- emit({ ...result, storePath: (0, learning_usage_store_1.resolveUsageStorePath)(), limits }, options.json, () => `Pruned ${result.prunedOfferRecords} offer records and ${result.prunedFiringRecords} firing records across ${result.entries} entries. Aggregates unchanged.`);
454
+ emit({ ...result, storePath: (0, learning_usage_store_1.resolveUsageStorePath)(), limits }, options.json, () => result.maintenanceStatus === 'deferred' ? `Maintenance deferred; history unchanged. ${result.evidence?.observations.join(', ') ?? ''}`
455
+ : `Pruned ${result.prunedOfferRecords} offer records and ${result.prunedFiringRecords} firing records across ${result.entries} entries. Aggregates unchanged.`);
454
456
  });
455
457
  function mb(bytes) {
456
458
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
@@ -472,9 +474,11 @@ const verifyCommand = addModelOption(addCommonOptions(new commander_1.Command('v
472
474
  '',
473
475
  ];
474
476
  const finding = (count, singular, plural, remedy) => count === 0 ? ` none: ${singular}` : ` ${count} ${count === 1 ? singular : plural}. ${remedy}`;
477
+ if (report.evidence)
478
+ lines.push(`Evidence: ${report.evidence.status}. ${report.evidence.observations.join(', ')}.`, ...(report.evidence.recovery ? [`Recovered from ${report.evidence.recovery}; primary replacement was not persisted by this read.`] : []), '');
475
479
  lines.push('Identity', finding(report.twinPairs.length, 'lesson tracked under two keys', 'lessons tracked under two keys', 'Its offers and its firings are on different records, so every count about it is partial.'), report.unmatchedTitleKeys.length
476
480
  ? ` ${report.unmatchedTitleKeys.length} title-keyed record${report.unmatchedTitleKeys.length === 1 ? ' has' : 's have'} no id twin. Expected for entries no tier has stamped; run \`stamp-ids\` to reduce it.`
477
- : ' every title-keyed record has been matched or has no twin', '', 'Counters', finding(report.invariantViolations.length, 'entry holds more attested firings than recorded offers', 'entries hold more attested firings than recorded offers', 'An entry cannot fire without being offered, so one of the two counters is wrong. History stranded in a store copy nothing reads is the usual cause.'), finding(report.caseVariantModelBuckets.length, 'model bucket differs only by case', 'model buckets differ only by case', 'One model spelled two ways splits its own denominator, so every per-model conclusion about it is computed on a fraction of its traffic.'), finding(report.unknownModelAttribution.length, 'entry fired with no model recorded', 'entries fired with no model recorded', 'Per-model retirement cannot be answered for these, and they are held back rather than counted as zero. These were attested before record-firings carried --agent and --model, so the model was never recorded against them and a rebuild cannot invent it; attestations recorded from now on carry both.'), '', 'Storage');
481
+ : ' every title-keyed record has been matched or has no twin', '', 'Counters', finding(report.invariantViolations.length, 'entry holds more attested firings than recorded offers', 'entries hold more attested firings than recorded offers', 'The counts are inconsistent. This observation does not establish their cause.'), finding(report.caseVariantModelBuckets.length, 'model bucket differs only by case', 'model buckets differ only by case', 'One model spelled two ways splits its own denominator, so every per-model conclusion about it is computed on a fraction of its traffic.'), finding(report.unknownModelAttribution.length, 'entry has incomplete model attribution', 'entries have incomplete model attribution', 'Affected model conclusions are withheld. The cause is not established; unknown historical attribution cannot be inferred from the current model.'), '', 'Storage');
478
482
  if (report.storeCopies.length === 0) {
479
483
  lines.push(' one store file, no unmerged copies');
480
484
  }
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.modelAttributionIncomplete = modelAttributionIncomplete;
4
+ exports.countEvidenceProblem = countEvidenceProblem;
5
+ exports.usageEvidenceExclusions = usageEvidenceExclusions;
6
+ const learning_usage_store_1 = require("./learning-usage-store");
7
+ const validCount = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
8
+ /** Missing attribution is unknown, including a known bucket beside unattributed history. */
9
+ function modelAttributionIncomplete(record) {
10
+ const buckets = [record.offered.byModel, record.fired.byModel];
11
+ if (buckets.some(map => !map || Object.entries(map).some(([name, n]) => !validCount(n) || !(0, learning_usage_store_1.normalizeModelName)(name) || (0, learning_usage_store_1.normalizeModelName)(name) !== name)))
12
+ return true;
13
+ return Object.values(record.offered.byModel).reduce((a, b) => a + b, 0) !== record.offered.total
14
+ || Object.values(record.fired.byModel).reduce((a, b) => a + b, 0)
15
+ !== record.fired.byOutcome.applied + record.fired.byOutcome.prevented;
16
+ }
17
+ /** Validate counts independently of a cached integrity flag. Never alter the record. */
18
+ function countEvidenceProblem(record, offersEraStart) {
19
+ const counts = [record.offered.total, record.offered.aboveThreshold, record.fired.total,
20
+ ...Object.values(record.offered.byModel), ...Object.values(record.fired.byModel),
21
+ ...Object.values(record.fired.byOutcome)];
22
+ if (counts.some(n => !validCount(n)) || learning_usage_store_1.FIRING_OUTCOMES.some(outcome => !validCount(record.fired.byOutcome[outcome]))
23
+ || record.offered.aboveThreshold > record.offered.total
24
+ || Object.values(record.fired.byOutcome).reduce((a, b) => a + b, 0) !== record.fired.total) {
25
+ return 'Usage counters are inconsistent.';
26
+ }
27
+ const violation = record.integrity ?? (0, learning_usage_store_1.integrityViolation)(record, offersEraStart);
28
+ if (!offersEraStart && record.offered.total > 0 && record.fired.total > record.offered.total) {
29
+ return 'Excess uses lack provenance establishing the offer-recording era.';
30
+ }
31
+ return violation ? violation.detail : null;
32
+ }
33
+ /** Read-time exclusions follow the actual record, never ambient UI or model state. */
34
+ function usageEvidenceExclusions(store) {
35
+ const excluded = new Map();
36
+ for (const key of store.evidence?.excludedKeys ?? [])
37
+ excluded.set(key, 'Unreadable usage record.');
38
+ const byTitle = new Map();
39
+ for (const record of Object.values(store.entries)) {
40
+ const problem = countEvidenceProblem(record, store.offersEraStart);
41
+ if (store.evidence?.status === 'unavailable')
42
+ excluded.set(record.key, 'Usage history is unavailable.');
43
+ else if (problem)
44
+ excluded.set(record.key, problem);
45
+ if (!record.fileType)
46
+ continue;
47
+ for (const title of record.titles) {
48
+ const titleKey = (0, learning_usage_store_1.titleUsageKey)(record.fileType, title);
49
+ byTitle.set(titleKey, [...new Set([...(byTitle.get(titleKey) ?? []), record.key])]);
50
+ }
51
+ }
52
+ for (const keys of byTitle.values()) {
53
+ if (keys.length > 1 && keys.some(key => key.startsWith('T:'))) {
54
+ for (const key of keys)
55
+ excluded.set(key, 'Ambiguous duplicate usage identity.');
56
+ }
57
+ }
58
+ return excluded;
59
+ }
@@ -15,6 +15,7 @@ exports.resolveCurrentModel = resolveCurrentModel;
15
15
  * Nothing here writes.
16
16
  */
17
17
  const learning_usage_store_1 = require("./learning-usage-store");
18
+ const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
18
19
  function toLookupValue(record) {
19
20
  return {
20
21
  key: record.key,
@@ -24,7 +25,7 @@ function toLookupValue(record) {
24
25
  firedTotalAttestations: record.fired.total,
25
26
  lastFired: record.fired.lastFiredAt,
26
27
  lastOffered: record.offered.lastOfferedAt,
27
- firedUnder: Object.keys(record.fired.byModel),
28
+ firedUnder: (0, learning_usage_eligibility_1.modelAttributionIncomplete)(record) ? undefined : Object.keys(record.fired.byModel),
28
29
  byOutcome: record.fired.byOutcome,
29
30
  log: record.fired.log,
30
31
  };
@@ -36,7 +37,10 @@ function toLookupValue(record) {
36
37
  */
37
38
  function buildUsageLookup(store) {
38
39
  const lookup = new Map();
40
+ const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
39
41
  for (const record of Object.values(store.entries)) {
42
+ if (excluded.has(record.key))
43
+ continue;
40
44
  const value = toLookupValue(record);
41
45
  lookup.set(record.key, value);
42
46
  if (record.fileType) {