@dzhechkov/harness-core 0.8.5 → 0.8.10

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.
Files changed (134) hide show
  1. package/.dz-manifest.json +286 -82
  2. package/README.md +54 -2
  3. package/dist/amendment-trace.d.ts.map +1 -1
  4. package/dist/amendment-trace.js +12 -1
  5. package/dist/amendment-trace.js.map +1 -1
  6. package/dist/compounding.d.ts +54 -0
  7. package/dist/compounding.d.ts.map +1 -1
  8. package/dist/compounding.js +221 -1
  9. package/dist/compounding.js.map +1 -1
  10. package/dist/eta.d.ts +92 -0
  11. package/dist/eta.d.ts.map +1 -0
  12. package/dist/eta.js +488 -0
  13. package/dist/eta.js.map +1 -0
  14. package/dist/feature-adr-checkpoints.d.ts +11 -2
  15. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  16. package/dist/feature-adr-checkpoints.js +38 -3
  17. package/dist/feature-adr-checkpoints.js.map +1 -1
  18. package/dist/feature-adr-decision-recall.d.ts +167 -0
  19. package/dist/feature-adr-decision-recall.d.ts.map +1 -0
  20. package/dist/feature-adr-decision-recall.js +519 -0
  21. package/dist/feature-adr-decision-recall.js.map +1 -0
  22. package/dist/feature-adr-landing.d.ts +37 -0
  23. package/dist/feature-adr-landing.d.ts.map +1 -0
  24. package/dist/feature-adr-landing.js +59 -0
  25. package/dist/feature-adr-landing.js.map +1 -0
  26. package/dist/feature-adr-routing.d.ts +58 -23
  27. package/dist/feature-adr-routing.d.ts.map +1 -1
  28. package/dist/feature-adr-routing.js +194 -69
  29. package/dist/feature-adr-routing.js.map +1 -1
  30. package/dist/guard-promotion.d.ts +41 -0
  31. package/dist/guard-promotion.d.ts.map +1 -1
  32. package/dist/guard-promotion.js +218 -4
  33. package/dist/guard-promotion.js.map +1 -1
  34. package/dist/guard-volume.d.ts +108 -0
  35. package/dist/guard-volume.d.ts.map +1 -0
  36. package/dist/guard-volume.js +536 -0
  37. package/dist/guard-volume.js.map +1 -0
  38. package/dist/guard.d.ts +34 -0
  39. package/dist/guard.d.ts.map +1 -1
  40. package/dist/guard.js +113 -4
  41. package/dist/guard.js.map +1 -1
  42. package/dist/index.d.ts +18 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +12 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/integration-apply.d.ts +25 -0
  47. package/dist/integration-apply.d.ts.map +1 -0
  48. package/dist/integration-apply.js +299 -0
  49. package/dist/integration-apply.js.map +1 -0
  50. package/dist/integration-evidence.d.ts +46 -0
  51. package/dist/integration-evidence.d.ts.map +1 -0
  52. package/dist/integration-evidence.js +44 -0
  53. package/dist/integration-evidence.js.map +1 -0
  54. package/dist/integration-probe-worker.d.ts +22 -0
  55. package/dist/integration-probe-worker.d.ts.map +1 -0
  56. package/dist/integration-probe-worker.js +334 -0
  57. package/dist/integration-probe-worker.js.map +1 -0
  58. package/dist/integrations-verify.d.ts +60 -0
  59. package/dist/integrations-verify.d.ts.map +1 -0
  60. package/dist/integrations-verify.js +194 -0
  61. package/dist/integrations-verify.js.map +1 -0
  62. package/dist/lesson-generalization.d.ts +29 -0
  63. package/dist/lesson-generalization.d.ts.map +1 -0
  64. package/dist/lesson-generalization.js +84 -0
  65. package/dist/lesson-generalization.js.map +1 -0
  66. package/dist/loop-blobs.generated.js +8 -8
  67. package/dist/loop-blobs.generated.js.map +1 -1
  68. package/dist/loop-plan.d.ts +13 -1
  69. package/dist/loop-plan.d.ts.map +1 -1
  70. package/dist/loop-plan.js +15 -1
  71. package/dist/loop-plan.js.map +1 -1
  72. package/dist/loop-render.d.ts.map +1 -1
  73. package/dist/loop-render.js +51 -6
  74. package/dist/loop-render.js.map +1 -1
  75. package/dist/loop-trace.d.ts +20 -1
  76. package/dist/loop-trace.d.ts.map +1 -1
  77. package/dist/loop-trace.js +83 -1
  78. package/dist/loop-trace.js.map +1 -1
  79. package/dist/model-recommender.d.ts +8 -0
  80. package/dist/model-recommender.d.ts.map +1 -1
  81. package/dist/model-recommender.js +31 -4
  82. package/dist/model-recommender.js.map +1 -1
  83. package/dist/operations.d.ts +27 -0
  84. package/dist/operations.d.ts.map +1 -1
  85. package/dist/operations.js +178 -2
  86. package/dist/operations.js.map +1 -1
  87. package/dist/patterns.d.ts +27 -1
  88. package/dist/patterns.d.ts.map +1 -1
  89. package/dist/patterns.js +211 -45
  90. package/dist/patterns.js.map +1 -1
  91. package/dist/setup.d.ts.map +1 -1
  92. package/dist/setup.js +20 -17
  93. package/dist/setup.js.map +1 -1
  94. package/dist/target-integrations.d.ts +65 -0
  95. package/dist/target-integrations.d.ts.map +1 -0
  96. package/dist/target-integrations.js +152 -0
  97. package/dist/target-integrations.js.map +1 -0
  98. package/dist/vector-tier.d.ts +6 -1
  99. package/dist/vector-tier.d.ts.map +1 -1
  100. package/dist/vector-tier.js +32 -7
  101. package/dist/vector-tier.js.map +1 -1
  102. package/dist/workflow-run.d.ts.map +1 -1
  103. package/dist/workflow-run.js +18 -12
  104. package/dist/workflow-run.js.map +1 -1
  105. package/package.json +19 -15
  106. package/sbom.json +591 -81
  107. package/src/amendment-trace.ts +12 -1
  108. package/src/compounding.ts +300 -1
  109. package/src/eta.ts +590 -0
  110. package/src/feature-adr-checkpoints.ts +39 -3
  111. package/src/feature-adr-decision-recall.ts +652 -0
  112. package/src/feature-adr-landing.ts +109 -0
  113. package/src/feature-adr-routing.ts +224 -65
  114. package/src/guard-promotion.ts +245 -4
  115. package/src/guard-volume.ts +752 -0
  116. package/src/guard.ts +148 -4
  117. package/src/index.ts +65 -3
  118. package/src/integration-apply.ts +332 -0
  119. package/src/integration-evidence.ts +89 -0
  120. package/src/integration-probe-worker.ts +310 -0
  121. package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
  122. package/src/integrations-verify.ts +258 -0
  123. package/src/lesson-generalization.ts +115 -0
  124. package/src/loop-blobs.generated.ts +8 -8
  125. package/src/loop-plan.ts +36 -3
  126. package/src/loop-render.ts +50 -6
  127. package/src/loop-trace.ts +91 -2
  128. package/src/model-recommender.ts +35 -4
  129. package/src/operations.ts +199 -2
  130. package/src/patterns.ts +252 -43
  131. package/src/setup.ts +20 -17
  132. package/src/target-integrations.ts +225 -0
  133. package/src/vector-tier.ts +44 -14
  134. package/src/workflow-run.ts +18 -11
