@mutagent/evaluator 0.1.0-alpha.4 → 0.1.0-alpha.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/.claude/skills/mutagent-evaluator/SKILL.md +2 -1
- package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +64 -0
- package/.claude/skills/mutagent-evaluator/references/operation-inventory.md +1 -0
- package/.claude/skills/mutagent-evaluator/scripts/cli/audit-run.ts +6 -3
- package/.claude/skills/mutagent-evaluator/scripts/code-quality-verdict.ts +285 -0
- package/.claude/skills/mutagent-evaluator/scripts/persist-eval-criteria.ts +232 -0
- package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +12 -2
- package/.claude/skills/mutagent-evaluator/scripts/run-deterministic.ts +114 -18
- package/.claude/skills/mutagent-evaluator/scripts/sync-eval-criteria.ts +244 -0
- package/bin/mutagent-cli.mjs +650 -3
- package/package.json +2 -2
|
@@ -40,7 +40,13 @@ export const AdlStage = {
|
|
|
40
40
|
} as const;
|
|
41
41
|
export type AdlStageValue = (typeof AdlStage)[keyof typeof AdlStage];
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
// Mirrors the orchestrator handover-contract SubjectKind. The CANONICAL inter-stage
|
|
44
|
+
// subject vocabulary is `agent | skill | code` (matches config-schema TargetSubject).
|
|
45
|
+
// `code` (Wave-2 follow-up) is ADDITIVE — every existing skill/agent handoff still
|
|
46
|
+
// validates; it only lets a `code` subject route through *evaluate's failure handoff
|
|
47
|
+
// the SAME way it flows through the orchestrator handover-contract (the two are kept
|
|
48
|
+
// in lock-step by design; the evaluator re-implements the shape, never imports it).
|
|
49
|
+
export const SubjectKind = { Skill: "skill", Agent: "agent", Code: "code" } as const;
|
|
44
50
|
export type SubjectKindValue = (typeof SubjectKind)[keyof typeof SubjectKind];
|
|
45
51
|
|
|
46
52
|
export const ArtifactKind = {
|
|
@@ -67,7 +73,11 @@ export type EscalationPolicyValue =
|
|
|
67
73
|
// ── TypeBox schemas (closed objects — auditable boundary) ───────────────────
|
|
68
74
|
const SubjectSchema = Type.Object(
|
|
69
75
|
{
|
|
70
|
-
kind: Type.Union([
|
|
76
|
+
kind: Type.Union([
|
|
77
|
+
Type.Literal(SubjectKind.Skill),
|
|
78
|
+
Type.Literal(SubjectKind.Agent),
|
|
79
|
+
Type.Literal(SubjectKind.Code),
|
|
80
|
+
]),
|
|
71
81
|
name: Type.String({ minLength: 1 }),
|
|
72
82
|
path: Type.String({ minLength: 1 }),
|
|
73
83
|
},
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
type EvalMatrix,
|
|
33
33
|
type RowResultValue,
|
|
34
34
|
type ScorecardCriterion,
|
|
35
|
+
CheckMethod,
|
|
35
36
|
RowResult,
|
|
36
37
|
Track,
|
|
37
38
|
trackForCheckMethod,
|
|
@@ -84,14 +85,96 @@ function isNonEmpty(value: unknown): boolean {
|
|
|
84
85
|
return true;
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/** A STRUCTURED, non-empty value: a non-empty object or array. A present-but-
|
|
89
|
+
* scalar/empty artifact is NOT structured — the typebox-schema re-exec fails it
|
|
90
|
+
* (a hollow presence-pass under the old proxy). PURE. */
|
|
91
|
+
function isStructuredNonEmpty(value: unknown): boolean {
|
|
92
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
93
|
+
if (value !== null && typeof value === "object") return Object.keys(value).length > 0;
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Scan a produced artifact for an EXPLICIT gate-failure marker: a falsy pass
|
|
98
|
+
* flag (`pass`/`ok`/`passed` === false) or a failing `status` stamp
|
|
99
|
+
* (`fail`/`failed`/`error`). Conservative + shallow so it stays deterministic
|
|
100
|
+
* and does not false-fail on unrelated substrings. PURE. */
|
|
101
|
+
function hasFailureMarker(value: unknown): boolean {
|
|
102
|
+
if (value === null || typeof value !== "object") return false;
|
|
103
|
+
const obj = value as Record<string, unknown>;
|
|
104
|
+
for (const flag of ["pass", "ok", "passed"] as const) {
|
|
105
|
+
if (obj[flag] === false) return true;
|
|
106
|
+
}
|
|
107
|
+
const status = obj["status"];
|
|
108
|
+
if (typeof status === "string") {
|
|
109
|
+
const s = status.toLowerCase();
|
|
110
|
+
if (s === "fail" || s === "failed" || s === "error") return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Item #4 — the REAL live re-execution of a deterministic row. Replaces the
|
|
117
|
+
* presence-proxy (`the artifact exists → mark it covered`) with an ACTUAL
|
|
118
|
+
* checkMethod-keyed predicate over the artifact's LIVE content:
|
|
119
|
+
* - typebox-schema : the produced artifact must be a STRUCTURED, non-empty
|
|
120
|
+
* object/array — a present-but-malformed scalar now FAILS
|
|
121
|
+
* instead of hollow-passing (real shape conformance).
|
|
122
|
+
* - gate : the evidence must be present AND carry NO explicit
|
|
123
|
+
* failure marker (fail-loud gate — `hasFailureMarker`).
|
|
124
|
+
* - deterministic-script: the produced artifact must be present + non-empty
|
|
125
|
+
* (the script actually emitted its output).
|
|
126
|
+
* Returns pass/fail when the criterion maps to a live bundle artifact; returns
|
|
127
|
+
* `null` when NO artifact answers it offline — the caller then records the
|
|
128
|
+
* documented seam-skip. A genuinely un-runnable row would require executing the
|
|
129
|
+
* SUBJECT'S OWN script, which the judge-only cell never does (EV-051) and which
|
|
130
|
+
* dispatches NO sub-agents (PR-ORCH-01). PURE + deterministic (no clock/random/
|
|
131
|
+
* network) ⇒ C-PIN byte-identical reruns.
|
|
132
|
+
*/
|
|
133
|
+
export function reexecDeterministicCheck(
|
|
134
|
+
criterion: Criterion,
|
|
135
|
+
bundle: RunBundle,
|
|
136
|
+
): RowResultValue | null {
|
|
137
|
+
const artifactKey = evidenceArtifactFor(criterion, bundle);
|
|
138
|
+
if (artifactKey == null) return null; // no live artifact → not re-executable here
|
|
139
|
+
const value = bundle.data[artifactKey];
|
|
140
|
+
// HONESTY on the depth of each re-exec (do NOT mistake these for deep checks):
|
|
141
|
+
// - typebox-schema : a real SHAPE upgrade over presence (rejects a present-but-
|
|
142
|
+
// scalar), but it is NOT a full TypeBox `Value.Check` against
|
|
143
|
+
// the produced artifact's declared schema — it asserts
|
|
144
|
+
// "structured + non-empty", not field-level conformance.
|
|
145
|
+
// - gate : `hasFailureMarker` is TOP-LEVEL-ONLY (scans the object's own
|
|
146
|
+
// pass/ok/passed/status keys) — it does NOT recurse into nested
|
|
147
|
+
// gate evidence, so a failure buried in a child object is missed.
|
|
148
|
+
// - deterministic-script : semantically ≡ the OLD presence proxy (present +
|
|
149
|
+
// non-empty). It is re-run against LIVE data (so it is a real
|
|
150
|
+
// re-execution, not a cached verdict), but carries no deeper
|
|
151
|
+
// predicate than presence. Deepening any of these to a true
|
|
152
|
+
// re-run of the subject's own script is the documented seam
|
|
153
|
+
// (out of scope for a judge-only / no-sub-agent cell).
|
|
154
|
+
switch (criterion.checkMethod) {
|
|
155
|
+
case CheckMethod.TypeboxSchema:
|
|
156
|
+
return isStructuredNonEmpty(value) ? RowResult.Pass : RowResult.Fail;
|
|
157
|
+
case CheckMethod.Gate:
|
|
158
|
+
return isNonEmpty(value) && !hasFailureMarker(value) ? RowResult.Pass : RowResult.Fail;
|
|
159
|
+
case CheckMethod.DeterministicScript:
|
|
160
|
+
default:
|
|
161
|
+
return isNonEmpty(value) ? RowResult.Pass : RowResult.Fail;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
87
165
|
export interface EvaluateOptions {
|
|
88
166
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
167
|
+
* Item #4 — the LIVE RE-EXEC executor. When supplied it RUNS the real
|
|
168
|
+
* deterministic check against live bundle data (see `reexecDeterministicCheck`,
|
|
169
|
+
* the default) and its pass/fail return is authoritative — replacing the
|
|
170
|
+
* presence-proxy that marked a row covered from mere artifact existence. A
|
|
171
|
+
* `null` return means "this executor cannot answer this row" ⇒ the legacy
|
|
172
|
+
* (byte-stable) path runs. ABSENT ⇒ no re-exec wired: fully back-compatible,
|
|
173
|
+
* byte-identical to before (the presence proxy + documented seam-skip). The
|
|
174
|
+
* executor is PURE + dispatches NO sub-agents (PR-ORCH-01) + never fixes the
|
|
175
|
+
* subject (EV-051).
|
|
93
176
|
*/
|
|
94
|
-
liveReexec?: (criterion: Criterion) => RowResultValue | null;
|
|
177
|
+
liveReexec?: (criterion: Criterion, bundle: RunBundle) => RowResultValue | null;
|
|
95
178
|
}
|
|
96
179
|
|
|
97
180
|
/**
|
|
@@ -104,21 +187,31 @@ export function evaluateDeterministic(
|
|
|
104
187
|
opts: EvaluateOptions = {},
|
|
105
188
|
): ScorecardCriterion {
|
|
106
189
|
const track = Track.Deterministic;
|
|
190
|
+
|
|
191
|
+
// Item #4 — LIVE RE-EXEC FIRST. When an executor is wired it RUNS the real
|
|
192
|
+
// deterministic check against live data; a pass/fail is authoritative and
|
|
193
|
+
// REPLACES the presence-proxy (no more "an artifact exists → covered"). A null
|
|
194
|
+
// return means the executor could not answer this row ⇒ fall through to the
|
|
195
|
+
// (byte-stable) legacy path below.
|
|
196
|
+
const live = opts.liveReexec?.(criterion, bundle) ?? null;
|
|
197
|
+
if (live === RowResult.Pass || live === RowResult.Fail || live === RowResult.Incomplete) {
|
|
198
|
+
return {
|
|
199
|
+
dimension: criterion.dimension,
|
|
200
|
+
severity: criterion.severity,
|
|
201
|
+
checkMethod: criterion.checkMethod,
|
|
202
|
+
track,
|
|
203
|
+
result: live,
|
|
204
|
+
detail: `live re-exec (${criterion.checkMethod}): real deterministic check over the produced artifact → ${live}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
107
208
|
const artifactKey = evidenceArtifactFor(criterion, bundle);
|
|
108
209
|
|
|
109
210
|
if (artifactKey == null) {
|
|
110
|
-
// No bundle artifact answers this row offline ->
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
dimension: criterion.dimension,
|
|
115
|
-
severity: criterion.severity,
|
|
116
|
-
checkMethod: criterion.checkMethod,
|
|
117
|
-
track,
|
|
118
|
-
result: live,
|
|
119
|
-
detail: "evaluated via live-reexec callback",
|
|
120
|
-
};
|
|
121
|
-
}
|
|
211
|
+
// No bundle artifact answers this row offline -> documented seam-skip. NOT
|
|
212
|
+
// silent: the coverage-honesty warning (computeCoverage) surfaces it, and
|
|
213
|
+
// fully executing it would require running the subject's OWN script — which
|
|
214
|
+
// the judge-only cell never does (EV-051) and which dispatches no sub-agents.
|
|
122
215
|
return {
|
|
123
216
|
dimension: criterion.dimension,
|
|
124
217
|
severity: criterion.severity,
|
|
@@ -130,6 +223,9 @@ export function evaluateDeterministic(
|
|
|
130
223
|
};
|
|
131
224
|
}
|
|
132
225
|
|
|
226
|
+
// LEGACY presence proxy — reached ONLY when no live-reexec executor is wired
|
|
227
|
+
// (kept for byte-identity with pre-Item-#4 callers). With the default executor
|
|
228
|
+
// wired (audit-run.ts) this branch is never taken for a mapped row.
|
|
133
229
|
const present = isNonEmpty(bundle.data[artifactKey]);
|
|
134
230
|
return {
|
|
135
231
|
dimension: criterion.dimension,
|
|
@@ -138,7 +234,7 @@ export function evaluateDeterministic(
|
|
|
138
234
|
track,
|
|
139
235
|
result: present ? RowResult.Pass : RowResult.Fail,
|
|
140
236
|
detail: present
|
|
141
|
-
? `evidence artifact '${artifactKey}' present + non-empty`
|
|
237
|
+
? `evidence artifact '${artifactKey}' present + non-empty (presence proxy — no live re-exec wired)`
|
|
142
238
|
: `evidence artifact '${artifactKey}' MISSING/empty in bundle (fail-loud)`,
|
|
143
239
|
};
|
|
144
240
|
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/sync-eval-criteria.ts — the EVAL-LEG reconcile CONTRACT (Wave-2 W2I5 · KP-003).
|
|
3
|
+
* ---------------------------------------------------------------------------
|
|
4
|
+
* The THIRD leg of the def → impl → eval triad. Today `#sync-spec` reconciles two
|
|
5
|
+
* legs — spec ↔ impl. When an implementation amends (the ⑤ IMPROVE loop's ai-engineer,
|
|
6
|
+
* or a brownfield drift), the eval criteria that GROUND the subject's evaluation can go
|
|
7
|
+
* stale w.r.t. the changed impl. This module is the EVALUATOR-SIDE CONTRACT that the
|
|
8
|
+
* reconcile (ai-architect REASONING, session-dispatched — Model-B) READS and WRITES:
|
|
9
|
+
*
|
|
10
|
+
* 1. WHICH eval leg applies — the subject-kind resolver. The eval leg's shape differs
|
|
11
|
+
* by subject kind (this is why W2I5 was gated behind W2I1's code-quality leg):
|
|
12
|
+
* - agent / skill / composite subject → the EVAL-SUITE criteria (the evaluator's
|
|
13
|
+
* discovered / maintained criteria for that subject).
|
|
14
|
+
* - code subject → the CODE-QUALITY criteria (W2I1's `DEFAULT_CODE_QUALITY_CRITERIA`
|
|
15
|
+
* / `#mode-judge-code-quality`).
|
|
16
|
+
* 2. A deterministic STALENESS FLAG predicate — the eval-leg half of the drift trigger
|
|
17
|
+
* (mirrors the builder freshness probe's spec/code compare; same 3-value semantics).
|
|
18
|
+
* 3. The monotonic APPLY primitive — the ai-architect reasons a criteria delta; this
|
|
19
|
+
* applies it as criteria MAINTENANCE: upsert-by-id, append the novel, and NEVER drop
|
|
20
|
+
* an existing criterion (EV-053, guarded by `assertMonotonicGrowth`).
|
|
21
|
+
*
|
|
22
|
+
* JUDGE-ONLY PRESERVED (EV-051): this file NEVER scores a subject and NEVER decides a
|
|
23
|
+
* subject pass/fail. It FLAGS freshness (pure epoch compare) and GROWS a criteria set
|
|
24
|
+
* (pure set algebra). The reconcile REASONING — which criteria a changed impl makes stale,
|
|
25
|
+
* what new criterion it needs — is the ai-architect's, not this code's (Model-B: code =
|
|
26
|
+
* drift predicates + deterministic maintenance only, never agent dispatch).
|
|
27
|
+
*
|
|
28
|
+
* PURE + deterministic (C-PIN): no clock / random / network; same inputs → byte-identical.
|
|
29
|
+
*/
|
|
30
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
31
|
+
import { Value } from "@sinclair/typebox/value";
|
|
32
|
+
|
|
33
|
+
import { assertMonotonicGrowth } from "./living-suite.ts";
|
|
34
|
+
|
|
35
|
+
// ── subject kind → eval leg ──────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** The subject kinds the triad reconciles. `agent|skill|composite` come from the
|
|
38
|
+
* agentspec `definition.identity.kind`; `code` is the ⑤ IMPROVE code-target subject. */
|
|
39
|
+
export const EvalSubjectKind = {
|
|
40
|
+
Agent: "agent",
|
|
41
|
+
Skill: "skill",
|
|
42
|
+
Composite: "composite",
|
|
43
|
+
Code: "code",
|
|
44
|
+
} as const;
|
|
45
|
+
export type EvalSubjectKindValue = (typeof EvalSubjectKind)[keyof typeof EvalSubjectKind];
|
|
46
|
+
|
|
47
|
+
/** Which eval leg grounds a subject's evaluation — the W2I5 core rule. */
|
|
48
|
+
export const EvalLegKind = {
|
|
49
|
+
/** agent / skill / composite → the evaluator's discovered/maintained eval-suite criteria. */
|
|
50
|
+
EvalSuite: "eval-suite",
|
|
51
|
+
/** code → W2I1's code-quality criteria (`DEFAULT_CODE_QUALITY_CRITERIA`). */
|
|
52
|
+
CodeQuality: "code-quality",
|
|
53
|
+
} as const;
|
|
54
|
+
export type EvalLegKindValue = (typeof EvalLegKind)[keyof typeof EvalLegKind];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve WHICH eval leg a subject kind reconciles against. Code subjects use the
|
|
58
|
+
* code-quality criteria (a deterministic-test-gate PLUS a quality judge); every other
|
|
59
|
+
* kind uses the discovered eval-suite criteria. This single mapping is the shape
|
|
60
|
+
* differentiator the whole triad turns on.
|
|
61
|
+
*/
|
|
62
|
+
export function evalLegForSubjectKind(kind: EvalSubjectKindValue): EvalLegKindValue {
|
|
63
|
+
return kind === EvalSubjectKind.Code ? EvalLegKind.CodeQuality : EvalLegKind.EvalSuite;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── the staleness flag predicate (eval-leg half of the drift trigger) ────────
|
|
67
|
+
|
|
68
|
+
/** The eval-leg freshness verdict — mirrors the builder freshness probe's 3-value spec/code semantics. */
|
|
69
|
+
export const EvalLegDrift = {
|
|
70
|
+
/** no eval-criteria artifact yet — the eval leg must be CONSTRUCTED (cold). */
|
|
71
|
+
MissingEval: "missing-eval",
|
|
72
|
+
/** the eval criteria are as fresh as the impl — no reconcile write needed. */
|
|
73
|
+
InSync: "in-sync",
|
|
74
|
+
/** the impl is newer than the eval criteria — the eval leg is flagged for RECONCILE. */
|
|
75
|
+
NeedsSync: "needs-sync",
|
|
76
|
+
} as const;
|
|
77
|
+
export type EvalLegDriftValue = (typeof EvalLegDrift)[keyof typeof EvalLegDrift];
|
|
78
|
+
|
|
79
|
+
/** Effective freshness epochs (seconds) the flag predicate compares. `null` = unknown/absent. */
|
|
80
|
+
export interface EvalLegFreshnessInput {
|
|
81
|
+
/** effective freshness epoch of the eval-criteria artifact; `null` = artifact absent. */
|
|
82
|
+
evalCriteriaEpoch: number | null;
|
|
83
|
+
/** effective freshness epoch of the implementation; `null` = unknown. */
|
|
84
|
+
implEpoch: number | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* FLAG whether the eval leg is stale w.r.t. the impl. Deterministic — a pure epoch
|
|
89
|
+
* compare, identical in spirit to the builder `check-sync-spec` spec/code compare so the
|
|
90
|
+
* two legs agree on "impl is newer → reconcile". This is a FLAG only: it decides nothing
|
|
91
|
+
* about the subject and proposes no criteria — the reconcile reasoning is the agent's.
|
|
92
|
+
*/
|
|
93
|
+
export function flagEvalLegDrift(input: EvalLegFreshnessInput): EvalLegDriftValue {
|
|
94
|
+
if (input.evalCriteriaEpoch == null) return EvalLegDrift.MissingEval;
|
|
95
|
+
if (input.implEpoch != null && input.implEpoch > input.evalCriteriaEpoch) {
|
|
96
|
+
return EvalLegDrift.NeedsSync;
|
|
97
|
+
}
|
|
98
|
+
return EvalLegDrift.InSync;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The resolved eval leg for a subject: which criteria kind + the current staleness flag. */
|
|
102
|
+
export interface EvalLegAssessment {
|
|
103
|
+
leg: EvalLegKindValue;
|
|
104
|
+
drift: EvalLegDriftValue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Convenience: resolve the eval leg for a subject kind AND flag its staleness in one
|
|
109
|
+
* call — the exact pair the reconcile reads ("which leg, is it stale?").
|
|
110
|
+
*/
|
|
111
|
+
export function assessEvalLeg(
|
|
112
|
+
subjectKind: EvalSubjectKindValue,
|
|
113
|
+
freshness: EvalLegFreshnessInput,
|
|
114
|
+
): EvalLegAssessment {
|
|
115
|
+
return { leg: evalLegForSubjectKind(subjectKind), drift: flagEvalLegDrift(freshness) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── the criteria representation + the monotonic reconcile-apply ──────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* One eval criterion — subject-kind-agnostic (a stable `id` key + the binary `statement`,
|
|
122
|
+
* plus the `leg` it belongs to and an optional severity). Serves BOTH legs: a code-quality
|
|
123
|
+
* `Qn` criterion (`severity` ∈ critical|high|medium) and an eval-suite discovered criterion
|
|
124
|
+
* flow through the same shape. `additionalProperties: true` keeps a richer criterion (e.g.
|
|
125
|
+
* a code-quality `passDefinition`/`failDefinition`, an eval-suite `checkMethod`) forward-parsing.
|
|
126
|
+
*/
|
|
127
|
+
export const EvalCriterionSchema = Type.Object(
|
|
128
|
+
{
|
|
129
|
+
id: Type.String({ minLength: 1 }),
|
|
130
|
+
statement: Type.String({ minLength: 1 }),
|
|
131
|
+
leg: Type.Union([Type.Literal(EvalLegKind.EvalSuite), Type.Literal(EvalLegKind.CodeQuality)]),
|
|
132
|
+
severity: Type.Optional(Type.String({ minLength: 1 })),
|
|
133
|
+
},
|
|
134
|
+
{ additionalProperties: true },
|
|
135
|
+
);
|
|
136
|
+
export type EvalCriterion = Static<typeof EvalCriterionSchema>;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The reconcile REQUEST the ai-architect emits: the persisted criteria `existing`, plus the
|
|
140
|
+
* agent-reasoned `proposed` additions/updates. `proposed` is an UPSERT set (append the novel,
|
|
141
|
+
* revise a same-id criterion's wording for the changed impl) — it is NEVER a delete list; the
|
|
142
|
+
* monotonic-growth invariant (EV-053) forbids dropping a criterion.
|
|
143
|
+
*/
|
|
144
|
+
export const EvalCriteriaReconcileRequestSchema = Type.Object(
|
|
145
|
+
{
|
|
146
|
+
subjectId: Type.String({ minLength: 1 }),
|
|
147
|
+
subjectKind: Type.Union([
|
|
148
|
+
Type.Literal(EvalSubjectKind.Agent),
|
|
149
|
+
Type.Literal(EvalSubjectKind.Skill),
|
|
150
|
+
Type.Literal(EvalSubjectKind.Composite),
|
|
151
|
+
Type.Literal(EvalSubjectKind.Code),
|
|
152
|
+
]),
|
|
153
|
+
leg: Type.Union([Type.Literal(EvalLegKind.EvalSuite), Type.Literal(EvalLegKind.CodeQuality)]),
|
|
154
|
+
existing: Type.Array(EvalCriterionSchema),
|
|
155
|
+
proposed: Type.Array(EvalCriterionSchema),
|
|
156
|
+
},
|
|
157
|
+
{ additionalProperties: false },
|
|
158
|
+
);
|
|
159
|
+
export type EvalCriteriaReconcileRequest = Static<typeof EvalCriteriaReconcileRequestSchema>;
|
|
160
|
+
|
|
161
|
+
/** Provenance for a reconcile — pure counters (NO clock, for byte-identity / C-PIN). */
|
|
162
|
+
export interface EvalCriteriaReconcileProvenance {
|
|
163
|
+
/** how many genuinely-novel criteria were appended. */
|
|
164
|
+
added: number;
|
|
165
|
+
/** how many existing criteria (same id) had their content revised. */
|
|
166
|
+
updated: number;
|
|
167
|
+
/** total criteria after the reconcile. */
|
|
168
|
+
total: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The reconcile RESULT — the grown/maintained criteria set + provenance. */
|
|
172
|
+
export interface EvalCriteriaReconcileResult {
|
|
173
|
+
subjectId: string;
|
|
174
|
+
leg: EvalLegKindValue;
|
|
175
|
+
criteria: EvalCriterion[];
|
|
176
|
+
provenance: EvalCriteriaReconcileProvenance;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Parse + narrow a reconcile request (guarded). THROWS on schema violation. PURE. */
|
|
180
|
+
export function parseEvalCriteriaReconcileRequest(value: unknown): EvalCriteriaReconcileRequest {
|
|
181
|
+
if (!Value.Check(EvalCriteriaReconcileRequestSchema, value)) {
|
|
182
|
+
const first = [...Value.Errors(EvalCriteriaReconcileRequestSchema, value)][0];
|
|
183
|
+
throw new Error(
|
|
184
|
+
`parseEvalCriteriaReconcileRequest: schema violation at '${first?.path ?? "(root)"}': ` +
|
|
185
|
+
`${first?.message ?? "invalid eval-criteria reconcile request"}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const criterionId = (c: EvalCriterion): string => c.id;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* APPLY the reconcile: maintain the eval-criteria set for a changed impl. Criteria
|
|
195
|
+
* MAINTENANCE, not judging (EV-051): upsert each `proposed` criterion by `id` (revise a
|
|
196
|
+
* same-id criterion's content in place, keeping the id), append the genuinely-novel, and
|
|
197
|
+
* assert the id-set NEVER shrank (EV-053, via the shared `assertMonotonicGrowth`). The
|
|
198
|
+
* `leg` on every proposed criterion must match the request's `leg` — mixing a code-quality
|
|
199
|
+
* criterion into an eval-suite reconcile (or vice versa) is a hard error, not a silent
|
|
200
|
+
* accept. DETERMINISTIC + PURE: existing order preserved, novel appended in proposal order;
|
|
201
|
+
* no clock / random. Same (existing, proposed) → byte-identical result.
|
|
202
|
+
*/
|
|
203
|
+
export function reconcileEvalCriteria(
|
|
204
|
+
request: EvalCriteriaReconcileRequest,
|
|
205
|
+
): EvalCriteriaReconcileResult {
|
|
206
|
+
const req = parseEvalCriteriaReconcileRequest(request);
|
|
207
|
+
|
|
208
|
+
for (const p of req.proposed) {
|
|
209
|
+
if (p.leg !== req.leg) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`reconcileEvalCriteria: proposed criterion '${p.id}' has leg '${p.leg}' but the ` +
|
|
212
|
+
`reconcile targets leg '${req.leg}'. An eval-suite and a code-quality criterion ` +
|
|
213
|
+
"are different legs — refusing to mix them (subject-kind integrity).",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Insertion-ordered upsert: existing order first (content possibly revised), novel appended.
|
|
219
|
+
const byId = new Map<string, EvalCriterion>();
|
|
220
|
+
for (const c of req.existing) byId.set(c.id, c);
|
|
221
|
+
|
|
222
|
+
let added = 0;
|
|
223
|
+
let updated = 0;
|
|
224
|
+
for (const p of req.proposed) {
|
|
225
|
+
if (byId.has(p.id)) {
|
|
226
|
+
byId.set(p.id, p); // revise content, id (and thus the key/order) retained
|
|
227
|
+
updated += 1;
|
|
228
|
+
} else {
|
|
229
|
+
byId.set(p.id, p); // novel → appended after the existing keys
|
|
230
|
+
added += 1;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const criteria = [...byId.values()];
|
|
235
|
+
// EV-053: a maintained criteria set may GROW or revise, but never DROP a criterion.
|
|
236
|
+
assertMonotonicGrowth(req.existing, criteria, criterionId);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
subjectId: req.subjectId,
|
|
240
|
+
leg: req.leg,
|
|
241
|
+
criteria,
|
|
242
|
+
provenance: { added, updated, total: criteria.length },
|
|
243
|
+
};
|
|
244
|
+
}
|