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.
Files changed (176) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +73 -0
  3. package/README.md +105 -2
  4. package/dist/BUILD.json +6 -0
  5. package/dist/api/apiAuth.js +43 -0
  6. package/dist/api/server.js +172 -0
  7. package/dist/buildIdentity.js +132 -0
  8. package/dist/cli-extractors.js +46 -0
  9. package/dist/cli.js +491 -0
  10. package/dist/config.js +65 -0
  11. package/dist/context/composer.js +320 -0
  12. package/dist/context/readHonesty.js +478 -0
  13. package/dist/context/rowStore.js +238 -0
  14. package/dist/conversation/formatter.js +81 -0
  15. package/dist/conversation/parser.js +189 -0
  16. package/dist/conversation/types.js +2 -0
  17. package/dist/conversation/writer.js +42 -0
  18. package/dist/db/client.js +147 -0
  19. package/dist/db/indexSalvage.js +81 -0
  20. package/dist/db/migrations.js +642 -0
  21. package/dist/db/readCache.js +109 -0
  22. package/dist/db/schema.js +5 -0
  23. package/dist/db/statements.js +89 -0
  24. package/dist/digests/digestCache.js +142 -0
  25. package/dist/digests/digests.js +597 -0
  26. package/dist/digests/worker.js +348 -0
  27. package/dist/embeddings/caching.js +337 -0
  28. package/dist/embeddings/coalescing.js +177 -0
  29. package/dist/embeddings/interactive.js +103 -0
  30. package/dist/embeddings/local.js +68 -0
  31. package/dist/embeddings/openai.js +105 -0
  32. package/dist/embeddings/rebuild.js +166 -0
  33. package/dist/embeddings/simdScan.js +258 -0
  34. package/dist/embeddings/space.js +21 -0
  35. package/dist/embeddings/types.js +14 -0
  36. package/dist/embeddings/vectorIndex.js +675 -0
  37. package/dist/embeddings/vertex.js +224 -0
  38. package/dist/eval/artifacts.js +42 -0
  39. package/dist/eval/cacheReport.js +139 -0
  40. package/dist/eval/comparison.js +182 -0
  41. package/dist/eval/context700-candidate.js +145 -0
  42. package/dist/eval/evalCache.js +525 -0
  43. package/dist/eval/hashEmbedding.js +35 -0
  44. package/dist/eval/longmemeval.js +56 -0
  45. package/dist/eval/metrics.js +181 -0
  46. package/dist/eval/renorbenchBaseline.js +486 -0
  47. package/dist/eval/renorbenchDocuments.js +134 -0
  48. package/dist/eval/renorbenchProduct.js +182 -0
  49. package/dist/eval/runLedger.js +279 -0
  50. package/dist/eval/runManifest.js +479 -0
  51. package/dist/eval/scoring.js +197 -0
  52. package/dist/evidence/attach.js +267 -0
  53. package/dist/evidence/background.js +147 -0
  54. package/dist/evidence/evidenceCache.js +182 -0
  55. package/dist/evidence/gate.js +37 -0
  56. package/dist/evidence/keyPool.js +85 -0
  57. package/dist/evidence/ollamaChat.js +84 -0
  58. package/dist/evidence/optin.js +165 -0
  59. package/dist/evidence/options.js +130 -0
  60. package/dist/evidence/secondPass.js +456 -0
  61. package/dist/extraction/classifyCapture.js +34 -0
  62. package/dist/extraction/decision-model.js +174 -0
  63. package/dist/extraction/eventExtractor.js +113 -0
  64. package/dist/extraction/extractionClient.js +122 -0
  65. package/dist/extraction/factExtractor.js +46 -0
  66. package/dist/extraction/factsWritePath.js +179 -0
  67. package/dist/extraction/preferenceExtractor.js +153 -0
  68. package/dist/extraction/secretGuard.js +176 -0
  69. package/dist/extraction/secretScanner.js +665 -0
  70. package/dist/extraction/typedFacts.js +224 -0
  71. package/dist/extraction/writePathAdjudication.js +110 -0
  72. package/dist/importing/apply.js +644 -0
  73. package/dist/importing/bundle-write.js +80 -0
  74. package/dist/importing/cli.js +407 -0
  75. package/dist/importing/existing.js +49 -0
  76. package/dist/importing/facts.js +123 -0
  77. package/dist/importing/forgetImported.js +79 -0
  78. package/dist/importing/hosted.js +467 -0
  79. package/dist/importing/index.js +29 -0
  80. package/dist/importing/journalLog.js +96 -0
  81. package/dist/importing/markdown.js +98 -0
  82. package/dist/importing/plan.js +206 -0
  83. package/dist/importing/secretScreen.js +113 -0
  84. package/dist/importing/serialize.js +188 -0
  85. package/dist/importing/source.js +463 -0
  86. package/dist/importing/types.js +6 -0
  87. package/dist/indexer/chunker.js +319 -0
  88. package/dist/indexer/dailyEntries.js +63 -0
  89. package/dist/indexer/hasher.js +10 -0
  90. package/dist/indexer/laneRuns.js +48 -0
  91. package/dist/indexer/o200k.js +481 -0
  92. package/dist/indexer/o200k.json +8 -0
  93. package/dist/indexer/outsideRoot.js +77 -0
  94. package/dist/indexer/parser.js +167 -0
  95. package/dist/indexer/pipeline.js +1168 -0
  96. package/dist/indexer/staleness.js +201 -0
  97. package/dist/indexer/tokenizer.js +77 -0
  98. package/dist/indexer/walker.js +184 -0
  99. package/dist/mcp/hive.js +88 -0
  100. package/dist/mcp/requestErrors.js +47 -0
  101. package/dist/mcp/server.js +396 -0
  102. package/dist/memory/agentLease.js +43 -0
  103. package/dist/memory/confinedWrite.js +315 -0
  104. package/dist/memory/controls.js +73 -0
  105. package/dist/memory/dailyNote.js +265 -0
  106. package/dist/memory/durableAppend.js +57 -0
  107. package/dist/memory/factConflicts.js +203 -0
  108. package/dist/memory/factTerms.js +321 -0
  109. package/dist/memory/hiveNameHeader.cjs +10 -0
  110. package/dist/memory/inventory.js +55 -0
  111. package/dist/memory/invisibleText.js +20 -0
  112. package/dist/memory/lifecycle.js +1756 -0
  113. package/dist/memory/limits.js +13 -0
  114. package/dist/memory/requests.js +183 -0
  115. package/dist/memory/sourceName.js +200 -0
  116. package/dist/memory/whatsNew.js +158 -0
  117. package/dist/memory/writer.js +175 -0
  118. package/dist/memory/writerConflicts.js +85 -0
  119. package/dist/memorymap/memoryMap.js +1568 -0
  120. package/dist/memorymap/replacedJournal.js +58 -0
  121. package/dist/memorymap/replacements.js +405 -0
  122. package/dist/mvp/activity.js +86 -0
  123. package/dist/mvp/agentMemory.js +1744 -0
  124. package/dist/mvp/auth.js +531 -0
  125. package/dist/mvp/cli.js +73 -0
  126. package/dist/mvp/client-store.js +462 -0
  127. package/dist/mvp/compileCache.js +249 -0
  128. package/dist/mvp/compileSeed.js +27 -0
  129. package/dist/mvp/config.js +288 -0
  130. package/dist/mvp/connectionStatus.js +80 -0
  131. package/dist/mvp/contextRelevance.js +413 -0
  132. package/dist/mvp/engine.js +1065 -0
  133. package/dist/mvp/exportAll.js +359 -0
  134. package/dist/mvp/factLanePack.js +77 -0
  135. package/dist/mvp/factMarkers.js +49 -0
  136. package/dist/mvp/factsLane.js +203 -0
  137. package/dist/mvp/hiddenSpans.js +585 -0
  138. package/dist/mvp/history.js +413 -0
  139. package/dist/mvp/hitDates.js +31 -0
  140. package/dist/mvp/hitFloor.js +36 -0
  141. package/dist/mvp/identity.js +18 -0
  142. package/dist/mvp/idle.js +113 -0
  143. package/dist/mvp/memoryDir.js +98 -0
  144. package/dist/mvp/posthogMcp.js +210 -0
  145. package/dist/mvp/readScope.js +104 -0
  146. package/dist/mvp/readUsefulness.js +222 -0
  147. package/dist/mvp/sanitize.js +48 -0
  148. package/dist/mvp/schemaRejections.js +91 -0
  149. package/dist/mvp/scope.js +127 -0
  150. package/dist/mvp/server.js +2089 -0
  151. package/dist/mvp/serverCard.js +29 -0
  152. package/dist/mvp/storedSize.js +139 -0
  153. package/dist/mvp/telemetry.js +441 -0
  154. package/dist/retrieval/adviceLines.js +195 -0
  155. package/dist/retrieval/adviceQuestion.js +44 -0
  156. package/dist/retrieval/conversationShape.js +44 -0
  157. package/dist/retrieval/dailyEntryTime.js +55 -0
  158. package/dist/retrieval/entityLookup.js +113 -0
  159. package/dist/retrieval/factsFirst.js +188 -0
  160. package/dist/retrieval/keyword.js +84 -0
  161. package/dist/retrieval/ledgerEventTime.js +51 -0
  162. package/dist/retrieval/ledgerLiveness.js +83 -0
  163. package/dist/retrieval/ledgerPresentation.js +161 -0
  164. package/dist/retrieval/noteIdentity.js +57 -0
  165. package/dist/retrieval/orchestrator.js +1086 -0
  166. package/dist/retrieval/queryHelpers.js +142 -0
  167. package/dist/retrieval/questionSpan.js +475 -0
  168. package/dist/retrieval/readerEvents.js +690 -0
  169. package/dist/retrieval/readerLayout.js +503 -0
  170. package/dist/retrieval/relativeTimeHints.js +113 -0
  171. package/dist/retrieval/semantic.js +67 -0
  172. package/dist/router/routerCache.js +106 -0
  173. package/dist/router/sessionRouter.js +726 -0
  174. package/dist/toolRefusal.js +120 -0
  175. package/dist/watcher/watcher.js +75 -0
  176. 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
+ }