@dzhechkov/harness-core 0.8.6 → 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 +257 -53
- package/README.md +48 -1
- 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.js +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 +2 -2
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +7 -11
- 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 +9 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +55 -4
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- 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/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/package.json +9 -8
- package/sbom.json +562 -52
- 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 +1 -1
- package/src/feature-adr-decision-recall.ts +652 -0
- package/src/feature-adr-landing.ts +109 -0
- package/src/feature-adr-routing.ts +7 -11
- package/src/guard-promotion.ts +245 -4
- package/src/guard-volume.ts +752 -0
- package/src/guard.ts +70 -4
- package/src/index.ts +63 -2
- 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/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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Pure decisions for the Step-7.5 Codex companion liveness barrier. */
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_CODE_LANDING_CEILING_MS = 7_200_000;
|
|
4
|
+
export const CODE_LANDING_CEILING_ENV = 'DZ_FEATURE_ADR_CODE_LANDING_CEILING_MS';
|
|
5
|
+
|
|
6
|
+
export type CodeLandingLivenessVerdict =
|
|
7
|
+
| 'coder-running'
|
|
8
|
+
| 'landed'
|
|
9
|
+
| 'genuinely-not-landed'
|
|
10
|
+
| 'exited-without-edits'
|
|
11
|
+
| 'dead-worker'
|
|
12
|
+
| 'inconclusive';
|
|
13
|
+
|
|
14
|
+
export type CodeLandingLivenessReason =
|
|
15
|
+
| 'recorded-pid-alive'
|
|
16
|
+
| 'recorded-pid-absent'
|
|
17
|
+
| 'terminal-companion'
|
|
18
|
+
| 'ceiling-exceeded'
|
|
19
|
+
| 'companion-probe-error'
|
|
20
|
+
| 'unparseable-companion-status'
|
|
21
|
+
| 'recorded-pid-unavailable'
|
|
22
|
+
| 'git-evidence-unavailable'
|
|
23
|
+
| 'reported-zero-touched-files';
|
|
24
|
+
|
|
25
|
+
export interface CodeLandingLivenessInput {
|
|
26
|
+
readonly companionStatus: unknown;
|
|
27
|
+
readonly recordedPidAlive: boolean | null;
|
|
28
|
+
readonly targetsChanged: boolean | null;
|
|
29
|
+
readonly elapsedMs: number;
|
|
30
|
+
readonly ceilingMs: number;
|
|
31
|
+
/**
|
|
32
|
+
* How many files the companion job itself REPORTED writing (`result.touchedFiles.length`), or
|
|
33
|
+
* null when the record could not be read. Zero from a cleanly-terminated job is a different
|
|
34
|
+
* event from "the window expired": the coder finished and wrote nothing, which is what happens
|
|
35
|
+
* when it asks a question a non-interactive dispatch cannot answer. Folding the two together
|
|
36
|
+
* hides the only cure that works — answer the gate and re-dispatch.
|
|
37
|
+
*/
|
|
38
|
+
readonly reportedTouchedFiles?: number | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CodeLandingLivenessDecision {
|
|
42
|
+
readonly verdict: CodeLandingLivenessVerdict;
|
|
43
|
+
readonly reason: CodeLandingLivenessReason;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function decideCodeLandingLiveness(input: CodeLandingLivenessInput): CodeLandingLivenessDecision {
|
|
47
|
+
const status = typeof input.companionStatus === 'string' ? input.companionStatus.trim().toLowerCase() : ''
|
|
48
|
+
const elapsedMs = Number.isFinite(input.elapsedMs) ? Math.max(0, input.elapsedMs) : 0
|
|
49
|
+
const ceilingMs = Number.isFinite(input.ceilingMs) && input.ceilingMs > 0 ? input.ceilingMs : DEFAULT_CODE_LANDING_CEILING_MS
|
|
50
|
+
const live = status === 'running' || status === 'queued'
|
|
51
|
+
const terminal = status === 'completed' || status === 'failed' || status === 'cancelled'
|
|
52
|
+
|
|
53
|
+
if (live && input.recordedPidAlive === false) {
|
|
54
|
+
return { verdict: 'dead-worker', reason: 'recorded-pid-absent' }
|
|
55
|
+
}
|
|
56
|
+
if (live && input.recordedPidAlive === true) {
|
|
57
|
+
if (elapsedMs >= ceilingMs) return { verdict: 'inconclusive', reason: 'ceiling-exceeded' }
|
|
58
|
+
return { verdict: 'coder-running', reason: 'recorded-pid-alive' }
|
|
59
|
+
}
|
|
60
|
+
if (live) return { verdict: 'inconclusive', reason: 'recorded-pid-unavailable' }
|
|
61
|
+
if (terminal) {
|
|
62
|
+
if (input.targetsChanged === true) return { verdict: 'landed', reason: 'terminal-companion' }
|
|
63
|
+
if (input.targetsChanged === false) {
|
|
64
|
+
if (input.reportedTouchedFiles === 0) {
|
|
65
|
+
return { verdict: 'exited-without-edits', reason: 'reported-zero-touched-files' }
|
|
66
|
+
}
|
|
67
|
+
return { verdict: 'genuinely-not-landed', reason: 'terminal-companion' }
|
|
68
|
+
}
|
|
69
|
+
return { verdict: 'inconclusive', reason: 'git-evidence-unavailable' }
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
verdict: 'inconclusive',
|
|
73
|
+
reason: status === '' ? 'companion-probe-error' : 'unparseable-companion-status',
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function extractCodexCompanionJobId(text: unknown): string | null {
|
|
78
|
+
const match = /\bstarted in the background as (task-[a-z0-9]+(?:-[a-z0-9]+)*)\b/i.exec(String(text ?? ''));
|
|
79
|
+
return match && match[1] ? match[1] : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface CodeLandingLivenessProbe {
|
|
83
|
+
readonly companionStatus: string;
|
|
84
|
+
readonly recordedPidAlive: boolean | null;
|
|
85
|
+
readonly targetsChanged: boolean | null;
|
|
86
|
+
readonly elapsedMs: number;
|
|
87
|
+
readonly ceilingMs: number;
|
|
88
|
+
readonly startMs: number;
|
|
89
|
+
readonly reportedTouchedFiles: number | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function parseCodeLandingLivenessSignal(text: unknown): CodeLandingLivenessProbe | null {
|
|
93
|
+
const source = String(text ?? '');
|
|
94
|
+
// touched-files is OPTIONAL in the grammar: an older probe, or a state record we could not read,
|
|
95
|
+
// simply omits it and the field stays null — which keeps the verdict at genuinely-not-landed
|
|
96
|
+
// rather than inventing a clean exit. Absence is never evidence here.
|
|
97
|
+
const match = /^CODEX-LIVENESS-SIGNAL companion=([a-z-]+) pid-alive=(true|false|unknown) targets-changed=(true|false|unknown) elapsed-ms=(\d+) ceiling-ms=(\d+) start-ms=(\d+)(?: touched-files=(\d+|unknown))?[ \t]*$/m.exec(source);
|
|
98
|
+
if (!match) return null;
|
|
99
|
+
const asTriState = (value: string): boolean | null => value === 'true' ? true : value === 'false' ? false : null;
|
|
100
|
+
return {
|
|
101
|
+
companionStatus: String(match[1]),
|
|
102
|
+
recordedPidAlive: asTriState(String(match[2])),
|
|
103
|
+
targetsChanged: asTriState(String(match[3])),
|
|
104
|
+
elapsedMs: Number(match[4]),
|
|
105
|
+
ceilingMs: Number(match[5]),
|
|
106
|
+
startMs: Number(match[6]),
|
|
107
|
+
reportedTouchedFiles: match[7] === undefined || match[7] === 'unknown' ? null : Number(match[7]),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -548,15 +548,11 @@ export function codexEffortHint(opts: StageOpts | null | undefined): string {
|
|
|
548
548
|
|
|
549
549
|
// ── Step-7.5 CODE landed barrier (Codex out-of-band write flush) ────────────
|
|
550
550
|
|
|
551
|
-
// Widened 2026-08-28 (MEASURED
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
// the underlying conflation of TIMEOUT with CONFIRMED-ABSENCE (filed as a backlog item — the barrier
|
|
557
|
-
// still cannot see the codex-companion job's own liveness) — it only narrows how often the false
|
|
558
|
-
// negative fires for a realistic M-tier build, at the cost of a longer worst-case wait when the
|
|
559
|
-
// coder truly produced nothing.
|
|
551
|
+
// Widened 2026-08-28 (MEASURED): the dz-deadwood coder ran 46m11s, and slop-lint was still running
|
|
552
|
+
// with its recorded PID alive at 16m38s when the old 120s window had already declared absence.
|
|
553
|
+
// Changed 2026-08-30 (MEASURED: six false verdicts; plus task-mtfhglwk stayed status=running for
|
|
554
|
+
// 8h51m after recorded PID 3639268 disappeared): this is now one git-evidence/backoff window inside
|
|
555
|
+
// the liveness-driven loop, not the whole barrier. The separate hard ceiling bounds a live worker.
|
|
560
556
|
export const DEFAULT_CODE_LANDING_MAX_WAIT_MS = 1_020_000;
|
|
561
557
|
|
|
562
558
|
export const DEFAULT_CODE_LANDING_BACKOFF_MS = [
|
|
@@ -604,11 +600,11 @@ export type CodeStageLandingStatus = 'landed' | 'genuinely-not-landed' | 'inconc
|
|
|
604
600
|
|
|
605
601
|
/** Bumped whenever the landing protocol's meaning changes. Recorded on every code-stage result so a
|
|
606
602
|
* pre-protocol checkpoint reads as no-checkpoint instead of resuming into new semantics. */
|
|
607
|
-
export const LANDING_PROTOCOL_VERSION =
|
|
603
|
+
export const LANDING_PROTOCOL_VERSION = 3;
|
|
608
604
|
|
|
609
605
|
/** R6: salted into the code stage's checkpoint hash PARTS (not the schema version — ADR-003 keeps
|
|
610
606
|
* `CKPT_SCHEMA_VERSION` at 'fa-ckpt-2' deliberately, so ONLY the code stage re-runs). */
|
|
611
|
-
export const LANDING_HASH_TOKEN = 'landing-
|
|
607
|
+
export const LANDING_HASH_TOKEN = 'landing-v3';
|
|
612
608
|
|
|
613
609
|
/** Why a landing verdict is INCONCLUSIVE. Typed, because "we do not know" needs to say WHICH
|
|
614
610
|
* unknown — the operator repair differs per reason and a single untyped bucket reads as noise. */
|
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 ───────────────────────────────────────────────────────────────────────────────────
|