@ngockhoale/ukit 3.0.3 → 3.0.5
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/CHANGELOG.md +26 -0
- package/bin/ukit +12 -4
- package/manifests/engineConformance.yaml +29 -0
- package/manifests/platform.full.yaml +13 -0
- package/package.json +2 -1
- package/scripts/audit/decision-coverage.mjs +295 -0
- package/scripts/bench/outline-savings.mjs +19 -4
- package/scripts/bench/parallel-agents.mjs +15 -4
- package/scripts/bench/runGold.mjs +22 -4
- package/scripts/bench/v3-ceremony.mjs +7 -1
- package/scripts/bug/triage.mjs +56 -17
- package/scripts/index/build-index.mjs +94 -28
- package/scripts/index/query-index.mjs +48 -14
- package/scripts/index/refresh-index.mjs +142 -62
- package/scripts/perf/audit-perf.mjs +8 -2
- package/scripts/skill/audit-skill.mjs +54 -25
- package/src/bug/triageBug.js +9 -6
- package/src/cli/adapters.js +6 -0
- package/src/cli/commands/code.js +7 -1
- package/src/cli/commands/indexArgs.js +4 -2
- package/src/cli/commands/indexTools.js +8 -1
- package/src/cli/commands/install.js +13 -0
- package/src/cli/commands/memory.js +13 -9
- package/src/cli/commands/status.js +17 -1
- package/src/cli/commands/update.js +7 -0
- package/src/context/detectProjectContext.js +3 -1
- package/src/core/codeintel/analogy.js +1 -1
- package/src/core/codeintel/diagnostics.js +9 -6
- package/src/core/codeintel/graph.js +14 -8
- package/src/core/codeintel/impact.js +0 -1
- package/src/core/codeintel/invalidation.js +5 -3
- package/src/core/codeintel/packet.js +11 -0
- package/src/core/codeintel/router.js +17 -3
- package/src/core/codeintel/semanticProvider.js +1 -1
- package/src/core/codeintel/summaries.js +11 -7
- package/src/core/compact/contextBudget.js +26 -12
- package/src/core/compact/index.js +15 -8
- package/src/core/docContracts.js +10 -2
- package/src/core/experiments/deliberation.js +321 -0
- package/src/core/experiments/dynamicWorkflow.js +492 -0
- package/src/core/fileOps.js +8 -1
- package/src/core/gatewayProbe.js +29 -1
- package/src/core/gatewayResilienceEnv.js +44 -3
- package/src/core/handoffDocValidator.js +3 -1
- package/src/core/hookChainDoctor.js +16 -1
- package/src/core/memory/deltaOverlays.js +448 -0
- package/src/core/memory/learningCandidates.js +302 -0
- package/src/core/memory/migrate.js +59 -32
- package/src/core/memory/recordStore.js +24 -1
- package/src/core/memory/store.js +44 -8
- package/src/core/memory/storeV2.js +48 -33
- package/src/core/memory/userMemory.js +26 -10
- package/src/core/output/index.js +15 -10
- package/src/core/permissionPolicy.js +8 -0
- package/src/core/runtimeConfig.js +224 -4
- package/src/core/sensitiveValueScanner.js +10 -2
- package/src/core/taskBudgetValidator.js +12 -17
- package/src/core/taskProgressGuard.js +59 -9
- package/src/core/unattendedDoctor.js +5 -2
- package/src/core/uninstall.js +37 -8
- package/src/decision/client.js +371 -0
- package/src/decision/lease.js +198 -0
- package/src/decision/preflight.js +492 -0
- package/src/decision/protocol.js +308 -0
- package/src/decision/registry.js +384 -0
- package/src/decision/shadow.js +281 -0
- package/src/decision/statePacket.js +165 -0
- package/src/diagnostics/failurePatterns.js +2 -1
- package/src/diagnostics/ledgerFiles.js +3 -1
- package/src/diagnostics/routeOutcomes.js +1 -29
- package/src/index/buildIndex.js +23 -11
- package/src/index/impactContext.js +21 -2
- package/src/index/importResolution.js +13 -7
- package/src/index/queryIndex.js +11 -5
- package/src/index/resolveContext.js +22 -9
- package/src/index/taskRouting.js +63 -2
- package/src/index/verificationPlan.js +12 -1
- package/src/learning/patternProposals.js +6 -0
- package/src/render/renderTemplate.js +1 -1
- package/src/skill/auditSkill.js +3 -1
- package/src/stack/detectStack.js +3 -1
- package/template_project/.claude/agents/handoff-planner.md +2 -5
- package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
- package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
- package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
- package/template_project/.claude/hooks/handoff-resume.sh +10 -1
- package/template_project/.claude/hooks/protect-files.sh +0 -1
- package/template_project/.claude/hooks/record-execution.mjs +13 -1
- package/template_project/.claude/hooks/sensitive-data-guard.mjs +80 -5
- package/template_project/.claude/hooks/session-episode.sh +9 -2
- package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
- package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
- package/template_project/.claude/ukit/index/lib/index-core.mjs +129 -42
- package/template_project/.claude/ukit/index/route-task.mjs +444 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
- package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
- package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
- package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
- package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
- package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
- package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
- package/template_project/ukit/storage/config.json +34 -1
- package/template_project/.claude/ukit/skill-router-state.json +0 -1
- package/template_project/.ukit/storage/cache/hook-latency/unknown.jsonl +0 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readJsonIfExists, writeJson } from '../fileOps.js';
|
|
1
|
+
import { readJsonIfExists, writeJson, withFileLock } from '../fileOps.js';
|
|
2
2
|
import { buildRuntimePaths } from '../runtimePaths.js';
|
|
3
3
|
import { compressLine, compressMarkdownLines, estimateTokenCount, normalizeLineForDedupe, uniqueLines } from '../token/index.js';
|
|
4
4
|
|
|
@@ -183,7 +183,10 @@ function buildCompactedContextLines(
|
|
|
183
183
|
const forceLine = nonEmptyCount < forceFirstCount;
|
|
184
184
|
const wouldExceedLines = nonEmptyCount >= maxLines;
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
// Forced lines (header + anchors) are guaranteed placement: they bypass the
|
|
187
|
+
// line cap — dropping a forced anchor would silently break the anchor
|
|
188
|
+
// contract this function exists to enforce.
|
|
189
|
+
if ((!forceLine && wouldExceedLines) || (!forceLine && selected.length > 0 && (usedTokens + tokens) > maxTokens)) {
|
|
187
190
|
break;
|
|
188
191
|
}
|
|
189
192
|
|
|
@@ -247,12 +250,16 @@ export async function appendCompactHistory(
|
|
|
247
250
|
}
|
|
248
251
|
|
|
249
252
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
// Same read-modify-write race as compact-pressure.json: concurrent hook
|
|
254
|
+
// processes appending history must not drop each other's entries.
|
|
255
|
+
return withFileLock(runtimePaths.compactHistoryPath, async () => {
|
|
256
|
+
const history = await readCompactHistory(projectRoot, { maxEntries });
|
|
257
|
+
const nextDocument = normalizeCompactHistoryDocument({
|
|
258
|
+
entries: [normalizedEntry, ...history.entries],
|
|
259
|
+
}, { maxEntries });
|
|
260
|
+
await writeJson(runtimePaths.compactHistoryPath, nextDocument);
|
|
261
|
+
return nextDocument;
|
|
262
|
+
});
|
|
256
263
|
}
|
|
257
264
|
|
|
258
265
|
export async function recordCompaction(
|
package/src/core/docContracts.js
CHANGED
|
@@ -367,6 +367,14 @@ async function checkCommands(rootDir, registry) {
|
|
|
367
367
|
return makeCheck(id, label, 'error', { passed: true });
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
+
// Logical line count: a trailing newline terminates the last line, it does not
|
|
371
|
+
// start a phantom extra one — 'a\n' is 1 line, not 2.
|
|
372
|
+
function countLines(content) {
|
|
373
|
+
if (!content) return 0;
|
|
374
|
+
const n = content.split('\n').length;
|
|
375
|
+
return content.endsWith('\n') ? n - 1 : n;
|
|
376
|
+
}
|
|
377
|
+
|
|
370
378
|
async function checkBudgets(rootDir, registry) {
|
|
371
379
|
const id = 'budgets';
|
|
372
380
|
const label = 'registry line budgets obeyed (error enforcement blocks)';
|
|
@@ -377,7 +385,7 @@ async function checkBudgets(rootDir, registry) {
|
|
|
377
385
|
if (!Number.isInteger(e.budget.max_lines)) continue;
|
|
378
386
|
const content = await readText(path.join(rootDir, e.path));
|
|
379
387
|
if (content === null) continue;
|
|
380
|
-
const lines = content
|
|
388
|
+
const lines = countLines(content);
|
|
381
389
|
if (lines <= e.budget.max_lines) continue;
|
|
382
390
|
const msg = `${e.path}: ${lines} lines > max_lines ${e.budget.max_lines}`;
|
|
383
391
|
if (e.budget.enforcement === 'error') errorViolations.push(msg);
|
|
@@ -531,7 +539,7 @@ async function checkArchiveSuggestions(rootDir, registry, nowMs) {
|
|
|
531
539
|
const signals = [];
|
|
532
540
|
if ((inbound.get(e.path)?.size ?? 0) === 0) signals.push('unreferenced');
|
|
533
541
|
if (e.budget && Number.isInteger(e.budget.max_lines)) {
|
|
534
|
-
const lines = content
|
|
542
|
+
const lines = countLines(content);
|
|
535
543
|
if (lines > e.budget.max_lines) signals.push('over-budget');
|
|
536
544
|
}
|
|
537
545
|
let st = null;
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deliberation.js (TASK-011 / M06.1)
|
|
3
|
+
*
|
|
4
|
+
* Read-only deliberation experiments — Arena (2–3 independent candidates +
|
|
5
|
+
* evidence-rubric judge), Swarm (≥3 independent read-only lanes), smart judge —
|
|
6
|
+
* behind `experiments.deliberation.enabled` (default false). Not wired into
|
|
7
|
+
* route-task; evaluated by fixtures (SPEC §5 FR-021, FR-023; §14 "M06 wiring").
|
|
8
|
+
*
|
|
9
|
+
* Disabled posture: activation predicates return `{activate:false}` and
|
|
10
|
+
* `runArena` returns `{status:'disabled'}` BEFORE any judge call — zero calls,
|
|
11
|
+
* zero prompt content.
|
|
12
|
+
*
|
|
13
|
+
* Judge contract: `judge({findings, rubric}) → verdict` where findings are
|
|
14
|
+
* frozen candidate descriptors `{candidateId, criterionScores?, violations?,
|
|
15
|
+
* evidence?}` and verdict is `{selected, rejected, uncertainty,
|
|
16
|
+
* requiredValidation}`. `selected`/`rejected[]` entries MUST carry `citations`
|
|
17
|
+
* drawn from candidate evidence — a majority-only verdict is rejected. Hard
|
|
18
|
+
* rubric constraints (`rubric.hardConstraints`) are floors: a verdict that
|
|
19
|
+
* selects a violator or declares `waivedConstraints` is rejected. `smartJudge`
|
|
20
|
+
* is the built-in deterministic judge implementing the same contract.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const MIN_ARENA_CANDIDATES = 2;
|
|
24
|
+
const MAX_ARENA_CANDIDATES = 3;
|
|
25
|
+
const MIN_SWARM_LANES = 3;
|
|
26
|
+
|
|
27
|
+
function deliberationEnabled(config) {
|
|
28
|
+
return config?.experiments?.deliberation?.enabled === true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPlainObject(value) {
|
|
32
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function candidateIdOf(entry) {
|
|
36
|
+
return entry?.candidateId ?? entry?.id ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function citationsOf(entry) {
|
|
40
|
+
return Array.isArray(entry?.citations) ? entry.citations : [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Arena activation predicate. Fires only when the route carries ≥2 materially
|
|
45
|
+
* different viable alternatives AND budget allows; suppressed on established
|
|
46
|
+
* precedent, reversible changes, or cosmetic-only alternatives.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} route — route summary fields: `alternatives[]`
|
|
49
|
+
* ({id, viable, materiallyDifferent}), `budget.callsRemaining`,
|
|
50
|
+
* `precedent`, `reversible`.
|
|
51
|
+
* @param {object} [config] — runtime config; absent/disabled → suppress.
|
|
52
|
+
* @returns {{activate: boolean, reasons: string[]}}
|
|
53
|
+
*/
|
|
54
|
+
export function evaluateArenaActivation(route = null, config = null) {
|
|
55
|
+
if (!deliberationEnabled(config)) {
|
|
56
|
+
return { activate: false, reasons: ['experiment-disabled'] };
|
|
57
|
+
}
|
|
58
|
+
const reasons = [];
|
|
59
|
+
if (route?.precedent) {
|
|
60
|
+
reasons.push('established-precedent');
|
|
61
|
+
}
|
|
62
|
+
if (route?.reversible === true) {
|
|
63
|
+
reasons.push('reversible-change');
|
|
64
|
+
}
|
|
65
|
+
const alternatives = Array.isArray(route?.alternatives) ? route.alternatives : [];
|
|
66
|
+
const material = alternatives.filter(
|
|
67
|
+
(alt) => alt?.viable === true && alt?.materiallyDifferent === true,
|
|
68
|
+
);
|
|
69
|
+
if (material.length < MIN_ARENA_CANDIDATES) {
|
|
70
|
+
reasons.push('insufficient-material-alternatives');
|
|
71
|
+
}
|
|
72
|
+
const callsRemaining = route?.budget?.callsRemaining;
|
|
73
|
+
const budgetAllows = !(typeof callsRemaining === 'number' && callsRemaining < 1);
|
|
74
|
+
if (!budgetAllows) {
|
|
75
|
+
reasons.push('budget-exhausted');
|
|
76
|
+
}
|
|
77
|
+
if (reasons.length > 0) {
|
|
78
|
+
return { activate: false, reasons };
|
|
79
|
+
}
|
|
80
|
+
return { activate: true, reasons: ['material-alternatives', 'budget-allows'] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Swarm activation predicate. Fires only on ≥3 independent read-only lanes;
|
|
85
|
+
* any mutating or dependent lane suppresses the whole swarm.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} route — route summary field `lanes[]`
|
|
88
|
+
* ({id, readOnly, independent}).
|
|
89
|
+
* @param {object} [config]
|
|
90
|
+
* @returns {{activate: boolean, reasons: string[], lanes?: string[]}}
|
|
91
|
+
*/
|
|
92
|
+
export function evaluateSwarmActivation(route = null, config = null) {
|
|
93
|
+
if (!deliberationEnabled(config)) {
|
|
94
|
+
return { activate: false, reasons: ['experiment-disabled'], lanes: [] };
|
|
95
|
+
}
|
|
96
|
+
const lanes = Array.isArray(route?.lanes) ? route.lanes : [];
|
|
97
|
+
const reasons = [];
|
|
98
|
+
if (lanes.some((lane) => lane?.readOnly !== true)) {
|
|
99
|
+
reasons.push('lane-not-read-only');
|
|
100
|
+
}
|
|
101
|
+
const independent = lanes.filter(
|
|
102
|
+
(lane) => lane?.readOnly === true && lane?.independent === true,
|
|
103
|
+
);
|
|
104
|
+
if (independent.length < MIN_SWARM_LANES) {
|
|
105
|
+
reasons.push('insufficient-independent-lanes');
|
|
106
|
+
}
|
|
107
|
+
if (reasons.length > 0) {
|
|
108
|
+
return { activate: false, reasons, lanes: [] };
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
activate: true,
|
|
112
|
+
reasons: ['independent-read-only-lanes'],
|
|
113
|
+
lanes: independent.map((lane) => lane.id),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Deterministic evidence-rubric judge. Scores each finding against weighted
|
|
119
|
+
* rubric criteria; findings violating a hard constraint are ineligible for
|
|
120
|
+
* selection regardless of score. Hard constraints are never waivable.
|
|
121
|
+
*
|
|
122
|
+
* @param {{findings: object[], rubric: {criteria?: {id, weight?}[],
|
|
123
|
+
* hardConstraints?: string[]}}} input
|
|
124
|
+
* @returns {{selected: object|null, rejected: object[], uncertainty: string[],
|
|
125
|
+
* requiredValidation: string[]}}
|
|
126
|
+
*/
|
|
127
|
+
export function smartJudge({ findings = [], rubric = {} } = {}) {
|
|
128
|
+
const criteria = Array.isArray(rubric?.criteria) ? rubric.criteria : [];
|
|
129
|
+
const hardConstraints = new Set(
|
|
130
|
+
Array.isArray(rubric?.hardConstraints) ? rubric.hardConstraints : [],
|
|
131
|
+
);
|
|
132
|
+
const scored = (Array.isArray(findings) ? findings : []).map((finding) => {
|
|
133
|
+
const scores = isPlainObject(finding?.criterionScores) ? finding.criterionScores : {};
|
|
134
|
+
const score = criteria.reduce(
|
|
135
|
+
(sum, criterion) => sum + Number(scores[criterion.id] ?? 0) * Number(criterion.weight ?? 1),
|
|
136
|
+
0,
|
|
137
|
+
);
|
|
138
|
+
const violations = (Array.isArray(finding?.violations) ? finding.violations : [])
|
|
139
|
+
.filter((id) => hardConstraints.has(id));
|
|
140
|
+
return { finding, score, violations };
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const eligible = scored.filter((entry) => entry.violations.length === 0);
|
|
144
|
+
const rejected = [];
|
|
145
|
+
for (const entry of scored) {
|
|
146
|
+
if (entry.violations.length > 0) {
|
|
147
|
+
rejected.push({
|
|
148
|
+
candidateId: candidateIdOf(entry.finding),
|
|
149
|
+
reason: `hard-constraint:${entry.violations.join(',')}`,
|
|
150
|
+
citations: Array.isArray(entry.finding?.evidence) ? entry.finding.evidence : [],
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
eligible.sort((a, b) => b.score - a.score);
|
|
156
|
+
const top = eligible[0] ?? null;
|
|
157
|
+
const runnerUp = eligible[1] ?? null;
|
|
158
|
+
const uncertainty = [];
|
|
159
|
+
const requiredValidation = [];
|
|
160
|
+
|
|
161
|
+
let selected = null;
|
|
162
|
+
if (top) {
|
|
163
|
+
const citations = Array.isArray(top.finding?.evidence) ? top.finding.evidence : [];
|
|
164
|
+
if (citations.length === 0) {
|
|
165
|
+
uncertainty.push(`no-evidence:${candidateIdOf(top.finding)}`);
|
|
166
|
+
requiredValidation.push(`collect-evidence:${candidateIdOf(top.finding)}`);
|
|
167
|
+
} else {
|
|
168
|
+
selected = {
|
|
169
|
+
candidateId: candidateIdOf(top.finding),
|
|
170
|
+
score: top.score,
|
|
171
|
+
citations,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (top && runnerUp && top.score - runnerUp.score <= 1) {
|
|
176
|
+
uncertainty.push(`close-call:${candidateIdOf(top.finding)}~${candidateIdOf(runnerUp.finding)}`);
|
|
177
|
+
requiredValidation.push(`validate-tiebreak:${candidateIdOf(top.finding)}`);
|
|
178
|
+
}
|
|
179
|
+
for (const entry of eligible) {
|
|
180
|
+
if (entry === top) continue;
|
|
181
|
+
rejected.push({
|
|
182
|
+
candidateId: candidateIdOf(entry.finding),
|
|
183
|
+
reason: 'lower-score',
|
|
184
|
+
citations: Array.isArray(entry.finding?.evidence) ? entry.finding.evidence : [],
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (!selected && !top) {
|
|
188
|
+
uncertainty.push('no-eligible-candidate');
|
|
189
|
+
requiredValidation.push('re-scope-candidates');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { selected, rejected, uncertainty, requiredValidation };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Post-checks a judge verdict against the arena floors. Returns the list of
|
|
197
|
+
* rejection reasons (empty = verdict stands).
|
|
198
|
+
*/
|
|
199
|
+
function verdictViolations(verdict, candidates, rubric) {
|
|
200
|
+
const reasons = [];
|
|
201
|
+
const hardConstraints = new Set(
|
|
202
|
+
Array.isArray(rubric?.hardConstraints) ? rubric.hardConstraints : [],
|
|
203
|
+
);
|
|
204
|
+
const waived = Array.isArray(verdict?.waivedConstraints) ? verdict.waivedConstraints : [];
|
|
205
|
+
if (waived.some((id) => hardConstraints.has(id)) || waived.length > 0) {
|
|
206
|
+
reasons.push('hard-constraint-waived');
|
|
207
|
+
}
|
|
208
|
+
const selectedId = candidateIdOf(verdict?.selected);
|
|
209
|
+
if (selectedId !== null) {
|
|
210
|
+
const selectedFinding = candidates.find((c) => candidateIdOf(c) === selectedId);
|
|
211
|
+
const violations = Array.isArray(selectedFinding?.violations) ? selectedFinding.violations : [];
|
|
212
|
+
if (violations.some((id) => hardConstraints.has(id))) {
|
|
213
|
+
reasons.push('hard-constraint-violation-selected');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const entries = [verdict?.selected, ...(Array.isArray(verdict?.rejected) ? verdict.rejected : [])]
|
|
217
|
+
.filter(Boolean);
|
|
218
|
+
if (entries.some((entry) => citationsOf(entry).length === 0)) {
|
|
219
|
+
reasons.push('majority-only-verdict');
|
|
220
|
+
}
|
|
221
|
+
return reasons;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Deep-frozen structural copy: plain objects and arrays are cloned and frozen
|
|
225
|
+
// recursively so a judge cannot mutate nested state (violations, evidence)
|
|
226
|
+
// to defeat the verdict post-check. Non-plain values pass through frozen-safe.
|
|
227
|
+
function deepFreezeCopy(value) {
|
|
228
|
+
if (Array.isArray(value)) {
|
|
229
|
+
return Object.freeze(value.map(deepFreezeCopy));
|
|
230
|
+
}
|
|
231
|
+
if (isPlainObject(value)) {
|
|
232
|
+
const copy = {};
|
|
233
|
+
for (const [key, item] of Object.entries(value)) {
|
|
234
|
+
copy[key] = deepFreezeCopy(item);
|
|
235
|
+
}
|
|
236
|
+
return Object.freeze(copy);
|
|
237
|
+
}
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Run a read-only arena over 2–3 frozen candidates with an evidence-rubric
|
|
243
|
+
* judge. The judge receives frozen copies — mutation attempts throw. The
|
|
244
|
+
* verdict is post-checked: hard-constraint waivers/violations and
|
|
245
|
+
* majority-only (citation-free) verdicts are rejected.
|
|
246
|
+
*
|
|
247
|
+
* @param {{candidates: object[], rubric: object, judge: Function,
|
|
248
|
+
* config?: object}} input
|
|
249
|
+
* @returns {{status: 'ok'|'disabled'|'rejected', selected?: object|null,
|
|
250
|
+
* rejected?: object[], uncertainty?: string[], requiredValidation?: string[],
|
|
251
|
+
* reasons?: string[]}}
|
|
252
|
+
*/
|
|
253
|
+
export function runArena({ candidates = [], rubric = {}, judge = smartJudge, config = null } = {}) {
|
|
254
|
+
if (!deliberationEnabled(config)) {
|
|
255
|
+
return { status: 'disabled', selected: null, rejected: [], reasons: ['experiment-disabled'] };
|
|
256
|
+
}
|
|
257
|
+
const list = Array.isArray(candidates) ? candidates : [];
|
|
258
|
+
if (list.length < MIN_ARENA_CANDIDATES || list.length > MAX_ARENA_CANDIDATES) {
|
|
259
|
+
throw new TypeError(
|
|
260
|
+
`arena requires ${MIN_ARENA_CANDIDATES}–${MAX_ARENA_CANDIDATES} candidates.`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (typeof judge !== 'function') {
|
|
264
|
+
throw new TypeError('arena judge must be a function.');
|
|
265
|
+
}
|
|
266
|
+
// Read-only: the judge sees deep-frozen copies — mutation of any nested field
|
|
267
|
+
// (e.g. hiding a hard-constraint violation) throws in strict mode. A shallow
|
|
268
|
+
// freeze would leave nested arrays/objects mutable and defeat the post-check.
|
|
269
|
+
const findings = list.map((candidate) => deepFreezeCopy(candidate));
|
|
270
|
+
const verdict = judge({ findings, rubric }) ?? {};
|
|
271
|
+
const reasons = verdictViolations(verdict, findings, rubric);
|
|
272
|
+
if (reasons.length > 0) {
|
|
273
|
+
return { status: 'rejected', selected: null, rejected: [], reasons };
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
status: 'ok',
|
|
277
|
+
selected: verdict.selected ?? null,
|
|
278
|
+
rejected: Array.isArray(verdict.rejected) ? verdict.rejected : [],
|
|
279
|
+
uncertainty: Array.isArray(verdict.uncertainty) ? verdict.uncertainty : [],
|
|
280
|
+
requiredValidation: Array.isArray(verdict.requiredValidation) ? verdict.requiredValidation : [],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const SCORECARD_VERDICTS = new Set(['net-gain', 'neutral', 'negative']);
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Emit a normalized experiment scorecard (FR-023). Promotion requires
|
|
288
|
+
* `verdict:'net-gain'` + maintainer review; `neutral|negative` keeps the
|
|
289
|
+
* experiment disabled.
|
|
290
|
+
*
|
|
291
|
+
* @param {{experimentId: string, taskClass: string, baselineRef: string,
|
|
292
|
+
* deltas: {quality,rework,latency,calls}, verdict: string}} record
|
|
293
|
+
* @returns {object} scorecard with `promotionEligible` + `disposition`.
|
|
294
|
+
*/
|
|
295
|
+
export function emitExperimentScorecard(record = {}) {
|
|
296
|
+
if (!isPlainObject(record) || typeof record.experimentId !== 'string' || !record.experimentId) {
|
|
297
|
+
throw new TypeError('scorecard record.experimentId must be a non-empty string.');
|
|
298
|
+
}
|
|
299
|
+
if (!SCORECARD_VERDICTS.has(record.verdict)) {
|
|
300
|
+
throw new TypeError(`scorecard verdict must be one of ${[...SCORECARD_VERDICTS].join('|')}.`);
|
|
301
|
+
}
|
|
302
|
+
const deltas = isPlainObject(record.deltas) ? record.deltas : {};
|
|
303
|
+
const scorecard = {
|
|
304
|
+
experimentId: record.experimentId,
|
|
305
|
+
taskClass: record.taskClass ?? null,
|
|
306
|
+
baselineRef: record.baselineRef ?? null,
|
|
307
|
+
deltas: {
|
|
308
|
+
quality: Number(deltas.quality ?? 0),
|
|
309
|
+
rework: Number(deltas.rework ?? 0),
|
|
310
|
+
latency: Number(deltas.latency ?? 0),
|
|
311
|
+
calls: Number(deltas.calls ?? 0),
|
|
312
|
+
},
|
|
313
|
+
verdict: record.verdict,
|
|
314
|
+
};
|
|
315
|
+
const promotionEligible = record.verdict === 'net-gain';
|
|
316
|
+
return {
|
|
317
|
+
...scorecard,
|
|
318
|
+
promotionEligible,
|
|
319
|
+
disposition: promotionEligible ? 'maintainer-review' : 'stay-disabled',
|
|
320
|
+
};
|
|
321
|
+
}
|