renor-engine 0.0.0-stage → 1.0.0
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/LICENSE +661 -0
- package/NOTICE +73 -0
- package/README.md +105 -2
- package/dist/BUILD.json +6 -0
- package/dist/api/apiAuth.js +43 -0
- package/dist/api/server.js +172 -0
- package/dist/buildIdentity.js +132 -0
- package/dist/cli-extractors.js +46 -0
- package/dist/cli.js +491 -0
- package/dist/config.js +65 -0
- package/dist/context/composer.js +320 -0
- package/dist/context/readHonesty.js +478 -0
- package/dist/context/rowStore.js +238 -0
- package/dist/conversation/formatter.js +81 -0
- package/dist/conversation/parser.js +189 -0
- package/dist/conversation/types.js +2 -0
- package/dist/conversation/writer.js +42 -0
- package/dist/db/client.js +147 -0
- package/dist/db/indexSalvage.js +81 -0
- package/dist/db/migrations.js +642 -0
- package/dist/db/readCache.js +109 -0
- package/dist/db/schema.js +5 -0
- package/dist/db/statements.js +89 -0
- package/dist/digests/digestCache.js +142 -0
- package/dist/digests/digests.js +597 -0
- package/dist/digests/worker.js +348 -0
- package/dist/embeddings/caching.js +337 -0
- package/dist/embeddings/coalescing.js +177 -0
- package/dist/embeddings/interactive.js +103 -0
- package/dist/embeddings/local.js +68 -0
- package/dist/embeddings/openai.js +105 -0
- package/dist/embeddings/rebuild.js +166 -0
- package/dist/embeddings/simdScan.js +258 -0
- package/dist/embeddings/space.js +21 -0
- package/dist/embeddings/types.js +14 -0
- package/dist/embeddings/vectorIndex.js +675 -0
- package/dist/embeddings/vertex.js +224 -0
- package/dist/eval/artifacts.js +42 -0
- package/dist/eval/cacheReport.js +139 -0
- package/dist/eval/comparison.js +182 -0
- package/dist/eval/context700-candidate.js +145 -0
- package/dist/eval/evalCache.js +525 -0
- package/dist/eval/hashEmbedding.js +35 -0
- package/dist/eval/longmemeval.js +56 -0
- package/dist/eval/metrics.js +181 -0
- package/dist/eval/renorbenchBaseline.js +486 -0
- package/dist/eval/renorbenchDocuments.js +134 -0
- package/dist/eval/renorbenchProduct.js +182 -0
- package/dist/eval/runLedger.js +279 -0
- package/dist/eval/runManifest.js +479 -0
- package/dist/eval/scoring.js +197 -0
- package/dist/evidence/attach.js +267 -0
- package/dist/evidence/background.js +147 -0
- package/dist/evidence/evidenceCache.js +182 -0
- package/dist/evidence/gate.js +37 -0
- package/dist/evidence/keyPool.js +85 -0
- package/dist/evidence/ollamaChat.js +84 -0
- package/dist/evidence/optin.js +165 -0
- package/dist/evidence/options.js +130 -0
- package/dist/evidence/secondPass.js +456 -0
- package/dist/extraction/classifyCapture.js +34 -0
- package/dist/extraction/decision-model.js +174 -0
- package/dist/extraction/eventExtractor.js +113 -0
- package/dist/extraction/extractionClient.js +122 -0
- package/dist/extraction/factExtractor.js +46 -0
- package/dist/extraction/factsWritePath.js +179 -0
- package/dist/extraction/preferenceExtractor.js +153 -0
- package/dist/extraction/secretGuard.js +176 -0
- package/dist/extraction/secretScanner.js +665 -0
- package/dist/extraction/typedFacts.js +224 -0
- package/dist/extraction/writePathAdjudication.js +110 -0
- package/dist/importing/apply.js +644 -0
- package/dist/importing/bundle-write.js +80 -0
- package/dist/importing/cli.js +407 -0
- package/dist/importing/existing.js +49 -0
- package/dist/importing/facts.js +123 -0
- package/dist/importing/forgetImported.js +79 -0
- package/dist/importing/hosted.js +467 -0
- package/dist/importing/index.js +29 -0
- package/dist/importing/journalLog.js +96 -0
- package/dist/importing/markdown.js +98 -0
- package/dist/importing/plan.js +206 -0
- package/dist/importing/secretScreen.js +113 -0
- package/dist/importing/serialize.js +188 -0
- package/dist/importing/source.js +463 -0
- package/dist/importing/types.js +6 -0
- package/dist/indexer/chunker.js +319 -0
- package/dist/indexer/dailyEntries.js +63 -0
- package/dist/indexer/hasher.js +10 -0
- package/dist/indexer/laneRuns.js +48 -0
- package/dist/indexer/o200k.js +481 -0
- package/dist/indexer/o200k.json +8 -0
- package/dist/indexer/outsideRoot.js +77 -0
- package/dist/indexer/parser.js +167 -0
- package/dist/indexer/pipeline.js +1168 -0
- package/dist/indexer/staleness.js +201 -0
- package/dist/indexer/tokenizer.js +77 -0
- package/dist/indexer/walker.js +184 -0
- package/dist/mcp/hive.js +88 -0
- package/dist/mcp/requestErrors.js +47 -0
- package/dist/mcp/server.js +396 -0
- package/dist/memory/agentLease.js +43 -0
- package/dist/memory/confinedWrite.js +315 -0
- package/dist/memory/controls.js +73 -0
- package/dist/memory/dailyNote.js +265 -0
- package/dist/memory/durableAppend.js +57 -0
- package/dist/memory/factConflicts.js +203 -0
- package/dist/memory/factTerms.js +321 -0
- package/dist/memory/hiveNameHeader.cjs +10 -0
- package/dist/memory/inventory.js +55 -0
- package/dist/memory/invisibleText.js +20 -0
- package/dist/memory/lifecycle.js +1756 -0
- package/dist/memory/limits.js +13 -0
- package/dist/memory/requests.js +183 -0
- package/dist/memory/sourceName.js +200 -0
- package/dist/memory/whatsNew.js +158 -0
- package/dist/memory/writer.js +175 -0
- package/dist/memory/writerConflicts.js +85 -0
- package/dist/memorymap/memoryMap.js +1568 -0
- package/dist/memorymap/replacedJournal.js +58 -0
- package/dist/memorymap/replacements.js +405 -0
- package/dist/mvp/activity.js +86 -0
- package/dist/mvp/agentMemory.js +1744 -0
- package/dist/mvp/auth.js +531 -0
- package/dist/mvp/cli.js +73 -0
- package/dist/mvp/client-store.js +462 -0
- package/dist/mvp/compileCache.js +249 -0
- package/dist/mvp/compileSeed.js +27 -0
- package/dist/mvp/config.js +288 -0
- package/dist/mvp/connectionStatus.js +80 -0
- package/dist/mvp/contextRelevance.js +413 -0
- package/dist/mvp/engine.js +1065 -0
- package/dist/mvp/exportAll.js +359 -0
- package/dist/mvp/factLanePack.js +77 -0
- package/dist/mvp/factMarkers.js +49 -0
- package/dist/mvp/factsLane.js +203 -0
- package/dist/mvp/hiddenSpans.js +585 -0
- package/dist/mvp/history.js +413 -0
- package/dist/mvp/hitDates.js +31 -0
- package/dist/mvp/hitFloor.js +36 -0
- package/dist/mvp/identity.js +18 -0
- package/dist/mvp/idle.js +113 -0
- package/dist/mvp/memoryDir.js +98 -0
- package/dist/mvp/posthogMcp.js +210 -0
- package/dist/mvp/readScope.js +104 -0
- package/dist/mvp/readUsefulness.js +222 -0
- package/dist/mvp/sanitize.js +48 -0
- package/dist/mvp/schemaRejections.js +91 -0
- package/dist/mvp/scope.js +127 -0
- package/dist/mvp/server.js +2089 -0
- package/dist/mvp/serverCard.js +29 -0
- package/dist/mvp/storedSize.js +139 -0
- package/dist/mvp/telemetry.js +441 -0
- package/dist/retrieval/adviceLines.js +195 -0
- package/dist/retrieval/adviceQuestion.js +44 -0
- package/dist/retrieval/conversationShape.js +44 -0
- package/dist/retrieval/dailyEntryTime.js +55 -0
- package/dist/retrieval/entityLookup.js +113 -0
- package/dist/retrieval/factsFirst.js +188 -0
- package/dist/retrieval/keyword.js +84 -0
- package/dist/retrieval/ledgerEventTime.js +51 -0
- package/dist/retrieval/ledgerLiveness.js +83 -0
- package/dist/retrieval/ledgerPresentation.js +161 -0
- package/dist/retrieval/noteIdentity.js +57 -0
- package/dist/retrieval/orchestrator.js +1086 -0
- package/dist/retrieval/queryHelpers.js +142 -0
- package/dist/retrieval/questionSpan.js +475 -0
- package/dist/retrieval/readerEvents.js +690 -0
- package/dist/retrieval/readerLayout.js +503 -0
- package/dist/retrieval/relativeTimeHints.js +113 -0
- package/dist/retrieval/semantic.js +67 -0
- package/dist/router/routerCache.js +106 -0
- package/dist/router/sessionRouter.js +726 -0
- package/dist/toolRefusal.js +120 -0
- package/dist/watcher/watcher.js +75 -0
- package/package.json +120 -4
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.VertexEmbeddingProvider = void 0;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
9
|
+
const types_1 = require("./types");
|
|
10
|
+
const TOKEN_MARGIN_MS = 5 * 60 * 1000;
|
|
11
|
+
/** Quota (429) and transient (500/503) answers are retried: 8 attempts, waiting 10 s, 20 s ... up to 120 s between them. */
|
|
12
|
+
const DEFAULT_MAX_ATTEMPTS = 8;
|
|
13
|
+
const defaultRetryWaitMs = (attempt) => Math.min(120_000, 5_000 * 2 ** attempt) + Math.random() * 2_000;
|
|
14
|
+
/** The endpoint is built from the location; a test points it at a local stub instead. */
|
|
15
|
+
function endpointBase(baseUrl, location) {
|
|
16
|
+
if (baseUrl === undefined)
|
|
17
|
+
return `https://${location}-aiplatform.googleapis.com`;
|
|
18
|
+
const url = new URL(baseUrl);
|
|
19
|
+
const loopback = url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]';
|
|
20
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))
|
|
21
|
+
throw new Error('Vertex baseUrl must be https, or http on loopback');
|
|
22
|
+
return url.origin;
|
|
23
|
+
}
|
|
24
|
+
/** Wait `ms`, or until `signal` aborts, which ends the call that is waiting. */
|
|
25
|
+
function sleepUnlessAborted(ms, signal) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
if (signal?.aborted)
|
|
28
|
+
return reject(signal.reason);
|
|
29
|
+
const onAbort = () => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
reject(signal.reason);
|
|
32
|
+
};
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
signal?.removeEventListener('abort', onAbort);
|
|
35
|
+
resolve();
|
|
36
|
+
}, ms);
|
|
37
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function b64url(input) {
|
|
41
|
+
return Buffer.from(input).toString('base64url');
|
|
42
|
+
}
|
|
43
|
+
class VertexEmbeddingProvider {
|
|
44
|
+
dimensions;
|
|
45
|
+
kind = 'vertex';
|
|
46
|
+
model;
|
|
47
|
+
identity;
|
|
48
|
+
project;
|
|
49
|
+
location;
|
|
50
|
+
key;
|
|
51
|
+
maxInstancesPerRequest;
|
|
52
|
+
maxInputTokens;
|
|
53
|
+
base;
|
|
54
|
+
maxAttempts;
|
|
55
|
+
retryWaitMs;
|
|
56
|
+
accessToken = null;
|
|
57
|
+
tokenExpiresAt = 0;
|
|
58
|
+
usage;
|
|
59
|
+
constructor(options) {
|
|
60
|
+
const keyFile = options?.keyFile
|
|
61
|
+
?? process.env.RENOR_EVAL_VERTEX_KEY_FILE
|
|
62
|
+
?? process.env.GOOGLE_APPLICATION_CREDENTIALS
|
|
63
|
+
?? '';
|
|
64
|
+
if (!keyFile || !node_fs_1.default.existsSync(keyFile)) {
|
|
65
|
+
throw new Error('Vertex provider requires a service-account key file (GOOGLE_APPLICATION_CREDENTIALS or RENOR_EVAL_VERTEX_KEY_FILE)');
|
|
66
|
+
}
|
|
67
|
+
const raw = JSON.parse(node_fs_1.default.readFileSync(keyFile, 'utf8'));
|
|
68
|
+
if (!raw.client_email || !raw.private_key || !raw.token_uri) {
|
|
69
|
+
throw new Error('Service-account key file must contain client_email, private_key, and token_uri');
|
|
70
|
+
}
|
|
71
|
+
this.key = raw;
|
|
72
|
+
this.project = options?.project ?? process.env.RENOR_EVAL_VERTEX_PROJECT ?? '';
|
|
73
|
+
if (!/^[a-z0-9-]+$/.test(this.project))
|
|
74
|
+
throw new Error('RENOR_EVAL_VERTEX_PROJECT is required');
|
|
75
|
+
this.location = options?.location ?? process.env.RENOR_EVAL_VERTEX_LOCATION ?? '';
|
|
76
|
+
if (!this.location)
|
|
77
|
+
throw new Error('RENOR_EVAL_VERTEX_LOCATION is required');
|
|
78
|
+
if (!/^[a-z0-9-]+$/.test(this.location))
|
|
79
|
+
throw new Error('Invalid Vertex location');
|
|
80
|
+
this.model = options?.model ?? process.env.RENOR_EVAL_MODEL ?? 'gemini-embedding-001';
|
|
81
|
+
this.identity = { kind: this.kind, model: this.model };
|
|
82
|
+
const dimensions = options?.dimensions ?? Number(process.env.RENOR_EVAL_EMBEDDING_DIMS ?? 768);
|
|
83
|
+
if (!Number.isInteger(dimensions) || ![768, 1536, 3072].includes(dimensions)) {
|
|
84
|
+
throw new Error('gemini-embedding-001 dimensions must be 768, 1536, or 3072');
|
|
85
|
+
}
|
|
86
|
+
this.dimensions = dimensions;
|
|
87
|
+
this.maxInstancesPerRequest = options?.maxInstancesPerRequest ?? 32;
|
|
88
|
+
const maxInputTokens = options?.maxInputTokens
|
|
89
|
+
?? Number(process.env.RENOR_EVAL_MAX_EMBED_TOKENS ?? 80_000_000);
|
|
90
|
+
if (!Number.isInteger(maxInputTokens) || maxInputTokens < 1_000) {
|
|
91
|
+
throw new Error('RENOR_EVAL_MAX_EMBED_TOKENS must be a positive integer >= 1000');
|
|
92
|
+
}
|
|
93
|
+
this.maxInputTokens = maxInputTokens;
|
|
94
|
+
this.base = endpointBase(options?.baseUrl, this.location);
|
|
95
|
+
this.maxAttempts = options?.retry?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
96
|
+
this.retryWaitMs = options?.retry?.waitMs ?? defaultRetryWaitMs;
|
|
97
|
+
this.usage = { requests: 0, inputTokens: 0, maxInputTokens };
|
|
98
|
+
}
|
|
99
|
+
/** OAuth2 JWT-bearer grant: SA key -> short-lived access token. */
|
|
100
|
+
async getAccessToken(signal) {
|
|
101
|
+
if (this.accessToken && Date.now() < this.tokenExpiresAt - TOKEN_MARGIN_MS)
|
|
102
|
+
return this.accessToken;
|
|
103
|
+
const now = Math.floor(Date.now() / 1000);
|
|
104
|
+
const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
105
|
+
const claims = b64url(JSON.stringify({
|
|
106
|
+
iss: this.key.client_email,
|
|
107
|
+
scope: 'https://www.googleapis.com/auth/cloud-platform',
|
|
108
|
+
aud: this.key.token_uri,
|
|
109
|
+
iat: now,
|
|
110
|
+
exp: now + 3600,
|
|
111
|
+
}));
|
|
112
|
+
const signature = b64url(node_crypto_1.default.createSign('RSA-SHA256')
|
|
113
|
+
.update(`${header}.${claims}`)
|
|
114
|
+
.sign(this.key.private_key));
|
|
115
|
+
const response = await fetch(this.key.token_uri, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
118
|
+
body: new URLSearchParams({
|
|
119
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
120
|
+
assertion: `${header}.${claims}.${signature}`,
|
|
121
|
+
}).toString(),
|
|
122
|
+
signal,
|
|
123
|
+
});
|
|
124
|
+
if (!response.ok)
|
|
125
|
+
throw new Error(`Vertex token exchange failed: ${response.status}`);
|
|
126
|
+
const data = await response.json();
|
|
127
|
+
this.accessToken = data.access_token;
|
|
128
|
+
this.tokenExpiresAt = Date.now() + data.expires_in * 1000;
|
|
129
|
+
return this.accessToken;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* `call` says how long this one may take (see EmbedOptions): `deadline` is the epoch ms by which it gives up, and a
|
|
133
|
+
* retry whose wait would end past it is not waited for, so a refusing service is reported at once rather than at the
|
|
134
|
+
* deadline. Without one the retries run their whole course.
|
|
135
|
+
*/
|
|
136
|
+
async predict(contents, call) {
|
|
137
|
+
const token = await this.getAccessToken(call.signal);
|
|
138
|
+
const url = `${this.base}/v1/projects/${this.project}/locations/${this.location}/publishers/google/models/${this.model}:predict`;
|
|
139
|
+
const body = JSON.stringify({
|
|
140
|
+
instances: contents.map(({ text, taskType }) => ({
|
|
141
|
+
content: text,
|
|
142
|
+
task_type: taskType,
|
|
143
|
+
})),
|
|
144
|
+
parameters: { outputDimensionality: this.dimensions, autoTruncate: true },
|
|
145
|
+
});
|
|
146
|
+
// Quota (429) and transient (500/503) errors are expected on new projects:
|
|
147
|
+
// back off with jitter instead of failing the run. Retries cost nothing —
|
|
148
|
+
// 429 responses are not billed.
|
|
149
|
+
const maxAttempts = this.maxAttempts;
|
|
150
|
+
let attempt = 0;
|
|
151
|
+
let response;
|
|
152
|
+
for (;;) {
|
|
153
|
+
response = await fetch(url, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
156
|
+
body,
|
|
157
|
+
signal: call.signal,
|
|
158
|
+
});
|
|
159
|
+
if (response.ok || ![429, 500, 503].includes(response.status))
|
|
160
|
+
break;
|
|
161
|
+
attempt += 1;
|
|
162
|
+
if (attempt >= maxAttempts) {
|
|
163
|
+
throw new types_1.EmbeddingUnavailableError(`Vertex predict failed after ${attempt} attempts (HTTP ${response.status})`);
|
|
164
|
+
}
|
|
165
|
+
const waitMs = this.retryWaitMs(attempt);
|
|
166
|
+
if (Date.now() + waitMs > call.deadline) {
|
|
167
|
+
throw new types_1.EmbeddingUnavailableError(`Vertex predict refused (HTTP ${response.status}); the retry is later than this call may wait`);
|
|
168
|
+
}
|
|
169
|
+
process.stderr.write(`[vertex] HTTP ${response.status} on ${contents.length} inputs; retry ${attempt}/${maxAttempts - 1} in ${Math.round(waitMs / 1000)}s\n`);
|
|
170
|
+
await sleepUnlessAborted(waitMs, call.signal);
|
|
171
|
+
}
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
const bodyText = await response.text();
|
|
174
|
+
// The status travels with the error (the message is unchanged): a 400 names an input the service refuses.
|
|
175
|
+
throw Object.assign(new Error(`Vertex predict failed (${response.status}): ${bodyText.slice(0, 400)}`), { status: response.status });
|
|
176
|
+
}
|
|
177
|
+
const data = await response.json();
|
|
178
|
+
const predictions = data.predictions ?? [];
|
|
179
|
+
if (predictions.length !== contents.length
|
|
180
|
+
|| predictions.some(p => !Array.isArray(p.embeddings?.values) || p.embeddings.values.length !== this.dimensions)) {
|
|
181
|
+
throw new Error('Vertex predict returned malformed or incomplete embeddings');
|
|
182
|
+
}
|
|
183
|
+
this.usage.requests += 1;
|
|
184
|
+
for (const p of predictions)
|
|
185
|
+
this.usage.inputTokens += p.embeddings?.statistics?.token_count ?? 0;
|
|
186
|
+
if (this.usage.inputTokens > this.maxInputTokens) {
|
|
187
|
+
throw new Error(`Embedding cost guard tripped: ${this.usage.inputTokens} input tokens exceeds the ${this.maxInputTokens} cap (set RENOR_EVAL_MAX_EMBED_TOKENS to raise it)`);
|
|
188
|
+
}
|
|
189
|
+
return predictions.map(p => p.embeddings.values);
|
|
190
|
+
}
|
|
191
|
+
async embedAll(texts, taskType, options) {
|
|
192
|
+
if (texts.length === 0)
|
|
193
|
+
return [];
|
|
194
|
+
const call = {
|
|
195
|
+
deadline: options?.budgetMs === undefined ? Infinity : Date.now() + options.budgetMs,
|
|
196
|
+
signal: options?.signal,
|
|
197
|
+
};
|
|
198
|
+
const out = [];
|
|
199
|
+
for (let i = 0; i < texts.length; i += this.maxInstancesPerRequest) {
|
|
200
|
+
const batch = texts.slice(i, i + this.maxInstancesPerRequest).map(text => ({ text, taskType }));
|
|
201
|
+
out.push(...await this.predict(batch, call));
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
async embed(texts, options) {
|
|
206
|
+
return this.embedAll(texts, 'RETRIEVAL_DOCUMENT', options);
|
|
207
|
+
}
|
|
208
|
+
async embedQuery(text, options) {
|
|
209
|
+
const [vector] = await this.embedAll([text], 'RETRIEVAL_QUERY', options);
|
|
210
|
+
return vector;
|
|
211
|
+
}
|
|
212
|
+
describe() {
|
|
213
|
+
// Credentials and project IDs never leave the process through logs.
|
|
214
|
+
return {
|
|
215
|
+
kind: 'vertex',
|
|
216
|
+
model: this.model,
|
|
217
|
+
dimensions: this.dimensions,
|
|
218
|
+
location: this.location,
|
|
219
|
+
taskTypes: { document: 'RETRIEVAL_DOCUMENT', query: 'RETRIEVAL_QUERY' },
|
|
220
|
+
usage: { ...this.usage },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
exports.VertexEmbeddingProvider = VertexEmbeddingProvider;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.sha256 = sha256;
|
|
7
|
+
exports.claimDirectory = claimDirectory;
|
|
8
|
+
exports.assertRegularTree = assertRegularTree;
|
|
9
|
+
exports.safeEndpoint = safeEndpoint;
|
|
10
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
13
|
+
/**
|
|
14
|
+
* Measurement-side helpers shared by the eval runners. Evaluator provenance
|
|
15
|
+
* (source hash, git state) lives in src/eval/runManifest.ts — the single
|
|
16
|
+
* authority — so only artifact-safety utilities live here.
|
|
17
|
+
*/
|
|
18
|
+
function sha256(data) {
|
|
19
|
+
return node_crypto_1.default.createHash('sha256').update(data).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
/** Exclusive claim: never overwrite another worker's run, even after failure. */
|
|
22
|
+
function claimDirectory(directory) {
|
|
23
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(directory), { recursive: true });
|
|
24
|
+
node_fs_1.default.mkdirSync(directory, { mode: 0o700 });
|
|
25
|
+
}
|
|
26
|
+
/** Fixtures must consist only of regular files/directories. No external links. */
|
|
27
|
+
function assertRegularTree(root) {
|
|
28
|
+
const stat = node_fs_1.default.lstatSync(root);
|
|
29
|
+
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
|
|
30
|
+
throw new Error(`Non-regular fixture entry: ${root}`);
|
|
31
|
+
}
|
|
32
|
+
if (stat.isDirectory())
|
|
33
|
+
for (const child of node_fs_1.default.readdirSync(root))
|
|
34
|
+
assertRegularTree(node_path_1.default.join(root, child));
|
|
35
|
+
}
|
|
36
|
+
function safeEndpoint(value) {
|
|
37
|
+
const url = new URL(value);
|
|
38
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
39
|
+
throw new Error('Embedding base URL must be HTTP(S) without credentials, query, or fragment');
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.PER_RUN_EVIDENCE_BLOCKER = exports.UNCACHED_MODEL_BLOCKER = exports.UNCACHED_EMBEDDING_BLOCKER = exports.UNPINNED_SCOPE_BLOCKER = void 0;
|
|
7
|
+
exports.cacheHitRate = cacheHitRate;
|
|
8
|
+
exports.buildCacheReport = buildCacheReport;
|
|
9
|
+
exports.formatCacheReport = formatCacheReport;
|
|
10
|
+
exports.emitCacheReport = emitCacheReport;
|
|
11
|
+
exports.readProxyCounters = readProxyCounters;
|
|
12
|
+
exports.proxyWindow = proxyWindow;
|
|
13
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
14
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
15
|
+
function cacheHitRate(counts) {
|
|
16
|
+
const lookups = counts.hits + counts.misses;
|
|
17
|
+
return lookups === 0 ? null : counts.hits / lookups;
|
|
18
|
+
}
|
|
19
|
+
const rate = (counts) => ({ hits: counts.hits, misses: counts.misses, hitRate: cacheHitRate(counts) });
|
|
20
|
+
exports.UNPINNED_SCOPE_BLOCKER = "answer/judge keys are scoped to this process's routing session; pin RENOR_QA_CACHE_SCOPE (and share --cache-dir) to replay across runs";
|
|
21
|
+
exports.UNCACHED_EMBEDDING_BLOCKER = 'embedding calls ran with the cache off (RENOR_EVAL_EMBED_CACHE=off): turn it on to replay them';
|
|
22
|
+
exports.UNCACHED_MODEL_BLOCKER = 'answer/judge calls ran with the eval cache off (--no-cache or RENOR_EVAL_CACHE=off): turn it on to replay them';
|
|
23
|
+
exports.PER_RUN_EVIDENCE_BLOCKER = "the evidence cache lives in this run's output directory; set RENOR_EVIDENCE_CACHE to a shared path to replay across runs";
|
|
24
|
+
function buildCacheReport(input) {
|
|
25
|
+
const report = { liveProviderCalls: 0, healthProbeCalls: input.healthProbes ?? 0, repeatRunBlockers: [] };
|
|
26
|
+
const uncached = {};
|
|
27
|
+
if (input.embedding) {
|
|
28
|
+
const { sent } = input.embedding;
|
|
29
|
+
const calls = sent ? sent.calls : input.embedding.misses;
|
|
30
|
+
report.liveProviderCalls += calls;
|
|
31
|
+
if (sent)
|
|
32
|
+
report.embeddingSent = { texts: sent.texts, calls: sent.calls };
|
|
33
|
+
if (input.embedding.cached === false) {
|
|
34
|
+
if (calls > 0) {
|
|
35
|
+
uncached.embedding = calls;
|
|
36
|
+
report.repeatRunBlockers.push(exports.UNCACHED_EMBEDDING_BLOCKER);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
report.embedding = rate(input.embedding);
|
|
41
|
+
if (!sent)
|
|
42
|
+
report.embeddingCallsUpperBound = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (input.evidence) {
|
|
46
|
+
report.evidence = rate(input.evidence);
|
|
47
|
+
report.liveProviderCalls += input.evidence.misses;
|
|
48
|
+
if (!input.evidenceCacheShared && input.evidence.hits + input.evidence.misses > 0)
|
|
49
|
+
report.repeatRunBlockers.push(exports.PER_RUN_EVIDENCE_BLOCKER);
|
|
50
|
+
}
|
|
51
|
+
if (input.evalCache) {
|
|
52
|
+
const { answerHits, answerMisses, judgeHits, judgeMisses } = input.evalCache;
|
|
53
|
+
report.answer = rate({ hits: answerHits, misses: answerMisses });
|
|
54
|
+
report.judge = rate({ hits: judgeHits, misses: judgeMisses });
|
|
55
|
+
if (!input.pinnedScope && answerHits + answerMisses + judgeHits + judgeMisses > 0)
|
|
56
|
+
report.repeatRunBlockers.push(exports.UNPINNED_SCOPE_BLOCKER);
|
|
57
|
+
}
|
|
58
|
+
const modelCalls = input.liveModelCalls ?? (input.evalCache ? input.evalCache.answerMisses + input.evalCache.judgeMisses : 0);
|
|
59
|
+
report.liveProviderCalls += modelCalls;
|
|
60
|
+
if (!input.evalCache && modelCalls > 0) {
|
|
61
|
+
uncached.model = modelCalls;
|
|
62
|
+
report.repeatRunBlockers.push(exports.UNCACHED_MODEL_BLOCKER);
|
|
63
|
+
}
|
|
64
|
+
if (Object.keys(uncached).length > 0)
|
|
65
|
+
report.uncached = uncached;
|
|
66
|
+
return report;
|
|
67
|
+
}
|
|
68
|
+
const part = (label, r) => r ? `${label} ${r.hits}/${r.hits + r.misses} (${r.hitRate === null ? 'n/a' : `${(r.hitRate * 100).toFixed(1)}%`})` : null;
|
|
69
|
+
function formatCacheReport(report) {
|
|
70
|
+
const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
71
|
+
const sent = report.embeddingSent
|
|
72
|
+
? `, ${count(report.embeddingSent.texts, 'text')} in ${count(report.embeddingSent.calls, 'call')}`
|
|
73
|
+
: report.embeddingCallsUpperBound ? ', calls counted as lookup misses (upper bound)' : '';
|
|
74
|
+
const layers = [
|
|
75
|
+
report.embedding ? `${part('embedding', report.embedding)}${sent}` : null,
|
|
76
|
+
report.uncached?.embedding ? `embedding uncached${sent}` : null,
|
|
77
|
+
part('evidence', report.evidence),
|
|
78
|
+
part('answer', report.answer),
|
|
79
|
+
part('judge', report.judge),
|
|
80
|
+
report.uncached?.model ? `answer/judge uncached ${report.uncached.model}` : null,
|
|
81
|
+
].filter(Boolean);
|
|
82
|
+
const probes = report.healthProbeCalls > 0 ? ` (+${report.healthProbeCalls} health probes, never cached)` : '';
|
|
83
|
+
const lines = [`[cache] ${layers.length > 0 ? layers.join(' | ') : 'no caches in use'} | live provider calls ${report.liveProviderCalls}${probes}`];
|
|
84
|
+
for (const blocker of report.repeatRunBlockers)
|
|
85
|
+
lines.push(`[cache] a repeat run will miss: ${blocker}`);
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
}
|
|
88
|
+
/** Print the report to stderr and, given an output dir, keep it beside the run as cache-report.json. */
|
|
89
|
+
function emitCacheReport(report, outputDir) {
|
|
90
|
+
process.stderr.write(`${formatCacheReport(report)}\n`);
|
|
91
|
+
if (outputDir)
|
|
92
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(outputDir, 'cache-report.json'), JSON.stringify(report, null, 2) + '\n', 'utf8');
|
|
93
|
+
}
|
|
94
|
+
/** Counters from an embed-proxy stats file, or null when it is absent or unreadable. */
|
|
95
|
+
function readProxyCounters(statsPath) {
|
|
96
|
+
if (!statsPath || !node_fs_1.default.existsSync(statsPath))
|
|
97
|
+
return null;
|
|
98
|
+
try {
|
|
99
|
+
const raw = JSON.parse(node_fs_1.default.readFileSync(statsPath, 'utf8'));
|
|
100
|
+
if (typeof raw.cacheHits !== 'number' || typeof raw.cacheMisses !== 'number')
|
|
101
|
+
return null;
|
|
102
|
+
const counters = { startedAt: raw.startedAt, cacheHits: raw.cacheHits, cacheMisses: raw.cacheMisses };
|
|
103
|
+
if (typeof raw.upstreamTexts === 'number' && typeof raw.upstreamCalls === 'number') {
|
|
104
|
+
counters.upstreamTexts = raw.upstreamTexts;
|
|
105
|
+
counters.upstreamCalls = raw.upstreamCalls;
|
|
106
|
+
}
|
|
107
|
+
return counters;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The embed-proxy is a long-lived service shared by every run (and shard), and
|
|
115
|
+
* its stats file holds lifetime counters, so one run's embedding lookups (and what
|
|
116
|
+
* the proxy sent upstream for them) are the change in those counters between its
|
|
117
|
+
* start and its end. That window also counts any other client of the same proxy.
|
|
118
|
+
* Unknown (undefined) when the proxy restarted in between, because its counters
|
|
119
|
+
* were reset.
|
|
120
|
+
*/
|
|
121
|
+
function proxyWindow(before, after) {
|
|
122
|
+
if (!after)
|
|
123
|
+
return undefined;
|
|
124
|
+
if (before && before.startedAt !== after.startedAt)
|
|
125
|
+
return undefined;
|
|
126
|
+
const window = {
|
|
127
|
+
hits: after.cacheHits - (before?.cacheHits ?? 0),
|
|
128
|
+
misses: after.cacheMisses - (before?.cacheMisses ?? 0),
|
|
129
|
+
};
|
|
130
|
+
// The sent figures are known only when the proxy counted them on both ends of the window; otherwise unknown, never zero.
|
|
131
|
+
if (after.upstreamTexts !== undefined && after.upstreamCalls !== undefined) {
|
|
132
|
+
if (!before)
|
|
133
|
+
window.sent = { texts: after.upstreamTexts, calls: after.upstreamCalls };
|
|
134
|
+
else if (before.upstreamTexts !== undefined && before.upstreamCalls !== undefined) {
|
|
135
|
+
window.sent = { texts: after.upstreamTexts - before.upstreamTexts, calls: after.upstreamCalls - before.upstreamCalls };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return window;
|
|
139
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Pure comparison policy between two eval runs. Extracted from the CLI
|
|
4
|
+
* wrapper so the promotion logic itself lives in the frozen evaluator
|
|
5
|
+
* trust domain (its hash is recorded in run manifests) and is unit tested.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.QUALITY_METRICS = void 0;
|
|
9
|
+
exports.compareRuns = compareRuns;
|
|
10
|
+
exports.QUALITY_METRICS = [
|
|
11
|
+
{ key: 'hitAt1', higherBetter: true },
|
|
12
|
+
{ key: 'hitAt5', higherBetter: true },
|
|
13
|
+
{ key: 'recallAt5', higherBetter: true },
|
|
14
|
+
{ key: 'recallAllAt10', higherBetter: true },
|
|
15
|
+
{ key: 'mrr', higherBetter: true },
|
|
16
|
+
{ key: 'ndcgAt5', higherBetter: true },
|
|
17
|
+
{ key: 'contradictionFirstRate', higherBetter: false },
|
|
18
|
+
{ key: 'forbiddenRetrievalRate', higherBetter: false },
|
|
19
|
+
{ key: 'abstentionEmptyRate', higherBetter: true },
|
|
20
|
+
];
|
|
21
|
+
const EFFICIENCY_METRICS = ['meanLatencyMs', 'p50LatencyMs', 'p95LatencyMs', 'meanContextTokens'];
|
|
22
|
+
const MATERIAL_EFFICIENCY_METRICS = ['p95LatencyMs', 'meanContextTokens'];
|
|
23
|
+
function same(a, b) {
|
|
24
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
25
|
+
}
|
|
26
|
+
function num(map, key) {
|
|
27
|
+
const value = map[key];
|
|
28
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
29
|
+
}
|
|
30
|
+
function delta(candidate, baseline) {
|
|
31
|
+
return candidate === null || baseline === null ? null : Math.round((candidate - baseline) * 10000) / 10000;
|
|
32
|
+
}
|
|
33
|
+
/** Relative improvement of candidate over baseline; null when not computable. */
|
|
34
|
+
function relativeGain(baseline, candidate) {
|
|
35
|
+
if (baseline === null || candidate === null || baseline <= 0)
|
|
36
|
+
return null;
|
|
37
|
+
return (baseline - candidate) / baseline;
|
|
38
|
+
}
|
|
39
|
+
function compareRuns(baseline, candidate, options = {}) {
|
|
40
|
+
const tolerance = options.maxQualityRegression ?? 0;
|
|
41
|
+
const metricList = options.metrics ?? exports.QUALITY_METRICS;
|
|
42
|
+
const minEfficiencyGain = options.minEfficiencyGain ?? 0.05;
|
|
43
|
+
if (!Number.isFinite(tolerance) || tolerance < 0 || tolerance > 1) {
|
|
44
|
+
throw new Error('maxQualityRegression must be between 0 and 1');
|
|
45
|
+
}
|
|
46
|
+
if (!Number.isFinite(minEfficiencyGain) || minEfficiencyGain < 0 || minEfficiencyGain > 1) {
|
|
47
|
+
throw new Error('minEfficiencyGain must be between 0 and 1');
|
|
48
|
+
}
|
|
49
|
+
const invariantChecks = {
|
|
50
|
+
benchmark: same(baseline.manifest.benchmark, candidate.manifest.benchmark),
|
|
51
|
+
cases: same(baseline.manifest.casesSha256, candidate.manifest.casesSha256),
|
|
52
|
+
fixture: same(baseline.manifest.fixtureSha256, candidate.manifest.fixtureSha256),
|
|
53
|
+
topK: same(baseline.manifest.topK, candidate.manifest.topK),
|
|
54
|
+
provider: same(baseline.manifest.provider, candidate.manifest.provider),
|
|
55
|
+
chunking: same(baseline.manifest.chunking, candidate.manifest.chunking),
|
|
56
|
+
evaluator: same(baseline.manifest.evaluatorSha256, candidate.manifest.evaluatorSha256),
|
|
57
|
+
cleanTree: baseline.manifest.gitDirty === false && candidate.manifest.gitDirty === false,
|
|
58
|
+
qualityEligible: baseline.manifest.qualityEligible === true && candidate.manifest.qualityEligible === true,
|
|
59
|
+
};
|
|
60
|
+
const reasons = [];
|
|
61
|
+
const datasetCompatible = invariantChecks.benchmark && invariantChecks.cases && invariantChecks.fixture;
|
|
62
|
+
if (!datasetCompatible) {
|
|
63
|
+
throw new Error(`Cannot compare runs from different benchmark data: ${JSON.stringify(invariantChecks)}`);
|
|
64
|
+
}
|
|
65
|
+
if (!invariantChecks.evaluator) {
|
|
66
|
+
if (!options.allowEvaluatorChange) {
|
|
67
|
+
throw new Error('Evaluator changed between baseline and candidate (evaluatorSha256 mismatch). ' +
|
|
68
|
+
'The candidate cannot grade its own exam: re-run the candidate against the frozen evaluator, ' +
|
|
69
|
+
'or pass --allow-evaluator-change to accept a DESCRIPTIVE_ONLY comparison during evaluator development.');
|
|
70
|
+
}
|
|
71
|
+
reasons.push('Evaluator code changed between runs; measurement meaning differs.');
|
|
72
|
+
}
|
|
73
|
+
if (!invariantChecks.cleanTree) {
|
|
74
|
+
if (!options.allowDirtyTree) {
|
|
75
|
+
reasons.push('Working tree was dirty in at least one run; executed code does not match the recorded git SHA.');
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
reasons.push('Dirty working tree tolerated (--allow-dirty-tree).');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!invariantChecks.qualityEligible) {
|
|
82
|
+
reasons.push('At least one run used a non-quality provider (e.g. hash embeddings); infrastructure evidence only.');
|
|
83
|
+
}
|
|
84
|
+
const configCompatible = invariantChecks.topK && invariantChecks.provider && invariantChecks.chunking;
|
|
85
|
+
if (!configCompatible) {
|
|
86
|
+
reasons.push('Provider, topK, or chunking changed between runs.');
|
|
87
|
+
}
|
|
88
|
+
const regressionGate = options.regressionGate === true;
|
|
89
|
+
const experimentCompatible = Boolean(invariantChecks.evaluator
|
|
90
|
+
&& (regressionGate || (invariantChecks.cleanTree && invariantChecks.qualityEligible))
|
|
91
|
+
&& (configCompatible || (!regressionGate && options.allowConfigChange)));
|
|
92
|
+
const quality = Object.fromEntries(metricList.map(metric => {
|
|
93
|
+
const baselineValue = num(baseline.summary.overall, metric.key);
|
|
94
|
+
const candidateValue = num(candidate.summary.overall, metric.key);
|
|
95
|
+
return [metric.key, { baseline: baselineValue, candidate: candidateValue, delta: delta(candidateValue, baselineValue) }];
|
|
96
|
+
}));
|
|
97
|
+
const efficiency = Object.fromEntries(EFFICIENCY_METRICS.map(key => {
|
|
98
|
+
const baselineValue = num(baseline.summary.overall, key);
|
|
99
|
+
const candidateValue = num(candidate.summary.overall, key);
|
|
100
|
+
return [key, { baseline: baselineValue, candidate: candidateValue, delta: delta(candidateValue, baselineValue) }];
|
|
101
|
+
}));
|
|
102
|
+
let verdict;
|
|
103
|
+
if (!experimentCompatible) {
|
|
104
|
+
verdict = 'DESCRIPTIVE_ONLY';
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const hasRegression = metricList.some(metric => {
|
|
108
|
+
const { delta: d } = quality[metric.key];
|
|
109
|
+
if (typeof d !== 'number')
|
|
110
|
+
return false;
|
|
111
|
+
return metric.higherBetter ? d < -tolerance : d > tolerance;
|
|
112
|
+
});
|
|
113
|
+
const hasQualityGain = metricList.some(metric => {
|
|
114
|
+
const { delta: d } = quality[metric.key];
|
|
115
|
+
if (typeof d !== 'number')
|
|
116
|
+
return false;
|
|
117
|
+
return metric.higherBetter ? d > tolerance : d < -tolerance;
|
|
118
|
+
});
|
|
119
|
+
const hasMaterialEfficiencyGain = MATERIAL_EFFICIENCY_METRICS.some(key => {
|
|
120
|
+
const gain = relativeGain(efficiency[key].baseline, efficiency[key].candidate);
|
|
121
|
+
return gain !== null && gain >= minEfficiencyGain;
|
|
122
|
+
});
|
|
123
|
+
if (hasRegression)
|
|
124
|
+
verdict = 'REGRESSED';
|
|
125
|
+
else if (hasQualityGain || hasMaterialEfficiencyGain)
|
|
126
|
+
verdict = 'IMPROVED';
|
|
127
|
+
else
|
|
128
|
+
verdict = 'NEUTRAL';
|
|
129
|
+
}
|
|
130
|
+
// Category-collapse guard: an aggregate improvement must not hide one
|
|
131
|
+
// category regressing beyond tolerance (independent-review finding).
|
|
132
|
+
const categoryRegressions = [];
|
|
133
|
+
const baselineCategories = baseline.summary.byCategory ?? {};
|
|
134
|
+
const candidateCategories = candidate.summary.byCategory ?? {};
|
|
135
|
+
// Both sides' categories: a category the baseline has and the candidate lost is a regression too.
|
|
136
|
+
for (const name of new Set([...Object.keys(baselineCategories), ...Object.keys(candidateCategories)])) {
|
|
137
|
+
const baselineMetrics = baselineCategories[name];
|
|
138
|
+
const metrics = candidateCategories[name];
|
|
139
|
+
if (!baselineMetrics)
|
|
140
|
+
continue; // new category: nothing to regress against
|
|
141
|
+
if (!metrics) {
|
|
142
|
+
categoryRegressions.push(`${name}.missing`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
for (const metric of metricList) {
|
|
146
|
+
const b = num(baselineMetrics, metric.key);
|
|
147
|
+
const c = num(metrics, metric.key);
|
|
148
|
+
if (b === null || c === null)
|
|
149
|
+
continue;
|
|
150
|
+
const d = delta(c, b);
|
|
151
|
+
if (typeof d === 'number' && (metric.higherBetter ? d < -tolerance : d > tolerance)) {
|
|
152
|
+
categoryRegressions.push(`${name}.${metric.key}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const name of categoryRegressions) {
|
|
157
|
+
reasons.push(`Category regression beyond tolerance: ${name}`);
|
|
158
|
+
}
|
|
159
|
+
if (categoryRegressions.length > 0) {
|
|
160
|
+
// A category regression beyond tolerance is a rejection regardless of the
|
|
161
|
+
// aggregate verdict: an overall gain must not hide a collapsing category.
|
|
162
|
+
verdict = 'REGRESSED';
|
|
163
|
+
}
|
|
164
|
+
const note = regressionGate
|
|
165
|
+
? 'Regression gate against a committed baseline: this is a no-regression check, never a clean ablation or a promotion.'
|
|
166
|
+
: experimentCompatible
|
|
167
|
+
? 'IMPROVED requires no quality regression beyond tolerance and a quality gain or a material (>= threshold) efficiency gain.'
|
|
168
|
+
: 'Not a clean ablation: this comparison is descriptive only and cannot support promotion.';
|
|
169
|
+
return {
|
|
170
|
+
verdict,
|
|
171
|
+
cleanAblation: experimentCompatible && !regressionGate,
|
|
172
|
+
regressionGate,
|
|
173
|
+
invariantChecks,
|
|
174
|
+
tolerance,
|
|
175
|
+
minEfficiencyGain,
|
|
176
|
+
quality,
|
|
177
|
+
efficiency,
|
|
178
|
+
categoryRegressions,
|
|
179
|
+
reasons,
|
|
180
|
+
note,
|
|
181
|
+
};
|
|
182
|
+
}
|