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,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PRODUCT_V2_QUALITY_METRICS = exports.PRODUCT_QUALITY_METRICS = exports.HASH_RELEVANCE_BAR = exports.PRODUCT_SUITES = exports.PRODUCT_V2_BENCHMARK = exports.PRODUCT_BENCHMARK = void 0;
|
|
4
|
+
exports.scoreProductArm = scoreProductArm;
|
|
5
|
+
const metrics_1 = require("./metrics");
|
|
6
|
+
const scoring_1 = require("./scoring");
|
|
7
|
+
const renorbenchDocuments_1 = require("./renorbenchDocuments");
|
|
8
|
+
/**
|
|
9
|
+
* The product arm of RenorBench: the same questions as the chunk arm, answered through the shipped `search_memory`
|
|
10
|
+
* tool, scored on the reply an agent actually receives. Nothing here needs a model: the gold is the case's own notes,
|
|
11
|
+
* the verdict is the reply's own `relevance` object, and the cost is the reply's own token count.
|
|
12
|
+
*
|
|
13
|
+
* What it measures that the chunk arm cannot (the chunk arm scores `retrieveContext` hits at top-K 10 and never sees
|
|
14
|
+
* the tool): the reply as a whole (the window, the session-expansion statements and the facts the tool adds, at the
|
|
15
|
+
* tool's own top_k), the order the tool presents it in, the `relevance` verdict, and what the reply costs in tokens.
|
|
16
|
+
*
|
|
17
|
+
* Rank-based scores (hit@1, MRR, nDCG) are deliberately absent: the tool's default order is newest first, and it tells
|
|
18
|
+
* the agent to judge a hit by its score, never its position, so a rank score would measure dates. The delivery scores
|
|
19
|
+
* count what is in the reply. Order matters only where it can mislead: `contradictionFirstRate`.
|
|
20
|
+
*/
|
|
21
|
+
exports.PRODUCT_BENCHMARK = 'renorbench-v1-product';
|
|
22
|
+
exports.PRODUCT_V2_BENCHMARK = 'renorbench-v2-product';
|
|
23
|
+
/**
|
|
24
|
+
* What the product arm can run: the questions, the vault they are asked of, and the benchmark id that its runs and its
|
|
25
|
+
* baseline carry. v1 is the original (191 questions of a 30-note vault) and stays as it was, so its scores stay comparable
|
|
26
|
+
* with every run recorded before v2 existed. v2 (scripts/eval/generate-renorbench-v2.ts) is a larger vault of chats and
|
|
27
|
+
* logs with questions that need session expansion, the newest-value rules and the verdict, which v1 is too small to show.
|
|
28
|
+
*/
|
|
29
|
+
exports.PRODUCT_SUITES = {
|
|
30
|
+
v1: { benchmark: exports.PRODUCT_BENCHMARK, cases: 'docs/evals/renorbench-v1.dev.jsonl', fixture: 'fixtures/eval/renorbench-v1' },
|
|
31
|
+
v2: { benchmark: exports.PRODUCT_V2_BENCHMARK, cases: 'docs/evals/renorbench-v2.dev.jsonl', fixture: 'fixtures/eval/renorbench-v2' },
|
|
32
|
+
// Forty handbook-style notes (scripts/eval/generate-renorbench-docs.ts), read by search_memory and by get_context at 800
|
|
33
|
+
// tokens, and scored by delivery alone (src/eval/renorbenchDocuments.ts): what the other suites cannot see of a document.
|
|
34
|
+
documents: { benchmark: renorbenchDocuments_1.DOCUMENTS_BENCHMARK, cases: 'docs/evals/renorbench-docs.dev.jsonl', fixture: 'fixtures/eval/renorbench-docs' },
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The verdict's bar for the hash embedder. The product judges "does anything on file match?" by the cosine of the best
|
|
38
|
+
* hit against a bar calibrated per embedding model (CALIBRATED_BARS), and the hash stand-in has no calibration, so it
|
|
39
|
+
* gets no verdict at all unless a bar is set (RENOR_CONTEXT_RELEVANCE_MIN, the product's own setting for a model with
|
|
40
|
+
* no calibrated bar). This is that setting for the stand-in. It is a constant of this harness, not a property of any
|
|
41
|
+
* model: the midpoint of the median best-hit cosine of the answerable cases (0.407) and of the abstention cases
|
|
42
|
+
* (0.3425) on this fixture, rounded, so that both kinds of case sit at a mixed operating point where a change to the
|
|
43
|
+
* verdict can move them. Lexical vectors cannot separate the two kinds of case (at best about two thirds are judged
|
|
44
|
+
* right), so under this bar the verdict scores say that the verdict still works, never how well a real model judges:
|
|
45
|
+
* a real embedder runs on its calibrated bar and never on this one.
|
|
46
|
+
*/
|
|
47
|
+
exports.HASH_RELEVANCE_BAR = 0.375;
|
|
48
|
+
/**
|
|
49
|
+
* What the gate holds the product arm to, overall and per category (any one falling fails it). Delivery and the
|
|
50
|
+
* verdict only ever go up, erasure and a stale value read first only ever go down, and so does what a reply costs:
|
|
51
|
+
* cost is part of quality, so a reply that grows is a regression the PR must defend, not a number that drifts.
|
|
52
|
+
*/
|
|
53
|
+
exports.PRODUCT_QUALITY_METRICS = [
|
|
54
|
+
{ key: 'replyRecall', higherBetter: true },
|
|
55
|
+
{ key: 'replyRecallAll', higherBetter: true },
|
|
56
|
+
{ key: 'contradictionFirstRate', higherBetter: false },
|
|
57
|
+
{ key: 'forbiddenInReplyRate', higherBetter: false },
|
|
58
|
+
{ key: 'verdictRelevantRate', higherBetter: true },
|
|
59
|
+
{ key: 'verdictAbstainRate', higherBetter: true },
|
|
60
|
+
{ key: 'meanReplyTokens', higherBetter: false },
|
|
61
|
+
];
|
|
62
|
+
/**
|
|
63
|
+
* What the gate holds the v2 suite to: the same, but for `forbiddenInReplyRate`. v2 has no erasure case, so that rate has
|
|
64
|
+
* nothing to count (it reads null), and a null is not a number a floor can be held to.
|
|
65
|
+
*/
|
|
66
|
+
exports.PRODUCT_V2_QUALITY_METRICS = exports.PRODUCT_QUALITY_METRICS.filter(metric => metric.key !== 'forbiddenInReplyRate');
|
|
67
|
+
function emptyAggregate() {
|
|
68
|
+
return {
|
|
69
|
+
count: 0, answerable: 0, abstention: 0, hit: 0, recall: 0, recallAll: 0,
|
|
70
|
+
supersessionSensitive: 0, contradictionFirst: 0, forbiddenSensitive: 0, forbiddenInReply: 0,
|
|
71
|
+
verdictRelevant: 0, verdictAbstain: 0, verdictMissing: 0, latencyMs: [], replyTokens: [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function round(value) {
|
|
75
|
+
return Math.round(value * 10000) / 10000;
|
|
76
|
+
}
|
|
77
|
+
function mean(values) {
|
|
78
|
+
return values.length === 0 ? null : values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
79
|
+
}
|
|
80
|
+
function percentile(values, p) {
|
|
81
|
+
if (values.length === 0)
|
|
82
|
+
return null;
|
|
83
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
84
|
+
return sorted[Math.max(0, Math.min(sorted.length - 1, Math.ceil(p * sorted.length) - 1))];
|
|
85
|
+
}
|
|
86
|
+
function rounded(value) {
|
|
87
|
+
return value === null ? null : round(value);
|
|
88
|
+
}
|
|
89
|
+
function addCase(agg, test, result) {
|
|
90
|
+
agg.count += 1;
|
|
91
|
+
// The whole reply is read, so "in the reply" is every hit it carries.
|
|
92
|
+
const reply = result.hits.length;
|
|
93
|
+
if (test.forbiddenEvidence && test.forbiddenEvidence.length > 0) {
|
|
94
|
+
agg.forbiddenSensitive += 1;
|
|
95
|
+
if ((0, metrics_1.forbiddenRetrieved)(result.hits, test.forbiddenEvidence, reply))
|
|
96
|
+
agg.forbiddenInReply += 1;
|
|
97
|
+
}
|
|
98
|
+
// A reply with no verdict is a wrong verdict for either kind of case: the product could not say, and the agent
|
|
99
|
+
// cannot tell "the answer is on file" from "cannot judge" (see contextRelevance.ts).
|
|
100
|
+
if (result.verdict === null)
|
|
101
|
+
agg.verdictMissing += 1;
|
|
102
|
+
if (Boolean(test.shouldAbstain) || test.relevant.length === 0) {
|
|
103
|
+
agg.abstention += 1;
|
|
104
|
+
if (result.verdict?.relevant === false)
|
|
105
|
+
agg.verdictAbstain += 1;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
agg.answerable += 1;
|
|
109
|
+
if (result.verdict?.relevant === true)
|
|
110
|
+
agg.verdictRelevant += 1;
|
|
111
|
+
agg.hit += (0, metrics_1.hitAtK)(result.hits, test.relevant, reply);
|
|
112
|
+
agg.recall += (0, metrics_1.recallAtK)(result.hits, test.relevant, reply);
|
|
113
|
+
const required = test.requiredEvidence ?? test.relevant.filter(target => (0, metrics_1.roleOf)(target) === 'positive');
|
|
114
|
+
if (required.length > 0)
|
|
115
|
+
agg.recallAll += (0, metrics_1.recallAllAtK)(result.hits, required, reply);
|
|
116
|
+
if (test.relevant.some(target => (0, metrics_1.roleOf)(target) === 'positive') && test.relevant.some(target => (0, metrics_1.roleOf)(target) === 'forbidden')) {
|
|
117
|
+
agg.supersessionSensitive += 1;
|
|
118
|
+
if ((0, metrics_1.contradictionFirst)(result.hits, test.relevant))
|
|
119
|
+
agg.contradictionFirst += 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (typeof result.latencyMs === 'number')
|
|
123
|
+
agg.latencyMs.push(result.latencyMs);
|
|
124
|
+
agg.replyTokens.push(result.replyTokens);
|
|
125
|
+
}
|
|
126
|
+
function summarize(agg) {
|
|
127
|
+
const a = Math.max(1, agg.answerable);
|
|
128
|
+
const z = Math.max(1, agg.abstention);
|
|
129
|
+
return {
|
|
130
|
+
cases: agg.count,
|
|
131
|
+
answerableCases: agg.answerable,
|
|
132
|
+
abstentionCases: agg.abstention,
|
|
133
|
+
replyHit: round(agg.hit / a),
|
|
134
|
+
replyRecall: round(agg.recall / a),
|
|
135
|
+
replyRecallAll: round(agg.recallAll / a),
|
|
136
|
+
contradictionFirstRate: agg.supersessionSensitive ? round(agg.contradictionFirst / agg.supersessionSensitive) : null,
|
|
137
|
+
supersessionSensitiveCases: agg.supersessionSensitive,
|
|
138
|
+
forbiddenInReplyRate: agg.forbiddenSensitive ? round(agg.forbiddenInReply / agg.forbiddenSensitive) : null,
|
|
139
|
+
forbiddenSensitiveCases: agg.forbiddenSensitive,
|
|
140
|
+
verdictRelevantRate: agg.answerable ? round(agg.verdictRelevant / a) : null,
|
|
141
|
+
verdictAbstainRate: agg.abstention ? round(agg.verdictAbstain / z) : null,
|
|
142
|
+
verdictMissingCases: agg.verdictMissing,
|
|
143
|
+
meanReplyTokens: rounded(mean(agg.replyTokens)),
|
|
144
|
+
p95ReplyTokens: rounded(percentile(agg.replyTokens, 0.95)),
|
|
145
|
+
meanLatencyMs: rounded(mean(agg.latencyMs)),
|
|
146
|
+
p50LatencyMs: rounded(percentile(agg.latencyMs, 0.5)),
|
|
147
|
+
p95LatencyMs: rounded(percentile(agg.latencyMs, 0.95)),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function validateResults(cases, results) {
|
|
151
|
+
// The cases, the ids, the hits and the token and time samples are validated by the chunk scorer's own rules, so the
|
|
152
|
+
// two arms can never disagree about what a well-formed run is (a run that fails there fails here).
|
|
153
|
+
(0, scoring_1.scoreRetrieval)(cases, results.map(result => ({ id: result.id, hits: result.hits, latencyMs: result.latencyMs, contextTokens: result.replyTokens })));
|
|
154
|
+
for (const result of results) {
|
|
155
|
+
if (typeof result.replyTokens !== 'number')
|
|
156
|
+
throw new Error(`Missing replyTokens for result: ${result.id}`);
|
|
157
|
+
const verdict = result.verdict;
|
|
158
|
+
if (verdict === null)
|
|
159
|
+
continue;
|
|
160
|
+
if (!verdict || typeof verdict.relevant !== 'boolean' || !Number.isFinite(verdict.bar)
|
|
161
|
+
|| (verdict.similarity !== null && !Number.isFinite(verdict.similarity))) {
|
|
162
|
+
throw new Error(`Invalid verdict for result: ${result.id}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function scoreProductArm(cases, results) {
|
|
167
|
+
validateResults(cases, results);
|
|
168
|
+
const byId = new Map(results.map(result => [result.id, result]));
|
|
169
|
+
const overall = emptyAggregate();
|
|
170
|
+
const categories = new Map();
|
|
171
|
+
for (const test of cases) {
|
|
172
|
+
const result = byId.get(test.id); // validateResults has already refused a missing id
|
|
173
|
+
addCase(overall, test, result);
|
|
174
|
+
const category = categories.get(test.category) ?? emptyAggregate();
|
|
175
|
+
addCase(category, test, result);
|
|
176
|
+
categories.set(test.category, category);
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
overall: summarize(overall),
|
|
180
|
+
byCategory: Object.fromEntries([...categories.entries()].map(([name, agg]) => [name, summarize(agg)])),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
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.RunRefusedError = exports.ORDINARY_CREDENTIAL_NAMED_ENV = exports.PRODUCT_ENV_NAMESPACES = exports.RUN_RECORD_SCHEMA = exports.RUN_LEDGER_ENV = void 0;
|
|
7
|
+
exports.resolveRunLedgerPath = resolveRunLedgerPath;
|
|
8
|
+
exports.canonicalJson = canonicalJson;
|
|
9
|
+
exports.runFingerprint = runFingerprint;
|
|
10
|
+
exports.codeIdentity = codeIdentity;
|
|
11
|
+
exports.questionSet = questionSet;
|
|
12
|
+
exports.isPresenceOnlyEnv = isPresenceOnlyEnv;
|
|
13
|
+
exports.productEnvironment = productEnvironment;
|
|
14
|
+
exports.readRunLedger = readRunLedger;
|
|
15
|
+
exports.admitRun = admitRun;
|
|
16
|
+
exports.recordRun = recordRun;
|
|
17
|
+
exports.archiveRow = archiveRow;
|
|
18
|
+
exports.saveRunOutcome = saveRunOutcome;
|
|
19
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
20
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
21
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
22
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
23
|
+
/**
|
|
24
|
+
* The eval run ledger: every LongMemEval run is on record before it spends anything.
|
|
25
|
+
*
|
|
26
|
+
* WHY THIS EXISTS
|
|
27
|
+
* Full 500-question runs were repeated with configurations that had already been measured, because what
|
|
28
|
+
* ran lived in disposable worktrees and private reports. The ledger is one JSON line per run, appended to
|
|
29
|
+
* RENOR_EVAL_RUN_LEDGER (default ~/.local/share/renor/eval-run-ledger.jsonl, next to the eval and embedding
|
|
30
|
+
* caches, so every worktree on the machine reads and writes the same file). Each line carries the run's
|
|
31
|
+
* configuration, its fingerprint and where its per-question results are written.
|
|
32
|
+
*
|
|
33
|
+
* THE RULE
|
|
34
|
+
* A runner calls admitRun() before its first provider call. A run whose exact fingerprint is already on
|
|
35
|
+
* record is refused, unless the caller states why it must run again (--rerun-reason). The reason is
|
|
36
|
+
* written into the new record. Continuing a stopped run in its own output directory is the same run, not
|
|
37
|
+
* a repeat.
|
|
38
|
+
*
|
|
39
|
+
* WHAT SURVIVES THE WORKTREE
|
|
40
|
+
* The record is appended before the first spend, so a run that is killed is still on record. Per-question
|
|
41
|
+
* rows are copied into eval-runs/<record id>/ beside the ledger as they are written, and the run's JSON
|
|
42
|
+
* artifacts plus outcome.json when it ends. A run with no outcome.json was stopped, killed or is still
|
|
43
|
+
* running.
|
|
44
|
+
*
|
|
45
|
+
* The fingerprint is sha256 over the canonical JSON of the configuration the runner builds: models and
|
|
46
|
+
* endpoints, prompt flags, product settings, the question set, the dataset and the content of the code
|
|
47
|
+
* the run executes. It never contains a credential value.
|
|
48
|
+
*/
|
|
49
|
+
exports.RUN_LEDGER_ENV = 'RENOR_EVAL_RUN_LEDGER';
|
|
50
|
+
exports.RUN_RECORD_SCHEMA = 'renor-eval-run/v1';
|
|
51
|
+
const sha256 = (data) => node_crypto_1.default.createHash('sha256').update(data).digest('hex');
|
|
52
|
+
function resolveRunLedgerPath(env = process.env) {
|
|
53
|
+
const raw = env[exports.RUN_LEDGER_ENV]?.trim();
|
|
54
|
+
if (raw === 'off' || raw === '0') {
|
|
55
|
+
throw new Error(`${exports.RUN_LEDGER_ENV} cannot turn the run ledger off: point it at a scratch file for runs that must not be on the shared record`);
|
|
56
|
+
}
|
|
57
|
+
return raw ? node_path_1.default.resolve(raw) : node_path_1.default.join(env.HOME || node_os_1.default.homedir(), '.local', 'share', 'renor', 'eval-run-ledger.jsonl');
|
|
58
|
+
}
|
|
59
|
+
/** JSON with object keys sorted at every depth and undefined members dropped, so equal configurations serialize equally. */
|
|
60
|
+
function canonicalJson(value) {
|
|
61
|
+
const sorted = (item) => {
|
|
62
|
+
if (Array.isArray(item))
|
|
63
|
+
return item.map(sorted);
|
|
64
|
+
if (item !== null && typeof item === 'object') {
|
|
65
|
+
return Object.fromEntries(Object.keys(item).sort()
|
|
66
|
+
.filter(key => item[key] !== undefined)
|
|
67
|
+
.map(key => [key, sorted(item[key])]));
|
|
68
|
+
}
|
|
69
|
+
return item;
|
|
70
|
+
};
|
|
71
|
+
return JSON.stringify(sorted(value));
|
|
72
|
+
}
|
|
73
|
+
function runFingerprint(config) {
|
|
74
|
+
return sha256(canonicalJson(config));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Content identity of the code a run executes: sha256 over every file under `roots` (paths relative to
|
|
78
|
+
* `engineRoot`), each as `path\0sha256(content)`, sorted by path. The same bytes give the same identity on
|
|
79
|
+
* any commit and in any worktree, so a commit that touches only other files does not make a measured
|
|
80
|
+
* configuration new again; any edit to these files, committed or not, does. A missing root throws.
|
|
81
|
+
*/
|
|
82
|
+
function codeIdentity(engineRoot, roots) {
|
|
83
|
+
const files = new Set();
|
|
84
|
+
const walk = (relative) => {
|
|
85
|
+
const full = node_path_1.default.join(engineRoot, relative);
|
|
86
|
+
const stat = node_fs_1.default.statSync(full);
|
|
87
|
+
if (stat.isDirectory()) {
|
|
88
|
+
for (const entry of node_fs_1.default.readdirSync(full))
|
|
89
|
+
walk(node_path_1.default.posix.join(relative, entry));
|
|
90
|
+
}
|
|
91
|
+
else if (stat.isFile()) {
|
|
92
|
+
files.add(relative);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
for (const root of roots)
|
|
96
|
+
walk(root.split(node_path_1.default.sep).join('/'));
|
|
97
|
+
return sha256([...files].sort()
|
|
98
|
+
.map(relative => `${relative}\0${sha256(node_fs_1.default.readFileSync(node_path_1.default.join(engineRoot, relative)))}`)
|
|
99
|
+
.join('\n'));
|
|
100
|
+
}
|
|
101
|
+
/** A question set as configuration: its size and a hash of its sorted ids (selection order does not change what is measured). */
|
|
102
|
+
function questionSet(ids) {
|
|
103
|
+
return { count: ids.length, sha256: sha256([...ids].sort().join('\n')) };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The namespaces the product reads its settings from (`process.env` in engine/src). Every variable of a product
|
|
107
|
+
* process's environment inside them is configuration, whoever set it: the runner, the operator's shell or a parent
|
|
108
|
+
* process. A setting such as RENOR_NEWEST_FIRST or CHUNK_MAX_TOKENS changes what a run measures even when no runner
|
|
109
|
+
* names it. tests/eval/run-ledger.test.ts scans engine/src and fails on a variable read outside these namespaces that
|
|
110
|
+
* it does not list as non-configuration, so a new setting cannot escape the fingerprint.
|
|
111
|
+
*/
|
|
112
|
+
exports.PRODUCT_ENV_NAMESPACES = /^(RENOR_|MVP_|EMBEDDING_|OPENAI_|OLLAMA_|GOOGLE_|CHUNK_|INDEX_|LLM_|MEMORY_|CONVERSATIONS_|PRIORITY_)|^TZ$/;
|
|
113
|
+
/**
|
|
114
|
+
* Inside those namespaces: the eval runners' own variables, and a spend cap, which can stop a run but not change its
|
|
115
|
+
* result. Settings that look operational but can change a result stay configuration: index concurrency and embedding
|
|
116
|
+
* batch size can change tie order, and the extractor timeout decides whether an armed extraction lands. A repeat that
|
|
117
|
+
* differs only in one of them is therefore not refused, which is the safe direction.
|
|
118
|
+
*/
|
|
119
|
+
const NOT_PRODUCT_CONFIGURATION = /^RENOR_QA_|^RENOR_EVAL_RUN_LEDGER$|^RENOR_EVAL_MAX_EMBED_TOKENS$/;
|
|
120
|
+
/**
|
|
121
|
+
* Names whose values never enter a run record: any name with a credential word as a whole `_`-separated part
|
|
122
|
+
* (`KEY`, `KEYS`, `API_KEY`, `APIKEY`, `TOKEN`, `SECRET(S)`, `PASSWORD`, `PASSWD`, `CREDENTIAL(S)`) anywhere in it,
|
|
123
|
+
* cloud project ids, and cache locations (where a cache lives is not what is measured). Their presence is recorded.
|
|
124
|
+
*
|
|
125
|
+
* The position of the word does not matter: `OPENAI_API_KEY_BENCH`, `OLLAMA_CLOUD_KEY_4` and `OPENAI_API_KEY_BENCH_TTL_SECONDS`
|
|
126
|
+
* are all credential names, because the words around the credential word say which credential, not that the value is
|
|
127
|
+
* safe to write. A part that only contains such a word (`RENOR_KEYWORD_WEIGHT`, `RENOR_EVIDENCE_MAX_TOKENS`) is not one.
|
|
128
|
+
*
|
|
129
|
+
* Fail closed: a new ordinary setting whose name has a credential part is masked until it is listed in
|
|
130
|
+
* ORDINARY_CREDENTIAL_NAMED_ENV, which holds the full names of the settings that really are read from the environment
|
|
131
|
+
* (tests/eval/run-ledger.test.ts fails when a listed name is no longer read by engine/src).
|
|
132
|
+
*/
|
|
133
|
+
const CREDENTIAL_PART = /(?:^|_)(?:API_?)?(?:KEYS?|TOKEN|SECRETS?|PASSWORD|PASSWD|CREDENTIALS?)(?:_|$)/;
|
|
134
|
+
/**
|
|
135
|
+
* Settings whose names carry a credential word and whose values are not credentials; their values stay in the record,
|
|
136
|
+
* so two runs that differ in one never share a fingerprint. Only full names: a suffix or prefix never makes a name
|
|
137
|
+
* ordinary.
|
|
138
|
+
* - MVP_ACCESS_TOKEN_TTL_SECONDS: how long an issued access token lives (src/mvp/config.ts).
|
|
139
|
+
* - RENOR_SECRET_GUARD: on/off switch of the secret-looking-note filter in import (src/extraction/secretGuard.ts).
|
|
140
|
+
*/
|
|
141
|
+
exports.ORDINARY_CREDENTIAL_NAMED_ENV = new Set([
|
|
142
|
+
'MVP_ACCESS_TOKEN_TTL_SECONDS',
|
|
143
|
+
'RENOR_SECRET_GUARD',
|
|
144
|
+
]);
|
|
145
|
+
/** True for a name whose value must be recorded as `set`, never as itself. */
|
|
146
|
+
function isPresenceOnlyEnv(name) {
|
|
147
|
+
if (exports.ORDINARY_CREDENTIAL_NAMED_ENV.has(name))
|
|
148
|
+
return false;
|
|
149
|
+
return CREDENTIAL_PART.test(name) || /_PROJECT$|_CACHE$/.test(name);
|
|
150
|
+
}
|
|
151
|
+
/** The configuration a product process's environment carries: its product-namespace variables, credentials and caches by presence only. */
|
|
152
|
+
function productEnvironment(env) {
|
|
153
|
+
return Object.fromEntries(Object.entries(env)
|
|
154
|
+
.filter((entry) => entry[1] !== undefined
|
|
155
|
+
&& exports.PRODUCT_ENV_NAMESPACES.test(entry[0]) && !NOT_PRODUCT_CONFIGURATION.test(entry[0]))
|
|
156
|
+
.map(([name, value]) => [name, isPresenceOnlyEnv(name) ? 'set' : value]));
|
|
157
|
+
}
|
|
158
|
+
class RunRefusedError extends Error {
|
|
159
|
+
}
|
|
160
|
+
exports.RunRefusedError = RunRefusedError;
|
|
161
|
+
/** Every record on the ledger. A torn last line (an append in progress) is skipped; any other unreadable line fails closed. */
|
|
162
|
+
function readRunLedger(ledgerPath) {
|
|
163
|
+
if (!node_fs_1.default.existsSync(ledgerPath))
|
|
164
|
+
return [];
|
|
165
|
+
const lines = node_fs_1.default.readFileSync(ledgerPath, 'utf8').split('\n');
|
|
166
|
+
const records = [];
|
|
167
|
+
lines.forEach((line, index) => {
|
|
168
|
+
if (!line.trim())
|
|
169
|
+
return;
|
|
170
|
+
let record;
|
|
171
|
+
try {
|
|
172
|
+
record = JSON.parse(line);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
if (index === lines.length - 1)
|
|
176
|
+
return;
|
|
177
|
+
throw new Error(`Run ledger ${ledgerPath} line ${index + 1} is not JSON: cannot tell whether this run repeats one on record`);
|
|
178
|
+
}
|
|
179
|
+
if (typeof record.fingerprint !== 'string' || typeof record.outputDir !== 'string') {
|
|
180
|
+
throw new Error(`Run ledger ${ledgerPath} line ${index + 1} has no fingerprint or outputDir: cannot tell whether this run repeats one on record`);
|
|
181
|
+
}
|
|
182
|
+
records.push(record);
|
|
183
|
+
});
|
|
184
|
+
return records;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Decide, before any provider call, whether this run may start. `resuming` is true when the output
|
|
188
|
+
* directory already holds this run's checkpoint. Throws RunRefusedError when the exact fingerprint is
|
|
189
|
+
* already on record for another output directory and no reason was given.
|
|
190
|
+
*/
|
|
191
|
+
function admitRun(args) {
|
|
192
|
+
const reason = args.rerunReason?.trim() ?? '';
|
|
193
|
+
if (args.rerunReason !== undefined && !reason) {
|
|
194
|
+
throw new RunRefusedError('Refused: --rerun-reason is empty. Say why this configuration must run again; the reason is recorded with the run.');
|
|
195
|
+
}
|
|
196
|
+
const outputDir = node_path_1.default.resolve(args.outputDir);
|
|
197
|
+
const fingerprint = runFingerprint(args.config);
|
|
198
|
+
const matches = readRunLedger(args.ledgerPath).filter(record => record.fingerprint === fingerprint);
|
|
199
|
+
const resumes = args.resuming ? matches.filter(record => record.outputDir === outputDir) : [];
|
|
200
|
+
const priors = resumes.length > 0 ? [] : matches;
|
|
201
|
+
if (priors.length > 0 && !reason) {
|
|
202
|
+
throw new RunRefusedError(refusalMessage(args.ledgerPath, fingerprint, priors));
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
ledgerPath: args.ledgerPath,
|
|
206
|
+
runner: args.runner,
|
|
207
|
+
fingerprint,
|
|
208
|
+
config: args.config,
|
|
209
|
+
outputDir,
|
|
210
|
+
resumes,
|
|
211
|
+
priors,
|
|
212
|
+
rerunReason: reason || null,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function describePrior(record) {
|
|
216
|
+
const outcomePath = node_path_1.default.join(record.archiveDir ?? '', 'outcome.json');
|
|
217
|
+
if (record.archiveDir && node_fs_1.default.existsSync(outcomePath)) {
|
|
218
|
+
try {
|
|
219
|
+
const outcome = JSON.parse(node_fs_1.default.readFileSync(outcomePath, 'utf8'));
|
|
220
|
+
const score = typeof outcome.correct === 'number' ? `${outcome.correct} correct, ` : '';
|
|
221
|
+
return `${outcome.status}: ${score}${outcome.casesCompleted}/${outcome.casesTotal} questions; results ${record.archiveDir}`;
|
|
222
|
+
}
|
|
223
|
+
catch { /* an unreadable outcome reads as no outcome */ }
|
|
224
|
+
}
|
|
225
|
+
const archived = record.archiveDir ? node_path_1.default.join(record.archiveDir, node_path_1.default.basename(record.perQuestionResults ?? '')) : '';
|
|
226
|
+
const rows = archived && node_fs_1.default.existsSync(archived)
|
|
227
|
+
? node_fs_1.default.readFileSync(archived, 'utf8').split('\n').filter(Boolean).length
|
|
228
|
+
: 0;
|
|
229
|
+
return `no outcome recorded (stopped, killed or still running); ${rows} per-question row(s) in ${archived || record.perQuestionResults}`;
|
|
230
|
+
}
|
|
231
|
+
function refusalMessage(ledgerPath, fingerprint, priors) {
|
|
232
|
+
const shown = priors.slice(-5).map(record => ` - ${record.recordedAt} ${record.outputDir}\n ${describePrior(record)}${record.rerun ? `\n (itself a re-run: ${record.rerun.reason})` : ''}`);
|
|
233
|
+
return [
|
|
234
|
+
`Refused: this exact configuration (fingerprint ${fingerprint.slice(0, 16)}) has already run ${priors.length} time(s) on this machine:`,
|
|
235
|
+
...shown,
|
|
236
|
+
'Running it again pays for a result that is already on record. Read the results above. To finish a stopped run, pass its',
|
|
237
|
+
'--output directory again. If a repeat is the point (a fresh answer draw, results that were lost), pass --rerun-reason "<why>":',
|
|
238
|
+
'the reason is recorded with the new run.',
|
|
239
|
+
`Ledger: ${ledgerPath}`,
|
|
240
|
+
].join('\n');
|
|
241
|
+
}
|
|
242
|
+
/** Append the run's record. Call it once the run will spend (it has questions to answer), after the output directory is claimed. */
|
|
243
|
+
function recordRun(admission, details) {
|
|
244
|
+
const recordedAt = new Date().toISOString();
|
|
245
|
+
const id = `${recordedAt.replace(/[-:]/g, '').slice(0, 15)}-${admission.fingerprint.slice(0, 12)}-${node_crypto_1.default.randomBytes(3).toString('hex')}`;
|
|
246
|
+
const archiveDir = node_path_1.default.join(node_path_1.default.dirname(admission.ledgerPath), 'eval-runs', id);
|
|
247
|
+
node_fs_1.default.mkdirSync(archiveDir, { recursive: true });
|
|
248
|
+
const record = {
|
|
249
|
+
schema: exports.RUN_RECORD_SCHEMA,
|
|
250
|
+
id,
|
|
251
|
+
recordedAt,
|
|
252
|
+
runner: admission.runner,
|
|
253
|
+
fingerprint: admission.fingerprint,
|
|
254
|
+
config: admission.config,
|
|
255
|
+
gitSha: details.gitSha,
|
|
256
|
+
gitDirty: details.gitDirty,
|
|
257
|
+
outputDir: admission.outputDir,
|
|
258
|
+
perQuestionResults: node_path_1.default.resolve(details.perQuestionResults),
|
|
259
|
+
archiveDir,
|
|
260
|
+
resumes: admission.resumes.map(prior => prior.id),
|
|
261
|
+
rerun: admission.rerunReason ? { reason: admission.rerunReason, priorRecordIds: admission.priors.map(prior => prior.id) } : null,
|
|
262
|
+
};
|
|
263
|
+
node_fs_1.default.appendFileSync(admission.ledgerPath, JSON.stringify(record) + '\n', 'utf8');
|
|
264
|
+
return record;
|
|
265
|
+
}
|
|
266
|
+
/** Copy one per-question row into the run's archive as it is written, so a run that is killed still leaves its results. */
|
|
267
|
+
function archiveRow(record, line) {
|
|
268
|
+
node_fs_1.default.appendFileSync(node_path_1.default.join(record.archiveDir, node_path_1.default.basename(record.perQuestionResults)), line.endsWith('\n') ? line : `${line}\n`, 'utf8');
|
|
269
|
+
}
|
|
270
|
+
/** When the run ends: copy its JSON artifacts from the output directory into the archive, then write outcome.json. */
|
|
271
|
+
function saveRunOutcome(record, outcome) {
|
|
272
|
+
for (const name of node_fs_1.default.readdirSync(record.outputDir)) {
|
|
273
|
+
const from = node_path_1.default.join(record.outputDir, name);
|
|
274
|
+
if (!/\.(json|jsonl|sha256)$/.test(name) || !node_fs_1.default.statSync(from).isFile())
|
|
275
|
+
continue;
|
|
276
|
+
node_fs_1.default.copyFileSync(from, node_path_1.default.join(record.archiveDir, name), node_fs_1.default.constants.COPYFILE_FICLONE);
|
|
277
|
+
}
|
|
278
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(record.archiveDir, 'outcome.json'), JSON.stringify({ recordId: record.id, ...outcome }, null, 2) + '\n', 'utf8');
|
|
279
|
+
}
|