@@ -38,6 +38,21 @@ export type RuleTemplate = 'pairing-check' | 'absence-check' | 'format-match';
38
38
 
39
39
  export const TEMPLATES: readonly RuleTemplate[] = ['pairing-check', 'absence-check', 'format-match'];
40
40
 
41
+ export function isOffsetIsoTimestamp(value: unknown): value is string {
42
+ if (typeof value !== 'string') return false;
43
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/.exec(value);
44
+ if (match === null || !Number.isFinite(Date.parse(value))) return false;
45
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText, zone, offsetHourText, offsetMinuteText] = match;
46
+ const year = Number(yearText);
47
+ const month = Number(monthText);
48
+ const day = Number(dayText);
49
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
50
+ const daysInMonth = [0, 31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month] ?? 0;
51
+ return day >= 1 && day <= daysInMonth &&
52
+ Number(hourText) <= 23 && Number(minuteText) <= 59 && Number(secondText) <= 59 &&
53
+ (zone === 'Z' || (Number(offsetHourText) <= 23 && Number(offsetMinuteText) <= 59));
54
+ }
55
+
41
56
  export interface TemplateParams {
42
57
  /** pairing-check: the glob whose presence in a change ARMS the rule. */
43
58
  readonly when?: string;
@@ -384,6 +399,23 @@ export function paramsKey(template: RuleTemplate, params: TemplateParams): strin
384
399
  return `${template}|${parts.join('&')}`;
385
400
  }
