@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.
- package/.dz-manifest.json +286 -82
- package/README.md +54 -2
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +12 -1
- package/dist/amendment-trace.js.map +1 -1
- package/dist/compounding.d.ts +54 -0
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +221 -1
- package/dist/compounding.js.map +1 -1
- package/dist/eta.d.ts +92 -0
- package/dist/eta.d.ts.map +1 -0
- package/dist/eta.js +488 -0
- package/dist/eta.js.map +1 -0
- package/dist/feature-adr-checkpoints.d.ts +11 -2
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +38 -3
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-decision-recall.d.ts +167 -0
- package/dist/feature-adr-decision-recall.d.ts.map +1 -0
- package/dist/feature-adr-decision-recall.js +519 -0
- package/dist/feature-adr-decision-recall.js.map +1 -0
- package/dist/feature-adr-landing.d.ts +37 -0
- package/dist/feature-adr-landing.d.ts.map +1 -0
- package/dist/feature-adr-landing.js +59 -0
- package/dist/feature-adr-landing.js.map +1 -0
- package/dist/feature-adr-routing.d.ts +58 -23
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +194 -69
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard-promotion.d.ts +41 -0
- package/dist/guard-promotion.d.ts.map +1 -1
- package/dist/guard-promotion.js +218 -4
- package/dist/guard-promotion.js.map +1 -1
- package/dist/guard-volume.d.ts +108 -0
- package/dist/guard-volume.d.ts.map +1 -0
- package/dist/guard-volume.js +536 -0
- package/dist/guard-volume.js.map +1 -0
- package/dist/guard.d.ts +34 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +113 -4
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +18 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/dist/integration-apply.d.ts +25 -0
- package/dist/integration-apply.d.ts.map +1 -0
- package/dist/integration-apply.js +299 -0
- package/dist/integration-apply.js.map +1 -0
- package/dist/integration-evidence.d.ts +46 -0
- package/dist/integration-evidence.d.ts.map +1 -0
- package/dist/integration-evidence.js +44 -0
- package/dist/integration-evidence.js.map +1 -0
- package/dist/integration-probe-worker.d.ts +22 -0
- package/dist/integration-probe-worker.d.ts.map +1 -0
- package/dist/integration-probe-worker.js +334 -0
- package/dist/integration-probe-worker.js.map +1 -0
- package/dist/integrations-verify.d.ts +60 -0
- package/dist/integrations-verify.d.ts.map +1 -0
- package/dist/integrations-verify.js +194 -0
- package/dist/integrations-verify.js.map +1 -0
- package/dist/lesson-generalization.d.ts +29 -0
- package/dist/lesson-generalization.d.ts.map +1 -0
- package/dist/lesson-generalization.js +84 -0
- package/dist/lesson-generalization.js.map +1 -0
- package/dist/loop-blobs.generated.js +8 -8
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-plan.d.ts +13 -1
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +15 -1
- package/dist/loop-plan.js.map +1 -1
- package/dist/loop-render.d.ts.map +1 -1
- package/dist/loop-render.js +51 -6
- package/dist/loop-render.js.map +1 -1
- package/dist/loop-trace.d.ts +20 -1
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +83 -1
- package/dist/loop-trace.js.map +1 -1
- package/dist/model-recommender.d.ts +8 -0
- package/dist/model-recommender.d.ts.map +1 -1
- package/dist/model-recommender.js +31 -4
- package/dist/model-recommender.js.map +1 -1
- package/dist/operations.d.ts +27 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +178 -2
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +27 -1
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +211 -45
- package/dist/patterns.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +20 -17
- package/dist/setup.js.map +1 -1
- package/dist/target-integrations.d.ts +65 -0
- package/dist/target-integrations.d.ts.map +1 -0
- package/dist/target-integrations.js +152 -0
- package/dist/target-integrations.js.map +1 -0
- package/dist/vector-tier.d.ts +6 -1
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +32 -7
- package/dist/vector-tier.js.map +1 -1
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +18 -12
- package/dist/workflow-run.js.map +1 -1
- package/package.json +19 -15
- package/sbom.json +591 -81
- package/src/amendment-trace.ts +12 -1
- package/src/compounding.ts +300 -1
- package/src/eta.ts +590 -0
- package/src/feature-adr-checkpoints.ts +39 -3
- package/src/feature-adr-decision-recall.ts +652 -0
- package/src/feature-adr-landing.ts +109 -0
- package/src/feature-adr-routing.ts +224 -65
- package/src/guard-promotion.ts +245 -4
- package/src/guard-volume.ts +752 -0
- package/src/guard.ts +148 -4
- package/src/index.ts +65 -3
- package/src/integration-apply.ts +332 -0
- package/src/integration-evidence.ts +89 -0
- package/src/integration-probe-worker.ts +310 -0
- package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
- package/src/integrations-verify.ts +258 -0
- package/src/lesson-generalization.ts +115 -0
- package/src/loop-blobs.generated.ts +8 -8
- package/src/loop-plan.ts +36 -3
- package/src/loop-render.ts +50 -6
- package/src/loop-trace.ts +91 -2
- package/src/model-recommender.ts +35 -4
- package/src/operations.ts +199 -2
- package/src/patterns.ts +252 -43
- package/src/setup.ts +20 -17
- package/src/target-integrations.ts +225 -0
- package/src/vector-tier.ts +44 -14
- package/src/workflow-run.ts +18 -11
package/src/guard-promotion.ts
CHANGED
|
@@ -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:
|
|
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 {
|
|
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 {
|
|
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 ───────────────────────────────────────────────────────────────────────────────────
|