@remnic/core 9.3.696 → 9.3.698
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/dist/access-boundary.d.ts +1 -1
- package/dist/access-boundary.js +20 -20
- package/dist/access-cli.js +44 -44
- package/dist/access-http.d.ts +1 -1
- package/dist/access-http.js +23 -23
- package/dist/access-mcp.d.ts +1 -1
- package/dist/access-mcp.js +22 -22
- package/dist/access-operations.d.ts +1 -1
- package/dist/access-operations.js +21 -21
- package/dist/{access-service-BqBQeUJd.d.ts → access-service-DftqtNUy.d.ts} +10 -0
- package/dist/access-service.d.ts +1 -1
- package/dist/access-service.js +19 -19
- package/dist/access-surface-catalog.d.ts +1 -1
- package/dist/active-recall.js +1 -1
- package/dist/{auto-sync-PFW2NJZY.js → auto-sync-3AAP25FV.js} +5 -5
- package/dist/briefing.js +7 -7
- package/dist/calibration.js +2 -2
- package/dist/causal-behavior.js +2 -2
- package/dist/causal-chain.js +2 -2
- package/dist/causal-consolidation.js +10 -10
- package/dist/causal-retrieval.js +2 -2
- package/dist/causal-trajectory-graph.js +2 -2
- package/dist/causal-trajectory.js +1 -1
- package/dist/{chunk-YOZNTNNA.js → chunk-2K64VH66.js} +2 -2
- package/dist/{chunk-NLZO5NO6.js → chunk-2KJG6ZZK.js} +2 -2
- package/dist/{chunk-3P2XEQSV.js → chunk-2ULWWAQH.js} +2 -2
- package/dist/{chunk-EK6FYL2E.js → chunk-3LSSFCB4.js} +13 -13
- package/dist/chunk-3LSSFCB4.js.map +1 -0
- package/dist/{chunk-3PG3H5TD.js → chunk-4EX5WZ4M.js} +2 -2
- package/dist/chunk-4EX5WZ4M.js.map +1 -0
- package/dist/{chunk-LM6JB2EG.js → chunk-4NFVPDIL.js} +2 -2
- package/dist/{chunk-5TYA4OCF.js → chunk-5J4OL7TJ.js} +2 -2
- package/dist/chunk-5J4OL7TJ.js.map +1 -0
- package/dist/{chunk-J45OCIVH.js → chunk-A62RAIBN.js} +2 -2
- package/dist/{chunk-XXA6T3O4.js → chunk-ANZLT74L.js} +2 -2
- package/dist/{chunk-MNFAGE5D.js → chunk-COEZR6F5.js} +2 -2
- package/dist/{chunk-HKET3ZOI.js → chunk-CQ4PGFMC.js} +3 -3
- package/dist/{chunk-SPVIG2R3.js → chunk-E6GOVHHJ.js} +10 -10
- package/dist/{chunk-XWT4JXXA.js → chunk-EZWQZYLK.js} +2 -2
- package/dist/{chunk-5AT62NQH.js → chunk-FEB7W5JG.js} +104 -16
- package/dist/chunk-FEB7W5JG.js.map +1 -0
- package/dist/{chunk-QJM2XAJD.js → chunk-FN2SM5SN.js} +5 -5
- package/dist/{chunk-Q6ZHGGNJ.js → chunk-FSEQXHEZ.js} +2 -2
- package/dist/{chunk-32XTY2DQ.js → chunk-HZ5DRA4Q.js} +44 -44
- package/dist/{chunk-XUNQLJT2.js → chunk-IZ5E6ZTK.js} +4 -4
- package/dist/{chunk-RWST6NOL.js → chunk-JYVD2XZ4.js} +52 -52
- package/dist/chunk-JYVD2XZ4.js.map +1 -0
- package/dist/{chunk-YGKUAX2B.js → chunk-KF74X62T.js} +4 -4
- package/dist/{chunk-JMAKCWJ3.js → chunk-LIWU4QI6.js} +5 -5
- package/dist/{chunk-Q7SFJURX.js → chunk-M4DQWUKX.js} +2 -2
- package/dist/{chunk-AF6DSAWD.js → chunk-MRX6S22R.js} +2 -2
- package/dist/{chunk-FZMH66NT.js → chunk-N55RJT4N.js} +2 -2
- package/dist/{chunk-RK6F44Y6.js → chunk-N7RWREIQ.js} +2 -2
- package/dist/chunk-N7RWREIQ.js.map +1 -0
- package/dist/{chunk-W54EAJT4.js → chunk-PJIHCOGV.js} +2 -2
- package/dist/{chunk-WR6KFJKA.js → chunk-RC3CNIPK.js} +2 -2
- package/dist/{chunk-MOBRVKWE.js → chunk-RIC5U67B.js} +10 -10
- package/dist/{chunk-EC3A6M43.js → chunk-RTN2BLZM.js} +2 -2
- package/dist/{chunk-G663XUEG.js → chunk-SIDSEXUG.js} +2 -2
- package/dist/{chunk-52QIAN76.js → chunk-SJQ4HY3E.js} +2 -2
- package/dist/{chunk-JJT3AGL4.js → chunk-TH7WMHVK.js} +2 -2
- package/dist/{chunk-4NNDJOAO.js → chunk-UDDSC6PO.js} +8 -8
- package/dist/{chunk-TT7BJWGI.js → chunk-VKJHM6PL.js} +2 -2
- package/dist/{chunk-N4PD5IY3.js → chunk-XFG3PVZE.js} +53 -33
- package/dist/chunk-XFG3PVZE.js.map +1 -0
- package/dist/{chunk-NFMDHE45.js → chunk-XL5RSHZP.js} +2 -2
- package/dist/{chunk-3LYJIA76.js → chunk-YN4ZT4CW.js} +1 -1
- package/dist/{chunk-IF362TF6.js → chunk-YOI3ELXF.js} +2 -2
- package/dist/{chunk-6GAUF4OU.js → chunk-YY2HGKWR.js} +2 -2
- package/dist/{cli-DBDgdvh9.d.ts → cli-DUMkkdLl.d.ts} +1 -1
- package/dist/{codex-cli-fallback.js → cli-fallback.js} +2 -2
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +38 -38
- package/dist/compounding/engine.js +7 -7
- package/dist/config.js +1 -1
- package/dist/connectors/codex-materialize-runner.js +7 -7
- package/dist/connectors/index.js +7 -7
- package/dist/entity-retrieval.js +7 -7
- package/dist/extraction-faithfulness.d.ts +23 -3
- package/dist/extraction-faithfulness.js +3 -3
- package/dist/extraction-judge.js +3 -3
- package/dist/extraction.js +4 -4
- package/dist/fallback-llm.js +2 -2
- package/dist/{graph-edge-decay-JLOF3YW4.js → graph-edge-decay-D7OESCBR.js} +3 -3
- package/dist/graph-snapshot.js +3 -3
- package/dist/graph.js +2 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.js +80 -80
- package/dist/maintenance/memory-governance.js +7 -7
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
- package/dist/maintenance/rebuild-memory-projection.js +8 -8
- package/dist/mcp-memory-inspector-app.d.ts +1 -1
- package/dist/namespaces/migrate.js +8 -8
- package/dist/namespaces/storage.js +7 -7
- package/dist/operator-toolkit.js +15 -15
- package/dist/orchestration/maintenance.js +9 -9
- package/dist/orchestrator.js +35 -35
- package/dist/recall-planner-llm.js +2 -2
- package/dist/resume-bundles.js +2 -2
- package/dist/schemas.d.ts +22 -22
- package/dist/semantic-consolidation.js +8 -8
- package/dist/semantic-rule-promotion.js +7 -7
- package/dist/semantic-rule-verifier.js +7 -7
- package/dist/storage.js +6 -6
- package/dist/summarizer.js +3 -3
- package/dist/{codex-thread-key.js → thread-key.js} +2 -2
- package/dist/transfer/types.d.ts +12 -12
- package/dist/verified-recall.js +7 -7
- package/package.json +18 -18
- package/src/access-service.ts +17 -0
- package/src/coding/session-delta-surfaces.test.ts +185 -1
- package/src/coding/session-delta-surfaces.ts +30 -1
- package/src/coding/session-delta.test.ts +46 -0
- package/src/coding/session-delta.ts +18 -1
- package/src/config.test.ts +32 -0
- package/src/config.ts +12 -12
- package/src/extraction-faithfulness.test.ts +149 -5
- package/src/extraction-faithfulness.ts +195 -21
- package/src/fallback-llm.test.ts +1 -1
- package/src/fallback-llm.ts +1 -1
- package/src/index.ts +2 -2
- package/src/orchestrator.ts +1 -1
- package/dist/chunk-3PG3H5TD.js.map +0 -1
- package/dist/chunk-5AT62NQH.js.map +0 -1
- package/dist/chunk-5TYA4OCF.js.map +0 -1
- package/dist/chunk-EK6FYL2E.js.map +0 -1
- package/dist/chunk-N4PD5IY3.js.map +0 -1
- package/dist/chunk-RK6F44Y6.js.map +0 -1
- package/dist/chunk-RWST6NOL.js.map +0 -1
- /package/dist/{auto-sync-PFW2NJZY.js.map → auto-sync-3AAP25FV.js.map} +0 -0
- /package/dist/{chunk-YOZNTNNA.js.map → chunk-2K64VH66.js.map} +0 -0
- /package/dist/{chunk-NLZO5NO6.js.map → chunk-2KJG6ZZK.js.map} +0 -0
- /package/dist/{chunk-3P2XEQSV.js.map → chunk-2ULWWAQH.js.map} +0 -0
- /package/dist/{chunk-LM6JB2EG.js.map → chunk-4NFVPDIL.js.map} +0 -0
- /package/dist/{chunk-J45OCIVH.js.map → chunk-A62RAIBN.js.map} +0 -0
- /package/dist/{chunk-XXA6T3O4.js.map → chunk-ANZLT74L.js.map} +0 -0
- /package/dist/{chunk-MNFAGE5D.js.map → chunk-COEZR6F5.js.map} +0 -0
- /package/dist/{chunk-HKET3ZOI.js.map → chunk-CQ4PGFMC.js.map} +0 -0
- /package/dist/{chunk-SPVIG2R3.js.map → chunk-E6GOVHHJ.js.map} +0 -0
- /package/dist/{chunk-XWT4JXXA.js.map → chunk-EZWQZYLK.js.map} +0 -0
- /package/dist/{chunk-QJM2XAJD.js.map → chunk-FN2SM5SN.js.map} +0 -0
- /package/dist/{chunk-Q6ZHGGNJ.js.map → chunk-FSEQXHEZ.js.map} +0 -0
- /package/dist/{chunk-32XTY2DQ.js.map → chunk-HZ5DRA4Q.js.map} +0 -0
- /package/dist/{chunk-XUNQLJT2.js.map → chunk-IZ5E6ZTK.js.map} +0 -0
- /package/dist/{chunk-YGKUAX2B.js.map → chunk-KF74X62T.js.map} +0 -0
- /package/dist/{chunk-JMAKCWJ3.js.map → chunk-LIWU4QI6.js.map} +0 -0
- /package/dist/{chunk-Q7SFJURX.js.map → chunk-M4DQWUKX.js.map} +0 -0
- /package/dist/{chunk-AF6DSAWD.js.map → chunk-MRX6S22R.js.map} +0 -0
- /package/dist/{chunk-FZMH66NT.js.map → chunk-N55RJT4N.js.map} +0 -0
- /package/dist/{chunk-W54EAJT4.js.map → chunk-PJIHCOGV.js.map} +0 -0
- /package/dist/{chunk-WR6KFJKA.js.map → chunk-RC3CNIPK.js.map} +0 -0
- /package/dist/{chunk-MOBRVKWE.js.map → chunk-RIC5U67B.js.map} +0 -0
- /package/dist/{chunk-EC3A6M43.js.map → chunk-RTN2BLZM.js.map} +0 -0
- /package/dist/{chunk-G663XUEG.js.map → chunk-SIDSEXUG.js.map} +0 -0
- /package/dist/{chunk-52QIAN76.js.map → chunk-SJQ4HY3E.js.map} +0 -0
- /package/dist/{chunk-JJT3AGL4.js.map → chunk-TH7WMHVK.js.map} +0 -0
- /package/dist/{chunk-4NNDJOAO.js.map → chunk-UDDSC6PO.js.map} +0 -0
- /package/dist/{chunk-TT7BJWGI.js.map → chunk-VKJHM6PL.js.map} +0 -0
- /package/dist/{chunk-NFMDHE45.js.map → chunk-XL5RSHZP.js.map} +0 -0
- /package/dist/{chunk-3LYJIA76.js.map → chunk-YN4ZT4CW.js.map} +0 -0
- /package/dist/{chunk-IF362TF6.js.map → chunk-YOI3ELXF.js.map} +0 -0
- /package/dist/{chunk-6GAUF4OU.js.map → chunk-YY2HGKWR.js.map} +0 -0
- /package/dist/{codex-cli-fallback.d.ts → cli-fallback.d.ts} +0 -0
- /package/dist/{codex-cli-fallback.js.map → cli-fallback.js.map} +0 -0
- /package/dist/{graph-edge-decay-JLOF3YW4.js.map → graph-edge-decay-D7OESCBR.js.map} +0 -0
- /package/dist/{codex-thread-key.d.ts → thread-key.d.ts} +0 -0
- /package/dist/{codex-thread-key.js.map → thread-key.js.map} +0 -0
- /package/src/{codex-cli-fallback.ts → cli-fallback.ts} +0 -0
- /package/src/{codex-thread-key.ts → thread-key.ts} +0 -0
|
@@ -117,6 +117,16 @@ export type DeltaSurfaceResponse =
|
|
|
117
117
|
delta: {
|
|
118
118
|
commits: ReadonlyArray<{ sha: string; subject: string }>;
|
|
119
119
|
touchedFiles: readonly string[];
|
|
120
|
+
/**
|
|
121
|
+
* Uncapped total commit count (the {@link commits} slice is capped
|
|
122
|
+
* for transport). Issue #1630 fix 1.
|
|
123
|
+
*/
|
|
124
|
+
totalCommits: number;
|
|
125
|
+
/**
|
|
126
|
+
* Uncapped total touched-file count (the {@link touchedFiles} slice
|
|
127
|
+
* is capped for transport). Issue #1630 fix 1.
|
|
128
|
+
*/
|
|
129
|
+
totalTouchedFiles: number;
|
|
120
130
|
summaryLine: string;
|
|
121
131
|
};
|
|
122
132
|
nextState: LastSeenState;
|
|
@@ -161,6 +171,15 @@ export interface DeltaSurfaceStorage {
|
|
|
161
171
|
readonly memoryDir: string;
|
|
162
172
|
/** The resolved coding-scoped namespace — basis for the state filename. */
|
|
163
173
|
readonly namespace: string;
|
|
174
|
+
/**
|
|
175
|
+
* Whether the calling principal may WRITE the namespace — i.e. advance the
|
|
176
|
+
* last-seen-head marker. Read-only callers (e.g. a principal with read-but-
|
|
177
|
+
* not-write on the shared namespace) receive the computed delta but the
|
|
178
|
+
* state file is NOT advanced, so they cannot move another principal's
|
|
179
|
+
* baseline (issue #1630 fix 2). Defaults to `true` when omitted so existing
|
|
180
|
+
* callers (and the surface contract tests) keep their pre-fix behavior.
|
|
181
|
+
*/
|
|
182
|
+
readonly canAdvanceState?: boolean;
|
|
164
183
|
}
|
|
165
184
|
|
|
166
185
|
/**
|
|
@@ -265,13 +284,21 @@ async function deltaGet(
|
|
|
265
284
|
// 5. Persist the new state (rule 25 + rule 54). Failures here are logged
|
|
266
285
|
// but do NOT fail the operation — the delta was computed; the next
|
|
267
286
|
// session may re-derive it. A write failure surfaces in doctor/xray.
|
|
287
|
+
//
|
|
288
|
+
// Issue #1630 fix 2: the marker write is gated on a write-capable
|
|
289
|
+
// principal. A read-only caller (e.g. read-but-not-write on the shared
|
|
290
|
+
// namespace) receives the computed delta but does NOT advance the state
|
|
291
|
+
// marker, so it cannot move another principal's baseline. The default
|
|
292
|
+
// is `true` so legacy callers and the surface contract tests keep their
|
|
293
|
+
// pre-fix behavior.
|
|
294
|
+
const canAdvanceState = storage.canAdvanceState !== false;
|
|
268
295
|
let nextState: LastSeenState | null = null;
|
|
269
296
|
if (result.ok) {
|
|
270
297
|
nextState = result.nextState;
|
|
271
298
|
} else if (result.code === "unreachable_head") {
|
|
272
299
|
nextState = result.nextState;
|
|
273
300
|
}
|
|
274
|
-
if (nextState) {
|
|
301
|
+
if (nextState && canAdvanceState) {
|
|
275
302
|
try {
|
|
276
303
|
await writeLastSeenState(statePath, nextState);
|
|
277
304
|
} catch (err) {
|
|
@@ -312,6 +339,8 @@ async function deltaGet(
|
|
|
312
339
|
delta: {
|
|
313
340
|
commits: result.delta.commits,
|
|
314
341
|
touchedFiles: result.delta.touchedFiles,
|
|
342
|
+
totalCommits: result.delta.totalCommits,
|
|
343
|
+
totalTouchedFiles: result.delta.totalTouchedFiles,
|
|
315
344
|
summaryLine: result.delta.summaryLine,
|
|
316
345
|
},
|
|
317
346
|
nextState: result.nextState,
|
|
@@ -171,6 +171,52 @@ test("computeSessionDelta caps a large delta to MAX constants", () => {
|
|
|
171
171
|
assert.equal(result.delta.touchedFiles.length, MAX_DELTA_FILES);
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
test("computeSessionDelta reports uncapped totals alongside capped slices (issue #1630 fix 1)", () => {
|
|
175
|
+
// A repo exceeding both caps: 100 commits / 200 files. The slices are
|
|
176
|
+
// capped for transport, but totalCommits/totalTouchedFiles must report
|
|
177
|
+
// the TRUE delta size so summaries never under-report.
|
|
178
|
+
const commitCount = 100;
|
|
179
|
+
const fileCount = 200;
|
|
180
|
+
const commits: GitCommit[] = Array.from({ length: commitCount }, (_, i) => ({
|
|
181
|
+
sha: `c${i}`,
|
|
182
|
+
subject: `s ${i}`,
|
|
183
|
+
}));
|
|
184
|
+
const files = Array.from({ length: fileCount }, (_, i) => `f${i}.ts`);
|
|
185
|
+
const result = computeSessionDelta(PRIOR, slice("head", commits, files));
|
|
186
|
+
if (!result.ok || result.kind !== "changed") {
|
|
187
|
+
assert.fail(`expected changed, got ${JSON.stringify(result)}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// Capped display lists — transport-sized.
|
|
191
|
+
assert.equal(result.delta.commits.length, MAX_DELTA_COMMITS);
|
|
192
|
+
assert.equal(result.delta.touchedFiles.length, MAX_DELTA_FILES);
|
|
193
|
+
// Uncapped totals — the true delta size, NOT the capped slice length.
|
|
194
|
+
assert.equal(result.delta.totalCommits, commitCount);
|
|
195
|
+
assert.equal(result.delta.totalTouchedFiles, fileCount);
|
|
196
|
+
// The summary line must report the UNCAPPED totals, not the capped slice
|
|
197
|
+
// length — otherwise the briefing under-reports the delta size (codex review).
|
|
198
|
+
assert.match(result.delta.summaryLine, /100 commits, 200 files touched/);
|
|
199
|
+
assert.ok(!result.delta.summaryLine.match(/^.*20 commits, 50 files/), "summary must NOT use capped counts");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("computeSessionDelta totals equal slice lengths when under the cap (issue #1630 fix 1)", () => {
|
|
203
|
+
// Below the caps, totals === slice lengths (no information lost).
|
|
204
|
+
const commits: GitCommit[] = [
|
|
205
|
+
{ sha: "u1", subject: "under cap 1" },
|
|
206
|
+
{ sha: "u2", subject: "under cap 2" },
|
|
207
|
+
];
|
|
208
|
+
const files = ["a.ts", "b.ts", "c.ts"];
|
|
209
|
+
const result = computeSessionDelta(PRIOR, slice("head", commits, files));
|
|
210
|
+
if (!result.ok || result.kind !== "changed") {
|
|
211
|
+
assert.fail(`expected changed, got ${JSON.stringify(result)}`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
assert.equal(result.delta.totalCommits, 2);
|
|
215
|
+
assert.equal(result.delta.totalTouchedFiles, 3);
|
|
216
|
+
assert.equal(result.delta.totalCommits, result.delta.commits.length);
|
|
217
|
+
assert.equal(result.delta.totalTouchedFiles, result.delta.touchedFiles.length);
|
|
218
|
+
});
|
|
219
|
+
|
|
174
220
|
// ──────────────────────────────────────────────────────────────────────────
|
|
175
221
|
// State persistence — rule 25 (write after compute) + rule 54 (temp+rename)
|
|
176
222
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -72,6 +72,18 @@ export interface SessionDelta {
|
|
|
72
72
|
commits: GitCommit[];
|
|
73
73
|
/** Touched files since last seen, capped to {@link MAX_DELTA_FILES}. */
|
|
74
74
|
touchedFiles: string[];
|
|
75
|
+
/**
|
|
76
|
+
* Uncapped total commit count. The {@link commits} slice is capped for
|
|
77
|
+
* transport; this total reports the true delta size so summaries and
|
|
78
|
+
* metrics never under-report on large repos (issue #1630 fix 1).
|
|
79
|
+
*/
|
|
80
|
+
totalCommits: number;
|
|
81
|
+
/**
|
|
82
|
+
* Uncapped total touched-file count. The {@link touchedFiles} slice is
|
|
83
|
+
* capped for transport; this total reports the true delta size (issue
|
|
84
|
+
* #1630 fix 1).
|
|
85
|
+
*/
|
|
86
|
+
totalTouchedFiles: number;
|
|
75
87
|
/** A single human-readable summary line for briefing injection. */
|
|
76
88
|
summaryLine: string;
|
|
77
89
|
}
|
|
@@ -159,7 +171,12 @@ export function computeSessionDelta(
|
|
|
159
171
|
delta: {
|
|
160
172
|
commits,
|
|
161
173
|
touchedFiles,
|
|
162
|
-
|
|
174
|
+
// Uncapped totals — the slices above are capped for transport, but
|
|
175
|
+
// the summary/metrics must report the true delta size so callers
|
|
176
|
+
// never under-report on large repos (issue #1630 fix 1).
|
|
177
|
+
totalCommits: current.commits.length,
|
|
178
|
+
totalTouchedFiles: current.touchedFiles.length,
|
|
179
|
+
summaryLine: buildSummaryLine(current.commits.length, current.touchedFiles.length, lastSeen.at),
|
|
163
180
|
},
|
|
164
181
|
nextState,
|
|
165
182
|
};
|
package/src/config.test.ts
CHANGED
|
@@ -2050,3 +2050,35 @@ test("parseConfig rejects a present-but-non-string extractionFaithfulnessGate (s
|
|
|
2050
2050
|
// A present-but-unknown string still rejects (pre-existing behavior preserved).
|
|
2051
2051
|
assert.throws(() => parseConfig({ extractionFaithfulnessGate: "on" }), /extractionFaithfulnessGate must be one of/);
|
|
2052
2052
|
});
|
|
2053
|
+
|
|
2054
|
+
test("parseConfig extractionFaithfulnessContextChars rejects non-numeric and non-integer input (#1634)", () => {
|
|
2055
|
+
// Issue #1634 (#1576 follow-up): migrate from coerce+clamp/default to the
|
|
2056
|
+
// strict-integer validator used by qmdDaemonTimeoutMs / commitmentDecayDays.
|
|
2057
|
+
// A malformed budget must reject, not silently default or round.
|
|
2058
|
+
assert.equal(parseConfig({}).extractionFaithfulnessContextChars, 400);
|
|
2059
|
+
assert.equal(parseConfig({ extractionFaithfulnessContextChars: null }).extractionFaithfulnessContextChars, 400);
|
|
2060
|
+
assert.equal(parseConfig({ extractionFaithfulnessContextChars: "2400" }).extractionFaithfulnessContextChars, 2400);
|
|
2061
|
+
assert.equal(parseConfig({ extractionFaithfulnessContextChars: 5000 }).extractionFaithfulnessContextChars, 4000);
|
|
2062
|
+
for (const value of ["abc", "", 0, 1.5, "1.5", Number.NaN, Infinity, true, {}] as unknown[]) {
|
|
2063
|
+
assert.throws(
|
|
2064
|
+
() => parseConfig({ extractionFaithfulnessContextChars: value } as Record<string, unknown>),
|
|
2065
|
+
/extractionFaithfulnessContextChars must be an integer/,
|
|
2066
|
+
`invalid extractionFaithfulnessContextChars ${String(value)} should throw`,
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
test("parseConfig extractionFaithfulnessTimeoutMs rejects non-numeric and non-integer input (#1634)", () => {
|
|
2072
|
+
// Issue #1634: same strict-integer contract as extractionFaithfulnessContextChars.
|
|
2073
|
+
assert.equal(parseConfig({}).extractionFaithfulnessTimeoutMs, 8000);
|
|
2074
|
+
assert.equal(parseConfig({ extractionFaithfulnessTimeoutMs: null }).extractionFaithfulnessTimeoutMs, 8000);
|
|
2075
|
+
assert.equal(parseConfig({ extractionFaithfulnessTimeoutMs: "12000" }).extractionFaithfulnessTimeoutMs, 12_000);
|
|
2076
|
+
assert.equal(parseConfig({ extractionFaithfulnessTimeoutMs: 999_999 }).extractionFaithfulnessTimeoutMs, 60_000);
|
|
2077
|
+
for (const value of ["abc", "", 0, 1.5, "1.5", Number.NaN, Infinity, true, {}] as unknown[]) {
|
|
2078
|
+
assert.throws(
|
|
2079
|
+
() => parseConfig({ extractionFaithfulnessTimeoutMs: value } as Record<string, unknown>),
|
|
2080
|
+
/extractionFaithfulnessTimeoutMs must be an integer/,
|
|
2081
|
+
`invalid extractionFaithfulnessTimeoutMs ${String(value)} should throw`,
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -2927,18 +2927,18 @@ export function parseConfig(
|
|
|
2927
2927
|
typeof cfg.extractionFaithfulnessModel === "string"
|
|
2928
2928
|
? cfg.extractionFaithfulnessModel
|
|
2929
2929
|
: "",
|
|
2930
|
-
//
|
|
2931
|
-
//
|
|
2932
|
-
//
|
|
2933
|
-
//
|
|
2934
|
-
extractionFaithfulnessContextChars: (
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
extractionFaithfulnessTimeoutMs: (
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2930
|
+
// Issue #1634 (#1576 follow-up): strict-integer validation via
|
|
2931
|
+
// parseIntegerAtLeast — reject non-numeric, <=0, non-integer, NaN,
|
|
2932
|
+
// Infinity, booleans, objects (gotcha #51). Mirrors qmdDaemonTimeoutMs.
|
|
2933
|
+
// Valid CLI-string integers still coerce and clamp to the budget cap.
|
|
2934
|
+
extractionFaithfulnessContextChars: Math.min(
|
|
2935
|
+
parseIntegerAtLeast(cfg.extractionFaithfulnessContextChars, 400, 1, "extractionFaithfulnessContextChars"),
|
|
2936
|
+
4000,
|
|
2937
|
+
),
|
|
2938
|
+
extractionFaithfulnessTimeoutMs: Math.min(
|
|
2939
|
+
parseIntegerAtLeast(cfg.extractionFaithfulnessTimeoutMs, 8000, 1, "extractionFaithfulnessTimeoutMs"),
|
|
2940
|
+
60_000,
|
|
2941
|
+
),
|
|
2942
2942
|
// Inline source attribution (issue #369). Opt-in to preserve
|
|
2943
2943
|
// backwards compatibility with existing downstream consumers.
|
|
2944
2944
|
inlineSourceAttributionEnabled: cfg.inlineSourceAttributionEnabled === true,
|
|
@@ -794,12 +794,13 @@ test("applyFaithfulnessVerdict: per-fact backend failure (ok:false) → unchecke
|
|
|
794
794
|
assert.equal(r.enforceStatus, undefined);
|
|
795
795
|
});
|
|
796
796
|
|
|
797
|
-
test("locateFactQuote: finds the best-overlap sentence", () => {
|
|
797
|
+
test("locateFactQuote: finds the best-overlap sentence (returns LocatedQuote, issue #1633)", () => {
|
|
798
798
|
const src = "I drove to Berlin yesterday. My favorite editor is Vim.";
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
799
|
+
const located = locateFactQuote("The user likes Vim.", src);
|
|
800
|
+
assert.ok(located, "expected a located quote");
|
|
801
|
+
assert.equal(located!.quote, "My favorite editor is Vim.");
|
|
802
|
+
// offset must point at the matched candidate's actual position in sourceText.
|
|
803
|
+
assert.equal(located!.offset, src.indexOf("My favorite editor is Vim."));
|
|
803
804
|
});
|
|
804
805
|
|
|
805
806
|
test("locateFactQuote: returns undefined below the overlap threshold", () => {
|
|
@@ -812,6 +813,120 @@ test("locateFactQuote: empty inputs → undefined", () => {
|
|
|
812
813
|
assert.equal(locateFactQuote("fact", ""), undefined);
|
|
813
814
|
});
|
|
814
815
|
|
|
816
|
+
test("locateFactQuote: centers the bounded window on matched terms for >maxQuoteChars candidates (issue #1633, codex :747)", () => {
|
|
817
|
+
// The supporting terms ("PostgreSQL", "migration") fall well past the
|
|
818
|
+
// ~maxQuoteChars prefix. Returning the prefix would drop the evidence and
|
|
819
|
+
// route an actually-entailed fact to pending_review in enforce mode.
|
|
820
|
+
const filler = "Lorem ipsum dolor sit amet ".repeat(28); // ~728-char preamble
|
|
821
|
+
const sourceText = filler + "The team completed the PostgreSQL migration last quarter.";
|
|
822
|
+
const located = locateFactQuote(
|
|
823
|
+
"The team finished the PostgreSQL migration.",
|
|
824
|
+
sourceText,
|
|
825
|
+
200,
|
|
826
|
+
);
|
|
827
|
+
assert.ok(located, "expected a located quote");
|
|
828
|
+
assert.ok(
|
|
829
|
+
located!.quote.length <= 200,
|
|
830
|
+
`bounded quote must respect maxQuoteChars (got ${located!.quote.length})`,
|
|
831
|
+
);
|
|
832
|
+
// The matched evidence must survive truncation — the whole point of centering.
|
|
833
|
+
assert.ok(located!.quote.includes("PostgreSQL"), "bounded quote keeps 'PostgreSQL'");
|
|
834
|
+
assert.ok(located!.quote.includes("migration"), "bounded quote keeps 'migration'");
|
|
835
|
+
// offset must locate the returned (possibly bounded) quote within sourceText.
|
|
836
|
+
assert.equal(
|
|
837
|
+
sourceText.slice(located!.offset, located!.offset + located!.quote.length),
|
|
838
|
+
located!.quote,
|
|
839
|
+
"offset must point at the returned quote within sourceText",
|
|
840
|
+
);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test("locateFactQuote: tracks the matched occurrence offset for repeated anaphoric lines (issue #1633, codex :710, :710-thread-S)", () => {
|
|
844
|
+
// Two entities with the EXACT SAME anaphoric sentence afterward — the
|
|
845
|
+
// fact is about Zeta, which appears SECOND. Both "It launched in March."
|
|
846
|
+
// candidates score identically on own-overlap, so the locator must use the
|
|
847
|
+
// preceding-neighbor tiebreak to pick the occurrence whose neighbor ("Zeta")
|
|
848
|
+
// names the fact's entity. The offset must then point at the Zeta clause.
|
|
849
|
+
const sourceText =
|
|
850
|
+
"We started the Acme project in January. It launched in March. " +
|
|
851
|
+
"Then we began the Zeta initiative in February. It launched in March.";
|
|
852
|
+
const located = locateFactQuote("The Zeta initiative launched in March.", sourceText);
|
|
853
|
+
assert.ok(located, "expected a located quote");
|
|
854
|
+
// The matched quote is the (identical) anaphoric line; offset must point at
|
|
855
|
+
// the SECOND occurrence (the one after the Zeta clause), not the first.
|
|
856
|
+
const firstOccurrence = sourceText.indexOf("It launched in March.");
|
|
857
|
+
const secondOccurrence = sourceText.indexOf("It launched in March.", firstOccurrence + 1);
|
|
858
|
+
assert.ok(secondOccurrence > firstOccurrence, "test fixture must contain two occurrences");
|
|
859
|
+
assert.equal(
|
|
860
|
+
located!.offset,
|
|
861
|
+
secondOccurrence,
|
|
862
|
+
"offset must point at the second (Zeta) occurrence, not the first (Acme)",
|
|
863
|
+
);
|
|
864
|
+
assert.equal(
|
|
865
|
+
sourceText.slice(located!.offset, located!.offset + located!.quote.length),
|
|
866
|
+
located!.quote,
|
|
867
|
+
"offset must point at the start of the matched quote",
|
|
868
|
+
);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
test("locateFactQuote: bounded window keeps the densest evidence cluster when matches span wider than maxQuoteChars (codex :747-thread-O)", () => {
|
|
872
|
+
// Fact tokens appear at BOTH ENDS of a long candidate with filler between.
|
|
873
|
+
// A naive midpoint-centered window would land in the filler and contain no
|
|
874
|
+
// evidence. The densest-cluster window must capture actual matched terms.
|
|
875
|
+
const head = "PostgreSQL migration completed. "; // matches at the very start
|
|
876
|
+
const filler = "and ".repeat(220); // ~880 chars of filler (no fact tokens)
|
|
877
|
+
const tail = " for the user."; // matches at the very end
|
|
878
|
+
const sourceText = head + filler + tail; // one long sentence, no period inside
|
|
879
|
+
// Fact spans tokens from both ends: postgresql, migration (head) + user (tail).
|
|
880
|
+
const located = locateFactQuote(
|
|
881
|
+
"The user completed the PostgreSQL migration.",
|
|
882
|
+
sourceText,
|
|
883
|
+
120,
|
|
884
|
+
);
|
|
885
|
+
assert.ok(located, "expected a located quote");
|
|
886
|
+
assert.ok(
|
|
887
|
+
located!.quote.length <= 120,
|
|
888
|
+
`bounded quote must respect maxQuoteChars (got ${located!.quote.length})`,
|
|
889
|
+
);
|
|
890
|
+
// The densest cluster is the head (2 matches: postgresql, migration); the
|
|
891
|
+
// window must contain at least one of them. (user appears alone at the tail,
|
|
892
|
+
// so a tail-anchored window would be sparser and is not chosen.)
|
|
893
|
+
assert.ok(
|
|
894
|
+
located!.quote.includes("PostgreSQL") || located!.quote.includes("migration"),
|
|
895
|
+
"bounded window must include matched evidence, not pure filler",
|
|
896
|
+
);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test("locateFactQuote: bounded window stays linear with many repeated matched tokens (codex PRRT_kwDORJXyws6Ocih3)", () => {
|
|
900
|
+
// A long unpunctuated candidate with thousands of repeats of a fact token
|
|
901
|
+
// (pasted logs / minified text). The densest-cluster selection must stay
|
|
902
|
+
// O(n) via the two-pointer sliding window; the O(n^2) nested loop would
|
|
903
|
+
// stall extraction on ~5k repeats. This test asserts both correctness (the
|
|
904
|
+
// bounded window contains the densest cluster) and that the call returns
|
|
905
|
+
// promptly — under O(n^2) this fixture (~5k repeats) would take seconds.
|
|
906
|
+
const repeats = 5000;
|
|
907
|
+
const token = "PostgreSQL "; // one fact token per repeat
|
|
908
|
+
const filler = "x ".repeat(50); // leading filler so the cluster is mid-string
|
|
909
|
+
const sourceText = filler + token.repeat(repeats) + "migration done";
|
|
910
|
+
const start = Date.now();
|
|
911
|
+
const located = locateFactQuote("PostgreSQL migration done.", sourceText, 200);
|
|
912
|
+
const elapsed = Date.now() - start;
|
|
913
|
+
assert.ok(located, "expected a located quote");
|
|
914
|
+
assert.ok(
|
|
915
|
+
located!.quote.length <= 200,
|
|
916
|
+
`bounded quote must respect maxQuoteChars (got ${located!.quote.length})`,
|
|
917
|
+
);
|
|
918
|
+
// The densest cluster is the run of PostgreSQL repeats; the window must be
|
|
919
|
+
// anchored inside it (not in the leading filler) and contain evidence.
|
|
920
|
+
assert.ok(
|
|
921
|
+
located!.quote.includes("PostgreSQL"),
|
|
922
|
+
"bounded window must anchor inside the densest cluster",
|
|
923
|
+
);
|
|
924
|
+
assert.ok(
|
|
925
|
+
elapsed < 1000,
|
|
926
|
+
`bounded window must stay linear (took ${elapsed}ms for ${repeats} repeats)`,
|
|
927
|
+
);
|
|
928
|
+
});
|
|
929
|
+
|
|
815
930
|
test("applyFaithfulnessVerdict: bumps verdict counters at apply time (cursor review)", () => {
|
|
816
931
|
const counters = createFaithfulnessCounters();
|
|
817
932
|
const map: Map<number, FaithfulnessResult> = new Map([
|
|
@@ -1042,6 +1157,35 @@ test("extractContextWindow: returns undefined when quote is absent from sourceTe
|
|
|
1042
1157
|
assert.equal(extractContextWindow("source", "s", 0), undefined);
|
|
1043
1158
|
});
|
|
1044
1159
|
|
|
1160
|
+
test("extractContextWindow: matchedOffset selects the matched occurrence, not the first (issue #1633, codex :710)", () => {
|
|
1161
|
+
// The same anaphoric line appears twice — once about Acme, once about Zeta.
|
|
1162
|
+
// Without matchedOffset, indexOf centers context on Acme (the first hit).
|
|
1163
|
+
// With the locator's offset, the window must center on the matched (Zeta) hit.
|
|
1164
|
+
const sourceText =
|
|
1165
|
+
"Acme context before. It launched in March. Acme context after. " +
|
|
1166
|
+
"Zeta context before. It launched in March. Zeta context after.";
|
|
1167
|
+
const quote = "It launched in March.";
|
|
1168
|
+
const firstOccurrence = sourceText.indexOf(quote);
|
|
1169
|
+
const zetaOccurrence = sourceText.indexOf("Zeta context before");
|
|
1170
|
+
assert.ok(firstOccurrence >= 0 && zetaOccurrence > firstOccurrence);
|
|
1171
|
+
// Budget 50 captures the surrounding entity tag without spanning both
|
|
1172
|
+
// occurrences (which would make them indistinguishable).
|
|
1173
|
+
const ctxDefault = extractContextWindow(sourceText, quote, 50);
|
|
1174
|
+
const ctxMatched = extractContextWindow(sourceText, quote, 50, zetaOccurrence);
|
|
1175
|
+
const ctxFirst = extractContextWindow(sourceText, quote, 50, firstOccurrence);
|
|
1176
|
+
assert.ok(ctxDefault && ctxMatched && ctxFirst, "all windows must resolve");
|
|
1177
|
+
assert.ok(
|
|
1178
|
+
ctxDefault!.includes("Acme") && !ctxDefault!.includes("Zeta"),
|
|
1179
|
+
"default (no offset) centers on the first (Acme) occurrence, not Zeta",
|
|
1180
|
+
);
|
|
1181
|
+
assert.ok(
|
|
1182
|
+
ctxMatched!.includes("Zeta") && !ctxMatched!.includes("Acme"),
|
|
1183
|
+
"matchedOffset centers on the matched (Zeta) occurrence, not Acme",
|
|
1184
|
+
);
|
|
1185
|
+
assert.notEqual(ctxMatched, ctxDefault, "windows must differ when offset disambiguates");
|
|
1186
|
+
assert.equal(ctxFirst, ctxDefault, "explicit first-occurrence offset equals the default");
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1045
1189
|
test("runFaithfulnessGateBatch: fallback locator injects CONTEXT into the verifier prompt (codex P2 PRRT_kwDORJXyws6OblI1)", async () => {
|
|
1046
1190
|
// No #1575 sources → the fallback locator locates a quote from sourceText.
|
|
1047
1191
|
// Previously the input never set `context`, so the surrounding turn text
|
|
@@ -718,9 +718,26 @@ export function extractContextWindow(
|
|
|
718
718
|
sourceText: string,
|
|
719
719
|
quote: string,
|
|
720
720
|
contextChars: number,
|
|
721
|
+
/**
|
|
722
|
+
* Character offset of `quote` within `sourceText` (from `locateFactQuote`).
|
|
723
|
+
* When the quote string appears more than once — e.g. repeated anaphoric
|
|
724
|
+
* lines after different entities — pass the matched occurrence's offset so
|
|
725
|
+
* the context window centers on the actual match, not the first occurrence
|
|
726
|
+
* (issue #1633, codex PRRT_kwDORJXyws6ObzwI). Defaults to the first
|
|
727
|
+
* occurrence via indexOf for backward compatibility.
|
|
728
|
+
*/
|
|
729
|
+
matchedOffset?: number,
|
|
721
730
|
): string | undefined {
|
|
722
731
|
if (!sourceText || !quote || !(contextChars > 0)) return undefined;
|
|
723
|
-
|
|
732
|
+
let idx: number;
|
|
733
|
+
if (matchedOffset !== undefined && matchedOffset >= 0) {
|
|
734
|
+
const at = sourceText.indexOf(quote, matchedOffset);
|
|
735
|
+
// Fall back to the first occurrence if the matched offset is stale (e.g.
|
|
736
|
+
// the quote was bounded and is not a literal substring at that offset).
|
|
737
|
+
idx = at >= 0 ? at : sourceText.indexOf(quote);
|
|
738
|
+
} else {
|
|
739
|
+
idx = sourceText.indexOf(quote);
|
|
740
|
+
}
|
|
724
741
|
if (idx < 0) return undefined;
|
|
725
742
|
const quoteEnd = idx + quote.length;
|
|
726
743
|
const center = Math.floor((idx + quoteEnd) / 2);
|
|
@@ -733,31 +750,179 @@ export function extractContextWindow(
|
|
|
733
750
|
return window.length > 0 ? window : undefined;
|
|
734
751
|
}
|
|
735
752
|
|
|
753
|
+
/**
|
|
754
|
+
* A located fallback quote plus the offset it begins at in the source text.
|
|
755
|
+
* `offset` lets `extractContextWindow` disambiguate repeated occurrences of
|
|
756
|
+
* the same quote string (issue #1633).
|
|
757
|
+
*/
|
|
758
|
+
export interface LocatedQuote {
|
|
759
|
+
/** Verbatim source span, centered on the matched terms when truncated. */
|
|
760
|
+
quote: string;
|
|
761
|
+
/** Character offset of `quote` within sourceText. */
|
|
762
|
+
offset: number;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
interface SourceCandidate {
|
|
766
|
+
/** Trimmed candidate text. */
|
|
767
|
+
text: string;
|
|
768
|
+
/** Character offset of `text` within the source string. */
|
|
769
|
+
start: number;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Split source text into candidate spans (sentences, then line segments as a
|
|
774
|
+
* fallback for transcripts without sentence punctuation), tracking each
|
|
775
|
+
* candidate's start offset so repeated occurrences can be disambiguated
|
|
776
|
+
* (issue #1633).
|
|
777
|
+
*/
|
|
778
|
+
function splitSourceCandidates(sourceText: string): SourceCandidate[] {
|
|
779
|
+
const candidates: SourceCandidate[] = [];
|
|
780
|
+
const re = /(?<=[.!?])\s+|\n+/g;
|
|
781
|
+
let lastEnd = 0;
|
|
782
|
+
let m: RegExpExecArray | null;
|
|
783
|
+
while ((m = re.exec(sourceText)) !== null) {
|
|
784
|
+
pushCandidate(candidates, sourceText, lastEnd, m.index);
|
|
785
|
+
lastEnd = re.lastIndex;
|
|
786
|
+
}
|
|
787
|
+
pushCandidate(candidates, sourceText, lastEnd, sourceText.length);
|
|
788
|
+
return candidates;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function pushCandidate(
|
|
792
|
+
out: SourceCandidate[],
|
|
793
|
+
source: string,
|
|
794
|
+
begin: number,
|
|
795
|
+
end: number,
|
|
796
|
+
): void {
|
|
797
|
+
const seg = source.slice(begin, end);
|
|
798
|
+
const leadingWS = seg.length - seg.trimStart().length;
|
|
799
|
+
const trimmed = seg.trim();
|
|
800
|
+
if (trimmed.length > 0) {
|
|
801
|
+
out.push({ text: trimmed, start: begin + leadingWS });
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Locate the start offsets of fact-token matches inside a candidate. Used by
|
|
807
|
+
* `locateFactQuote` to build a bounded window that is guaranteed to contain
|
|
808
|
+
* real evidence (issue #1633, codex PRRT_kwDORJXyws6Obrwe / PRRT_kwDORJXyws6Oce-O).
|
|
809
|
+
*/
|
|
810
|
+
function locateMatchedTokens(factTokens: Set<string>, candidate: string): number[] {
|
|
811
|
+
const lower = candidate.toLowerCase();
|
|
812
|
+
const wordRe = /[a-z0-9]+/g;
|
|
813
|
+
const positions: number[] = [];
|
|
814
|
+
let m: RegExpExecArray | null;
|
|
815
|
+
while ((m = wordRe.exec(lower)) !== null) {
|
|
816
|
+
const word = m[0];
|
|
817
|
+
if (word.length <= 1) continue;
|
|
818
|
+
if (STOPWORDS.has(word)) continue;
|
|
819
|
+
if (factTokens.has(crudeStem(word))) {
|
|
820
|
+
positions.push(m.index);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return positions;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Build a bounded window of `maxChars` from `text` that contains the densest
|
|
828
|
+
* cluster of matched-token positions. Slides a maxChars-wide window anchored
|
|
829
|
+
* at each matched token and keeps the one that captures the most matches
|
|
830
|
+
* (tiebreak: earliest anchor). This guarantees the window includes real
|
|
831
|
+
* evidence even when the matched span is wider than `maxChars` (e.g. fact
|
|
832
|
+
* tokens at both ends of a long sentence with filler between). Centering on
|
|
833
|
+
* the densest cluster's midpoint then uses any leftover budget as leading and
|
|
834
|
+
* trailing context. Returns the window text and its start offset within `text`
|
|
835
|
+
* so callers can translate the in-candidate offset back to a source-text offset.
|
|
836
|
+
*/
|
|
837
|
+
function boundedWindow(
|
|
838
|
+
text: string,
|
|
839
|
+
matchedPositions: number[],
|
|
840
|
+
maxChars: number,
|
|
841
|
+
): { text: string; start: number } {
|
|
842
|
+
if (matchedPositions.length === 0) {
|
|
843
|
+
// Defensive: overlap scoring accepted the candidate but no individual
|
|
844
|
+
// token matched (e.g. all matches were stopwords). Fall back to prefix.
|
|
845
|
+
const end = Math.min(text.length, maxChars);
|
|
846
|
+
return { text: text.slice(0, end), start: 0 };
|
|
847
|
+
}
|
|
848
|
+
// matchedPositions is sorted ascending (the regex scans left-to-right), so a
|
|
849
|
+
// two-pointer sliding window finds the densest maxChars-wide cluster in O(n)
|
|
850
|
+
// instead of O(n^2). This matters when a long unpunctuated candidate carries
|
|
851
|
+
// many repeats of a fact token (pasted logs, minified text) — thousands of
|
|
852
|
+
// positions would otherwise stall extraction before the LLM call (codex
|
|
853
|
+
// PRRT_kwDORJXyws6Ocih3).
|
|
854
|
+
let bestAnchor = matchedPositions[0]!;
|
|
855
|
+
let bestCount = 0;
|
|
856
|
+
let bestLast = bestAnchor;
|
|
857
|
+
let j = 0;
|
|
858
|
+
for (let i = 0; i < matchedPositions.length; i++) {
|
|
859
|
+
const anchor = matchedPositions[i]!;
|
|
860
|
+
const winEnd = anchor + maxChars;
|
|
861
|
+
if (j < i) j = i;
|
|
862
|
+
while (j + 1 < matchedPositions.length && matchedPositions[j + 1]! < winEnd) {
|
|
863
|
+
j++;
|
|
864
|
+
}
|
|
865
|
+
const count = j - i + 1;
|
|
866
|
+
if (count > bestCount) {
|
|
867
|
+
bestCount = count;
|
|
868
|
+
bestAnchor = anchor;
|
|
869
|
+
bestLast = matchedPositions[j]!;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
// The densest cluster [bestAnchor, bestLast] fits within maxChars by
|
|
873
|
+
// construction; center a maxChars window on its midpoint, clamped to text.
|
|
874
|
+
const center = Math.floor((bestAnchor + bestLast) / 2);
|
|
875
|
+
const half = Math.floor(maxChars / 2);
|
|
876
|
+
let start = Math.max(0, center - half);
|
|
877
|
+
const end = Math.min(text.length, start + maxChars);
|
|
878
|
+
// Re-anchor start so the window uses the full budget when end clamped.
|
|
879
|
+
start = Math.max(0, end - maxChars);
|
|
880
|
+
return { text: text.slice(start, end), start };
|
|
881
|
+
}
|
|
882
|
+
|
|
736
883
|
export function locateFactQuote(
|
|
737
884
|
factText: string,
|
|
738
885
|
sourceText: string,
|
|
739
886
|
maxQuoteChars = 600,
|
|
740
|
-
):
|
|
887
|
+
): LocatedQuote | undefined {
|
|
741
888
|
if (!factText || !sourceText) return undefined;
|
|
742
889
|
const factTokens = tokenize(factText);
|
|
743
890
|
if (factTokens.size === 0) return undefined;
|
|
744
|
-
|
|
745
|
-
// fallback for transcripts without sentence punctuation.
|
|
746
|
-
const candidates: string[] = [];
|
|
747
|
-
for (const sentence of sourceText.split(/(?<=[.!?])\s+|\n+/)) {
|
|
748
|
-
const s = sentence.trim();
|
|
749
|
-
if (s.length > 0) candidates.push(s);
|
|
750
|
-
}
|
|
891
|
+
const candidates = splitSourceCandidates(sourceText);
|
|
751
892
|
if (candidates.length === 0) return undefined;
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
893
|
+
// Pick the best-overlap candidate. Tiebreak equal own-overlap scores by a
|
|
894
|
+
// "context" score that includes the immediately PRECEDING candidate's text,
|
|
895
|
+
// so a repeated anaphoric line ("It launched in March") resolves to the
|
|
896
|
+
// occurrence whose neighbor names the fact's entity (issue #1633, codex
|
|
897
|
+
// PRRT_kwDORJXyws6Oce-S). Without this tiebreak the first occurrence wins
|
|
898
|
+
// even when the second is the one the fact refers to.
|
|
899
|
+
let best: { candidate: SourceCandidate; score: number; contextScore: number } | null = null;
|
|
900
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
901
|
+
const candidate = candidates[i]!;
|
|
902
|
+
const score = overlapCoefficient(factTokens, tokenize(candidate.text));
|
|
903
|
+
const prevText = i > 0 ? candidates[i - 1]!.text : "";
|
|
904
|
+
const contextText = prevText ? `${prevText} ${candidate.text}` : candidate.text;
|
|
905
|
+
const contextScore = overlapCoefficient(factTokens, tokenize(contextText));
|
|
906
|
+
if (
|
|
907
|
+
!best ||
|
|
908
|
+
score > best.score ||
|
|
909
|
+
(score === best.score && contextScore > best.contextScore)
|
|
910
|
+
) {
|
|
911
|
+
best = { candidate, score, contextScore };
|
|
912
|
+
}
|
|
756
913
|
}
|
|
757
914
|
if (!best || best.score < LOCATE_QUOTE_MIN_OVERLAP) return undefined;
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
:
|
|
915
|
+
const { text, start } = best.candidate;
|
|
916
|
+
if (text.length <= maxQuoteChars) {
|
|
917
|
+
return { quote: text, offset: start };
|
|
918
|
+
}
|
|
919
|
+
// Build a bounded window around the densest cluster of matched terms instead
|
|
920
|
+
// of returning the first maxQuoteChars prefix (issue #1633). Returning the
|
|
921
|
+
// prefix drops supporting words that fall after char ~600, routing
|
|
922
|
+
// actually-entailed facts to pending_review in enforce mode.
|
|
923
|
+
const matched = locateMatchedTokens(factTokens, text);
|
|
924
|
+
const win = boundedWindow(text, matched, maxQuoteChars);
|
|
925
|
+
return { quote: win.text, offset: start + win.start };
|
|
761
926
|
}
|
|
762
927
|
export async function runFaithfulnessGateBatch(
|
|
763
928
|
facts: readonly FaithfulnessGateFact[],
|
|
@@ -793,11 +958,19 @@ export async function runFaithfulnessGateBatch(
|
|
|
793
958
|
.map((s) => (s && typeof s.quote === "string" ? s.quote.trim() : ""))
|
|
794
959
|
.filter((q) => q.length > 0);
|
|
795
960
|
const usingFallbackLocator = sourceQuotes.length === 0;
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
961
|
+
let quote: string;
|
|
962
|
+
let matchedOffset: number | undefined;
|
|
963
|
+
if (sourceQuotes.length > 0) {
|
|
964
|
+
quote = sourceQuotes.join("\n");
|
|
965
|
+
} else {
|
|
966
|
+
// Fallback locator returns the matched span AND its offset so the
|
|
967
|
+
// context window centers on the occurrence that actually matched the
|
|
968
|
+
// fact, not the first indexOf hit (issue #1633).
|
|
969
|
+
const located = locateFactQuote(f.content, sourceText);
|
|
970
|
+
if (!located) continue; // no located span — applyFaithfulnessVerdict tags skipped_no_span
|
|
971
|
+
quote = located.quote;
|
|
972
|
+
matchedOffset = located.offset;
|
|
973
|
+
}
|
|
801
974
|
// Pass source context into the verifier so extractionFaithfulnessContextChars
|
|
802
975
|
// actually applies in the fallback-locator path (codex P2
|
|
803
976
|
// PRRT_kwDORJXyws6OblI1). #1575 verified spans already carry full evidence,
|
|
@@ -808,6 +981,7 @@ export async function runFaithfulnessGateBatch(
|
|
|
808
981
|
sourceText,
|
|
809
982
|
quote,
|
|
810
983
|
config.extractionFaithfulnessContextChars,
|
|
984
|
+
matchedOffset,
|
|
811
985
|
)
|
|
812
986
|
: undefined;
|
|
813
987
|
inputs.push({
|
package/src/fallback-llm.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import test from "node:test";
|
|
4
4
|
|
|
5
5
|
import { FallbackLlmClient, gatewayTaskChainOptions } from "./fallback-llm.js";
|
|
6
|
-
import { __codexCliFallbackTestHooks } from "./
|
|
6
|
+
import { __codexCliFallbackTestHooks } from "./cli-fallback.js";
|
|
7
7
|
import { clearModelsJsonCache, __setModelsJsonForTest } from "./models-json.js";
|
|
8
8
|
import {
|
|
9
9
|
__setGatewayRuntimeAuthForModelForTest,
|