386
401
 
402
+ /**
403
+ * Exact-byte discriminator of effective promoted-rule content. The ordered tuple keeps boundaries
404
+ * unambiguous; only its two-part digest is persisted, so lesson-derived literals are not copied into
405
+ * observational history. This is correlation identity, not authentication of a locally editable log.
406
+ */
407
+ export function lessonRuleContentAnchor(template: RuleTemplate, params: TemplateParams): string {
408
+ const ordered = Object.keys(params ?? {})
409
+ .sort()
410
+ .map((key) => [key, String((params as Record<string, unknown>)[key])] as const);
411
+ const canonical = JSON.stringify([template, ordered]);
412
+ return `lesson-rule/v1:${fnv1a32(canonical)}${fnv1a32(`\u0000${canonical}`)}`;
413
+ }
414
+
415
+ export function isLessonRuleContentAnchor(value: unknown): value is string {
416
+ return typeof value === 'string' && /^lesson-rule\/v1:[a-f0-9]{16}$/.test(value);
417
+ }
418
+
387
419
  /** A rule already present in the engine or the config, in the shape the dedup check consumes. */
388
420
  export interface ExistingRuleView {
389
421
  readonly id: string;
@@ -531,6 +563,8 @@ export interface LessonInput {
531
563
  export interface PromotionCandidate {
532
564
  readonly lessonId: string;
533
565
  readonly lessonText: string;
566
+ /** False only for quarantined hypotheses; classification alone never makes them eligible. */
567
+ readonly eligible: boolean;
534
568
  readonly ruleId: string | null;
535
569
  readonly template: RuleTemplate | null;
536
570
  readonly params: TemplateParams | null;
@@ -645,7 +679,7 @@ export function assembleCandidates(facts: PromotionFacts): PromotionReport {
645
679
  if (!l || typeof l.dzId !== 'string') continue;
646
680
  const uses = Number.isFinite(l.uses) && l.uses >= 0 ? Math.floor(l.uses) : 0;
647
681
  const cost = 1 + uses;
648
- const base = { lessonId: l.dzId, lessonText: typeof l.text === 'string' ? l.text : '', cost, ruleId: null, template: null, params: null, score: 0, firings: 0, wins: 0, evaluatedPeriods: 0, periods: [], proposedRule: null, firstSeenTs: null, elapsedMs: 0, elapsedRequiredMs } as const;
682
+ const base = { lessonId: l.dzId, lessonText: typeof l.text === 'string' ? l.text : '', eligible: l.quarantined !== true, cost, ruleId: null, template: null, params: null, score: 0, firings: 0, wins: 0, evaluatedPeriods: 0, periods: [], proposedRule: null, firstSeenTs: null, elapsedMs: 0, elapsedRequiredMs } as const;
649
683
 
650
684
  if (l.quarantined === true) quarantinedSkipped += 1;
651
685
 
@@ -737,6 +771,31 @@ export function assembleCandidates(facts: PromotionFacts): PromotionReport {
737
771
 
738
772
  // ── State (ADR-004) ─────────────────────────────────────────────────────────────────────────────
739
773
 
774
+ /** Enough prospective observations for multi-year monthly reporting without an unbounded journal. */
775
+ export const MAX_PROMOTION_RUN_EVIDENCE = 120;
776
+ /** A larger run is recorded as incomplete, so truncation becomes NOT MEASURED rather than a low count. */
777
+ export const MAX_PROMOTION_RUN_CANDIDATES = 10_000;
778
+ export const MAX_PROMOTION_ACCEPTANCE_EVIDENCE = 10_000;
779
+
780
+ export interface PromotionRunCandidateEvidence {
781
+ readonly candidateAnchor: string;
782
+ readonly eligible: boolean;
783
+ readonly ruleContentAnchor: string | null;
784
+ readonly verdict: CandidateVerdict;
785
+ }
786
+
787
+ export interface PromotionRunEvidence {
788
+ readonly runId: string;
789
+ readonly ts: string;
790
+ readonly complete: boolean;
791
+ readonly candidates: readonly PromotionRunCandidateEvidence[];
792
+ }
793
+
794
+ export interface PromotionAcceptanceEvidence {
795
+ readonly ruleContentAnchor: string;
796
+ readonly acceptedTs: string;
797
+ }
798
+
740
799
  export interface PromotionStateEntry {
741
800
  readonly ruleId: string;
742
801
  readonly lessonId: string;
@@ -759,10 +818,59 @@ export interface PromotionState {
759
818
  readonly version: 1;
760
819
  readonly nextAdrSeq: number;
761
820
  readonly entries: Readonly<Record<string, PromotionStateEntry>>;
821
+ /** Optional so a legacy v1 state remains distinguishable from a recorded zero-candidate run. */
822
+ readonly runs?: readonly PromotionRunEvidence[];
823
+ /** Months whose oldest whole run records were pruned; their promote counts are not measurable. */
824
+ readonly truncatedRunPeriods?: readonly string[];
825
+ /** Compact durable join provenance, independent of bounded per-run retention. */
826
+ readonly acceptances?: readonly PromotionAcceptanceEvidence[];
827
+ /** False means an unmatched promoted firing may belong to pre-feature or pruned provenance. */
828
+ readonly acceptanceHistoryComplete?: boolean;
762
829
  }
763
830
 
764
831
  export const EMPTY_PROMOTION_STATE: PromotionState = { version: 1, nextAdrSeq: 1, entries: {} };
765
832
 
833
+ const CANDIDATE_VERDICTS: readonly CandidateVerdict[] = ['promote', 'wait', 'insufficient-data', 'duplicate', 'not-promotable'];
834
+
835
+ function normalizePromotionRun(raw: unknown): PromotionRunEvidence | null {
836
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
837
+ const value = raw as Record<string, unknown>;
838
+ const runId = value['runId'];
839
+ const ts = value['ts'];
840
+ const candidates = value['candidates'];
841
+ if (typeof runId !== 'string' || runId === '' || runId.length > 512) return null;
842
+ if (!isOffsetIsoTimestamp(ts)) return null;
843
+ if (typeof value['complete'] !== 'boolean' || !Array.isArray(candidates)) return null;
844
+ if (candidates.length > MAX_PROMOTION_RUN_CANDIDATES) return null;
845
+ const normalized: PromotionRunCandidateEvidence[] = [];
846
+ for (const candidate of candidates) {
847
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return null;
848
+ const item = candidate as Record<string, unknown>;
849
+ const candidateAnchor = item['candidateAnchor'];
850
+ const ruleContentAnchor = item['ruleContentAnchor'];
851
+ const verdict = item['verdict'];
852
+ if (typeof candidateAnchor !== 'string' || candidateAnchor === '' || candidateAnchor.length > 512) return null;
853
+ if (typeof item['eligible'] !== 'boolean') return null;
854
+ if (ruleContentAnchor !== null && !isLessonRuleContentAnchor(ruleContentAnchor)) return null;
855
+ if (typeof verdict !== 'string' || !CANDIDATE_VERDICTS.includes(verdict as CandidateVerdict)) return null;
856
+ if (verdict === 'promote' && (item['eligible'] !== true || ruleContentAnchor === null)) return null;
857
+ normalized.push({
858
+ candidateAnchor,
859
+ eligible: item['eligible'],
860
+ ruleContentAnchor: ruleContentAnchor as string | null,
861
+ verdict: verdict as CandidateVerdict,
862
+ });
863
+ }
864
+ return { runId, ts, complete: value['complete'], candidates: normalized };
865
+ }
866
+
867
+ function normalizeAcceptance(raw: unknown): PromotionAcceptanceEvidence | null {
868
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
869
+ const value = raw as Record<string, unknown>;
870
+ if (!isLessonRuleContentAnchor(value['ruleContentAnchor']) || !isOffsetIsoTimestamp(value['acceptedTs'])) return null;
871
+ return { ruleContentAnchor: value['ruleContentAnchor'], acceptedTs: value['acceptedTs'] };
872
+ }
873
+
766
874
  /**
767
875
  * Keys that must never become an entry name. `Object.hasOwn` stops a polluted JSON from being READ
768
876
  * through the prototype, but it does not stop `entries[key] = …` from WRITING through it: `JSON.parse`
@@ -784,6 +892,50 @@ export function normalizePromotionState(raw: unknown): PromotionState {
784
892
  const seqRaw = o['nextAdrSeq'];
785
893
  const nextAdrSeq = typeof seqRaw === 'number' && Number.isInteger(seqRaw) && seqRaw >= 1 && seqRaw <= 100_000 ? seqRaw : 1;
786
894
  const entries: Record<string, PromotionStateEntry> = {};
895
+ let runs: PromotionRunEvidence[] | undefined;
896
+ if (Object.hasOwn(o, 'runs')) {
897
+ runs = [];
898
+ if (Array.isArray(o['runs'])) {
899
+ const seen = new Set<string>();
900
+ for (const rawRun of o['runs']) {
901
+ const run = normalizePromotionRun(rawRun);
902
+ if (run === null || seen.has(run.runId)) continue;
903
+ seen.add(run.runId);
904
+ runs.push(run);
905
+ }
906
+ runs.sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts) || a.runId.localeCompare(b.runId));
907
+ if (runs.length > MAX_PROMOTION_RUN_EVIDENCE) runs = runs.slice(-MAX_PROMOTION_RUN_EVIDENCE);
908
+ }
909
+ }
910
+ let truncatedRunPeriods: string[] | undefined;
911
+ if (Object.hasOwn(o, 'truncatedRunPeriods')) {
912
+ truncatedRunPeriods = Array.isArray(o['truncatedRunPeriods'])
913
+ ? [...new Set(o['truncatedRunPeriods'].filter((period): period is string =>
914
+ typeof period === 'string' && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(period),
915
+ ))].sort().slice(-12)
916
+ : [];
917
+ }
918
+ let acceptances: PromotionAcceptanceEvidence[] | undefined;
919
+ if (Object.hasOwn(o, 'acceptances')) {
920
+ acceptances = [];
921
+ if (Array.isArray(o['acceptances'])) {
922
+ const byAnchor = new Map<string, PromotionAcceptanceEvidence>();
923
+ for (const rawAcceptance of o['acceptances']) {
924
+ const acceptance = normalizeAcceptance(rawAcceptance);
925
+ if (acceptance === null) continue;
926
+ const prior = byAnchor.get(acceptance.ruleContentAnchor);
927
+ if (prior === undefined || Date.parse(acceptance.acceptedTs) < Date.parse(prior.acceptedTs)) {
928
+ byAnchor.set(acceptance.ruleContentAnchor, acceptance);
929
+ }
930
+ }
931
+ acceptances = [...byAnchor.values()]
932
+ .sort((a, b) => Date.parse(a.acceptedTs) - Date.parse(b.acceptedTs) || a.ruleContentAnchor.localeCompare(b.ruleContentAnchor))
933
+ .slice(-MAX_PROMOTION_ACCEPTANCE_EVIDENCE);
934
+ }
935
+ }
936
+ const acceptanceHistoryComplete = typeof o['acceptanceHistoryComplete'] === 'boolean'
937
+ ? o['acceptanceHistoryComplete']
938
+ : undefined;
787
939
  const rawEntries = o['entries'];
788
940
  if (rawEntries && typeof rawEntries === 'object' && !Array.isArray(rawEntries)) {
789
941
  for (const key of Object.keys(rawEntries as Record<string, unknown>)) {
@@ -810,13 +962,21 @@ export function normalizePromotionState(raw: unknown): PromotionState {
810
962
  lastRunTs: str('lastRunTs') ?? '',
811
963
  wins: int('wins'),
812
964
  evaluatedPeriods: int('evaluatedPeriods'),
813
- verdict: (['promote', 'wait', 'insufficient-data', 'duplicate', 'not-promotable'] as const).includes(str('verdict') as CandidateVerdict) ? (str('verdict') as CandidateVerdict) : 'wait',
965
+ verdict: CANDIDATE_VERDICTS.includes(str('verdict') as CandidateVerdict) ? (str('verdict') as CandidateVerdict) : 'wait',
814
966
  ...(adrSeq !== undefined ? { adrSeq } : {}),
815
967
  ...(str('appliedTs') !== undefined ? { appliedTs: str('appliedTs')! } : {}),
816
968
  };
817
969
  }
818
970
  }
819
- return { version: 1, nextAdrSeq, entries };
971
+ return {
972
+ version: 1,
973
+ nextAdrSeq,
974
+ entries,
975
+ ...(runs !== undefined ? { runs } : {}),
976
+ ...(truncatedRunPeriods !== undefined ? { truncatedRunPeriods } : {}),
977
+ ...(acceptances !== undefined ? { acceptances } : {}),
978
+ ...(acceptanceHistoryComplete !== undefined ? { acceptanceHistoryComplete } : {}),
979
+ };
820
980
  }
821
981
 
822
982
  /**
@@ -850,7 +1010,88 @@ export function nextPromotionState(prev: PromotionState, report: PromotionReport
850
1010
  // Only NEWLY allocated documents advance the sequence — a re-refused candidate rewrites its own
851
1011
  // file, so counting every path would leave permanent gaps in the numbering.
852
1012
  const seq = Number.isInteger(newlyAllocated) && (newlyAllocated as number) >= 0 ? (newlyAllocated as number) : Object.keys(adrSeqs).length;
853
- return { version: 1, nextAdrSeq: Math.min(100_000, base.nextAdrSeq + seq), entries };
1013
+ return {
1014
+ version: 1,
1015
+ nextAdrSeq: Math.min(100_000, base.nextAdrSeq + seq),
1016
+ entries,
1017
+ ...(base.runs !== undefined ? { runs: base.runs } : {}),
1018
+ ...(base.truncatedRunPeriods !== undefined ? { truncatedRunPeriods: base.truncatedRunPeriods } : {}),
1019
+ ...(base.acceptances !== undefined ? { acceptances: base.acceptances } : {}),
1020
+ ...(base.acceptanceHistoryComplete !== undefined ? { acceptanceHistoryComplete: base.acceptanceHistoryComplete } : {}),
1021
+ };
1022
+ }
1023
+
1024
+ /** Add one prospective observation without changing any promotion decision or candidate report. */
1025
+ export function recordPromotionRunEvidence(
1026
+ state: PromotionState,
1027
+ report: PromotionReport,
1028
+ nowTs: string,
1029
+ ): PromotionState {
1030
+ const base = normalizePromotionState(state);
1031
+ if (!isOffsetIsoTimestamp(nowTs)) return base;
1032
+ const source = Array.isArray(report?.candidates) ? report.candidates : [];
1033
+ const complete = source.length <= MAX_PROMOTION_RUN_CANDIDATES;
1034
+ const candidates: PromotionRunCandidateEvidence[] = source
1035
+ .slice(0, MAX_PROMOTION_RUN_CANDIDATES)
1036
+ .filter((candidate) => typeof candidate?.lessonId === 'string' && candidate.lessonId !== '')
1037
+ .map((candidate) => ({
1038
+ candidateAnchor: candidate.lessonId,
1039
+ eligible: candidate.eligible === true,
1040
+ ruleContentAnchor:
1041
+ candidate.template !== null && candidate.params !== null
1042
+ ? lessonRuleContentAnchor(candidate.template, candidate.params)
1043
+ : null,
1044
+ verdict: CANDIDATE_VERDICTS.includes(candidate.verdict) ? candidate.verdict : 'wait',
1045
+ }));
1046
+ const fingerprint = fnv1a32(JSON.stringify([nowTs, complete, candidates]));
1047
+ const run: PromotionRunEvidence = {
1048
+ runId: `${nowTs}#${fingerprint}`,
1049
+ ts: nowTs,
1050
+ complete,
1051
+ candidates,
1052
+ };
1053
+ const runs = [...(base.runs ?? [])];
1054
+ if (!runs.some((item) => item.runId === run.runId)) runs.push(run);
1055
+ runs.sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts) || a.runId.localeCompare(b.runId));
1056
+ const removed = runs.slice(0, Math.max(0, runs.length - MAX_PROMOTION_RUN_EVIDENCE));
1057
+ const retained = runs.slice(-MAX_PROMOTION_RUN_EVIDENCE);
1058
+ const truncatedRunPeriods = [...new Set([
1059
+ ...(base.truncatedRunPeriods ?? []),
1060
+ ...removed.map((item) => new Date(Date.parse(item.ts)).toISOString().slice(0, 7)),
1061
+ ])].sort().slice(-12);
1062
+ const retainedAcceptances = base.acceptances ?? (base.runs ?? []).flatMap((priorRun) =>
1063
+ priorRun.candidates
1064
+ .filter((candidate) => candidate.verdict === 'promote' && candidate.ruleContentAnchor !== null)
1065
+ .map((candidate) => ({ ruleContentAnchor: candidate.ruleContentAnchor!, acceptedTs: priorRun.ts })),
1066
+ );
1067
+ const byAnchor = new Map(retainedAcceptances.map((item) => [item.ruleContentAnchor, item]));
1068
+ for (const candidate of candidates) {
1069
+ if (candidate.verdict !== 'promote' || candidate.ruleContentAnchor === null) continue;
1070
+ const prior = byAnchor.get(candidate.ruleContentAnchor);
1071
+ if (prior === undefined || Date.parse(nowTs) < Date.parse(prior.acceptedTs)) {
1072
+ byAnchor.set(candidate.ruleContentAnchor, { ruleContentAnchor: candidate.ruleContentAnchor, acceptedTs: nowTs });
1073
+ }
1074
+ }
1075
+ const allAcceptances = [...byAnchor.values()]
1076
+ .sort((a, b) => Date.parse(a.acceptedTs) - Date.parse(b.acceptedTs) || a.ruleContentAnchor.localeCompare(b.ruleContentAnchor));
1077
+ const acceptanceOverflow = allAcceptances.length > MAX_PROMOTION_ACCEPTANCE_EVIDENCE;
1078
+ const currentAcceptedRuleIds = new Set(report.candidates
1079
+ .filter((candidate) => candidate.verdict === 'promote' && candidate.ruleId !== null)
1080
+ .map((candidate) => candidate.ruleId!));
1081
+ const hadUnknownAcceptedRules = base.acceptanceHistoryComplete === undefined &&
1082
+ (Object.values(base.entries).some((entry) =>
1083
+ entry.appliedTs !== undefined && !currentAcceptedRuleIds.has(entry.ruleId),
1084
+ ) ||
1085
+ (base.truncatedRunPeriods?.length ?? 0) > 0 ||
1086
+ (base.runs ?? []).some((priorRun) => priorRun.complete !== true));
1087
+ return {
1088
+ ...base,
1089
+ runs: retained,
1090
+ truncatedRunPeriods,
1091
+ acceptances: allAcceptances.slice(-MAX_PROMOTION_ACCEPTANCE_EVIDENCE),
1092
+ acceptanceHistoryComplete:
1093
+ base.acceptanceHistoryComplete !== false && !hadUnknownAcceptedRules && !acceptanceOverflow && complete,
1094
+ };
854
1095
  }
855
1096
 
856
1097
  // ── Rendering ───────────────────────────────────────────────────────────────────────────────────