@aiwayds/dsh-topics-memory 0.18.2 → 0.20.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/lib/jev/log.js ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * decisions.jsonl — System One usage/optimization telemetry (design
3
+ * 2026-09-25 §6), always on while jevEnabled is on. Lives beside the ilog at
4
+ * `<bundle>/meta/decisions.jsonl` under the same discipline: 512KB
5
+ * auto-compaction keeping the most recent quarter, line JSON, and write
6
+ * failures swallowed silently — fail-open extends to the stats itself.
7
+ *
8
+ * REDACTION INVARIANT (§6.1): metadata and probabilities only — slug/pair
9
+ * hash/question type/score/latency/token count. NEVER the state text or
10
+ * conclusion bodies. Writers copy an explicit field whitelist, so extra
11
+ * fields on caller objects cannot leak into the file.
12
+ *
13
+ * @module jev/log
14
+ */
15
+ import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
16
+ import { createHash, randomUUID } from 'node:crypto';
17
+ import { dirname, join } from 'node:path';
18
+ import { metaDir, resolveBundleRoot } from "../paths.js";
19
+ const COMPACT_LIMIT = 512 * 1024; // ~512KB cap, same as the ilog sidecars
20
+ export function decisionsFile(root) {
21
+ return join(metaDir(root), 'decisions.jsonl');
22
+ }
23
+ function liveFile() {
24
+ return decisionsFile(resolveBundleRoot());
25
+ }
26
+ /** digest(state, questionId, instructions): `h1:` + first 16 hex of
27
+ * sha256(state + NUL + questionId + NUL + instructions) — the key the
28
+ * repeat-decision-rate analysis joins on. */
29
+ export function digestFor(state, questionId, instructions) {
30
+ const h = createHash('sha256');
31
+ h.update(state);
32
+ h.update('\u0000');
33
+ h.update(questionId);
34
+ h.update('\u0000');
35
+ h.update(instructions);
36
+ return `h1:${h.digest('hex').slice(0, 16)}`;
37
+ }
38
+ // Serialized like the store's write queue: single-record appends are
39
+ // line-atomic anyway, but the compaction rewrite must not interleave with
40
+ // an append.
41
+ let queue = Promise.resolve();
42
+ function enqueue(op) {
43
+ const run = queue.then(op, op);
44
+ queue = run.catch(() => undefined);
45
+ return run;
46
+ }
47
+ /** All write failures die HERE — never upstream. */
48
+ async function appendJsonl(lines) {
49
+ await enqueue(async () => {
50
+ try {
51
+ const file = liveFile();
52
+ await mkdir(dirname(file), { recursive: true });
53
+ await appendFile(file, lines.map((l) => `${l}\n`).join(''), 'utf8');
54
+ await compactIfNeeded(file);
55
+ }
56
+ catch {
57
+ // fail-open: a broken stats sink must never break the feature (§6.1)
58
+ }
59
+ });
60
+ }
61
+ // store.ts compactInjectionsIfNeeded pattern: ~512KB cap; keep the most
62
+ // recent quarter when exceeded (headroom for the next window).
63
+ async function compactIfNeeded(file) {
64
+ let raw;
65
+ try {
66
+ raw = await readFile(file, 'utf8');
67
+ }
68
+ catch {
69
+ return;
70
+ }
71
+ if (raw.length <= COMPACT_LIMIT)
72
+ return;
73
+ const lines = raw.split('\n').filter((l) => l.trim() !== '');
74
+ const keep = lines.slice(-Math.max(1, Math.floor(lines.length / 4)));
75
+ const tmp = `${file}.tmp-${randomUUID()}`;
76
+ try {
77
+ await writeFile(tmp, keep.map((l) => `${l}\n`).join(''), 'utf8');
78
+ await rename(tmp, file);
79
+ }
80
+ catch {
81
+ // swallow — the next append retries the compaction
82
+ }
83
+ }
84
+ /** Call-layer row. Field whitelist enforced here: anything extra on the
85
+ * caller's object is dropped, so no state/conclusion text can sneak in. */
86
+ export async function logCall(rec) {
87
+ await appendJsonl([
88
+ JSON.stringify({
89
+ at: rec.at,
90
+ lane: rec.lane,
91
+ backend: rec.backend,
92
+ model: rec.model,
93
+ questionCount: rec.questionCount,
94
+ stateChars: rec.stateChars,
95
+ latencyMs: rec.latencyMs,
96
+ usage: rec.usage === null
97
+ ? null
98
+ : { input_tokens: rec.usage.input_tokens, output_tokens: rec.usage.output_tokens },
99
+ outcome: rec.outcome,
100
+ fallback: rec.fallback,
101
+ }),
102
+ ]);
103
+ }
104
+ /** Verdict-layer rows (batch expanded by the caller). */
105
+ export async function logVerdicts(recs) {
106
+ if (recs.length === 0)
107
+ return;
108
+ await appendJsonl(recs.map((rec) => JSON.stringify({
109
+ at: rec.at,
110
+ lane: rec.lane,
111
+ questionId: rec.questionId,
112
+ qtype: rec.qtype,
113
+ ref: rec.ref,
114
+ digest: rec.digest,
115
+ probability: rec.probability,
116
+ band: rec.band,
117
+ agree: rec.agree,
118
+ backend: rec.backend ?? 'primary',
119
+ degraded: rec.degraded === true,
120
+ })));
121
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * System One decision bands (design 2026-09-25 §5).
3
+ *
4
+ * The numbers are CALIBRATED — single-case A/B plus a 383-case threshold
5
+ * sweep (2026-09-25) — and bound to the batch wire protocol (protocol-drift
6
+ * evidence: the same case scores differently single vs batch). Changing the
7
+ * protocol requires a re-sweep; do not tune these in place.
8
+ *
9
+ * @module jev/thresholds
10
+ */
11
+ /** Slow-lane rerank (候选问): adopt ≥ 0.60, strong-veto < 0.10. */
12
+ export declare const RERANK_ADOPT = 0.6;
13
+ export declare const RERANK_FALLBACK = 0.1;
14
+ /** Consolidation prefilter (pair 问「同一问题吗」): adopt ≥ 0.50, veto < 0.15. */
15
+ export declare const MERGE_PAIR_ADOPT = 0.5;
16
+ export declare const MERGE_PAIR_FALLBACK = 0.15;
17
+ export type JevBand = 'adopt' | 'record' | 'fallback';
18
+ export type JevBandKind = 'rerank' | 'mergePair';
19
+ /** Map a noul probability to its band: adopt → act on it, record → only log
20
+ * it, fallback → hard veto back to the lexical/legacy behavior. */
21
+ export declare function band(probability: number, kind: JevBandKind): JevBand;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * System One decision bands (design 2026-09-25 §5).
3
+ *
4
+ * The numbers are CALIBRATED — single-case A/B plus a 383-case threshold
5
+ * sweep (2026-09-25) — and bound to the batch wire protocol (protocol-drift
6
+ * evidence: the same case scores differently single vs batch). Changing the
7
+ * protocol requires a re-sweep; do not tune these in place.
8
+ *
9
+ * @module jev/thresholds
10
+ */
11
+ /** Slow-lane rerank (候选问): adopt ≥ 0.60, strong-veto < 0.10. */
12
+ export const RERANK_ADOPT = 0.6;
13
+ export const RERANK_FALLBACK = 0.1;
14
+ /** Consolidation prefilter (pair 问「同一问题吗」): adopt ≥ 0.50, veto < 0.15. */
15
+ export const MERGE_PAIR_ADOPT = 0.5;
16
+ export const MERGE_PAIR_FALLBACK = 0.15;
17
+ /** Map a noul probability to its band: adopt → act on it, record → only log
18
+ * it, fallback → hard veto back to the lexical/legacy behavior. */
19
+ export function band(probability, kind) {
20
+ const floor = kind === 'rerank' ? RERANK_FALLBACK : MERGE_PAIR_FALLBACK;
21
+ const adopt = kind === 'rerank' ? RERANK_ADOPT : MERGE_PAIR_ADOPT;
22
+ if (probability >= adopt)
23
+ return 'adopt';
24
+ if (probability >= floor)
25
+ return 'record';
26
+ return 'fallback';
27
+ }
package/lib/quality.d.ts CHANGED
@@ -5,8 +5,12 @@
5
5
  * 1. intent-query build over the observer's last-K ring buffer — kills
6
6
  * verbatim priming at the root (the query is what the model needs, not
7
7
  * what the user typed);
8
- * 2. lexical candidate band (hits + near-misses, recall-oriented) then an
9
- * LLM rerank that releases 0-2 picks, each with a one-line why.
8
+ * 2. lexical candidate band (hits + near-misses, recall-oriented) then a
9
+ * rerank that releases 0-2 picks, each with a one-line why. With
10
+ * `jevEnabled` on (design 2026-09-25 §2) the rerank is a batched System
11
+ * One noul request, probability-band gated; any failure fails open to the
12
+ * legacy LLM rerank below. The same trigger also runs the fastgate
13
+ * SHADOW (zero behavior, decisions.jsonl only).
10
14
  *
11
15
  * Picks rest in a per-session pending slot and inject at the NEXT spliced
12
16
  * (steer message), then the slot is gone (消费即清). Hard bounds: per-call
@@ -28,8 +32,32 @@ export declare const CALL_TIMEOUT_MS = 20000;
28
32
  export declare const PIPELINE_TIMEOUT_MS = 45000;
29
33
  /** qualityLane 'sampled' runs on every Nth turn (design default 1/3). */
30
34
  export declare const SAMPLED_EVERY = 3;
35
+ /** Fastgate shadow cap (design §2 row 3): topK hits + near-misses, ≤8 questions. */
36
+ export declare const FASTGATE_MAX_QUESTIONS = 8;
31
37
  export declare const QUERY_BUILD_PROMPT: string;
32
38
  export declare const RERANK_PROMPT: string;
39
+ /**
40
+ * §6.2 state for one batch: the round's query plus the task frame. Shared by
41
+ * the rerank and the fastgate shadow so both seams speak ONE batch protocol —
42
+ * the calibrated bands are protocol-bound (design §5), a second state shape
43
+ * would need its own sweep.
44
+ */
45
+ export declare function jevState(query: string): string;
46
+ /** The lexical disposition the fast lane recorded for one candidate — the
47
+ * agree-reconciliation key (§6.2; retrieval.ts hits/nearMisses split). */
48
+ export type FastGateDisposition = 'hit' | 'nearFloor' | 'gate-blocked';
49
+ export interface FastGateCandidate {
50
+ slug: string;
51
+ disposition: FastGateDisposition;
52
+ }
53
+ export interface FastGateShadowInput {
54
+ /** The fast-lane round's query (the claimed text), captured at spliced. */
55
+ query: string;
56
+ /** hits + near-misses of that round, with their lexical dispositions. */
57
+ candidates: readonly FastGateCandidate[];
58
+ /** Observer turn count at the trigger — the sampled-cadence clock. */
59
+ turnId: number;
60
+ }
33
61
  export interface PendingInjection {
34
62
  items: {
35
63
  slug: string;
@@ -64,6 +92,8 @@ export declare function parseJsonObject(raw: string): Record<string, unknown>;
64
92
  export declare class SlowLane {
65
93
  private pending;
66
94
  private inFlight;
95
+ /** Same-session shadow serialization — a second trigger never overlaps. */
96
+ private shadowInFlight;
67
97
  private readonly service;
68
98
  private readonly caller;
69
99
  /**
@@ -81,6 +111,19 @@ export declare class SlowLane {
81
111
  hasInFlight(sessionId: string): boolean;
82
112
  /** Drop the pending slot (session teardown, restore boundary). */
83
113
  clear(sessionId: string): void;
114
+ /**
115
+ * Fastgate shadow (design §2 row 3): fire-and-forget on the SAME sampled
116
+ * turn-end trigger as the slow lane, zero behavior — verdicts land only in
117
+ * decisions.jsonl, reconciled against the lexical disposition. Needs no LLM
118
+ * caller, so it also runs on turns where the slow lane itself yields (distill
119
+ * in flight, no model configured). `dispatch()`'s own guards (lane mode,
120
+ * sampled cadence) are mirrored here so both seams share one cadence.
121
+ */
122
+ dispatchFastGateShadow(sessionId: string, input: FastGateShadowInput): void;
123
+ /** Shadow body: resolve candidate content from the roster, one batched noul
124
+ * request, verdict rows only. A jevAsk failure leaves just its auto-written
125
+ * call row — there is no legacy path to fall back to, and nothing to gate. */
126
+ private fastGateShadow;
84
127
  /**
85
128
  * Produce a pending slot for the session's next steer message. Fire-and-
86
129
  * forget; every guard failure is silent. Policy gates live here (config,
@@ -89,6 +132,21 @@ export declare class SlowLane {
89
132
  */
90
133
  dispatch(sessionId: string, input: DispatchInput): void;
91
134
  private produce;
135
+ /** One pending slot per settled pipeline (the 0-2 picks + lane metadata). */
136
+ private settlePending;
137
+ /**
138
+ * The jev rerank (design §2 row 1): one batched noul request over the whole
139
+ * candidate band; per-candidate verdict rows (agree: 'n/a' — no lexical
140
+ * disposition applies on this seam). Gate: adopt band (≥ 0.60) ranked by
141
+ * probability, top 2 (the 0-2 picks semantics); record band only logs;
142
+ * fallback band (< 0.10) is a hard veto.
143
+ *
144
+ * Returns undefined on ANY failure — jevAsk !ok (timeout / network / gate /
145
+ * bad shape) or an unparseable probability (坏答案) — so the caller fails
146
+ * open to the legacy LLM rerank (red line ②; the call-layer row already
147
+ * carries fallback=true via jevAsk's flag).
148
+ */
149
+ private jevRerank;
92
150
  /**
93
151
  * Consumption point (the next spliced). Returns the pending exactly once
94
152
  * (消费即清) or an expiry reason; `turnId` is the CURRENT observer turn
package/lib/quality.js CHANGED
@@ -5,8 +5,12 @@
5
5
  * 1. intent-query build over the observer's last-K ring buffer — kills
6
6
  * verbatim priming at the root (the query is what the model needs, not
7
7
  * what the user typed);
8
- * 2. lexical candidate band (hits + near-misses, recall-oriented) then an
9
- * LLM rerank that releases 0-2 picks, each with a one-line why.
8
+ * 2. lexical candidate band (hits + near-misses, recall-oriented) then a
9
+ * rerank that releases 0-2 picks, each with a one-line why. With
10
+ * `jevEnabled` on (design 2026-09-25 §2) the rerank is a batched System
11
+ * One noul request, probability-band gated; any failure fails open to the
12
+ * legacy LLM rerank below. The same trigger also runs the fastgate
13
+ * SHADOW (zero behavior, decisions.jsonl only).
10
14
  *
11
15
  * Picks rest in a per-session pending slot and inject at the NEXT spliced
12
16
  * (steer message), then the slot is gone (消费即清). Hard bounds: per-call
@@ -18,6 +22,9 @@
18
22
  * @module quality
19
23
  */
20
24
  import { searchTopics } from "./retrieval.js";
25
+ import { jevAskDual } from "./jev/dual.js";
26
+ import { band as jevBand } from "./jev/thresholds.js";
27
+ import { digestFor, logVerdicts } from "./jev/log.js";
21
28
  export const PENDING_TTL_MS = 10 * 60_000;
22
29
  /** Hard drift bound: a pending older than this many turn-ends expires. */
23
30
  export const TURN_LAG_LIMIT = 2;
@@ -31,6 +38,8 @@ const RING_CHARS = 6000;
31
38
  const CANDIDATE_LIMIT = 6;
32
39
  /** Over-fetch before the exclude filter, so excluded slugs don't thin the band. */
33
40
  const CANDIDATE_OVERFETCH = 6;
41
+ /** Fastgate shadow cap (design §2 row 3): topK hits + near-misses, ≤8 questions. */
42
+ export const FASTGATE_MAX_QUESTIONS = 8;
34
43
  export const QUERY_BUILD_PROMPT = [
35
44
  '你是检索查询构建器。输入是最近几轮对话。任务:判断「模型此刻需要什么背景知识」,输出一个用于关键词检索的 query。',
36
45
  '只输出一个 JSON 对象,不要任何其他文字:{"needs": true, "query": "3-8 个检索词加一句意图", "ignore": ["被你忽略的内容类型"]}',
@@ -47,6 +56,77 @@ export const RERANK_PROMPT = [
47
56
  '- why 面向模型自己读:说清这条记忆能帮上当前哪一步。',
48
57
  '- slug 必须逐字取自候选列表,禁止编造。',
49
58
  ].join('\n');
59
+ /**
60
+ * §6.2 state for one batch: the round's query plus the task frame. Shared by
61
+ * the rerank and the fastgate shadow so both seams speak ONE batch protocol —
62
+ * the calibrated bands are protocol-bound (design §5), a second state shape
63
+ * would need its own sweep.
64
+ */
65
+ export function jevState(query) {
66
+ return `检索 query:${query}\n任务背景:模型正在推进当前会话的工作,需要判断哪些长期记忆 topic 值得注入上下文作参考。`;
67
+ }
68
+ /** Per-candidate question: full candidate inline (title/tags/conclusion
69
+ * excerpt), RERANK_PROMPT's rendering style but compact. */
70
+ function candidateInstructions(c) {
71
+ const tags = c.tags !== undefined && c.tags.length > 0 ? `,标签: ${c.tags.join('/')}` : '';
72
+ const desc = c.description !== undefined && c.description.trim() !== '' ? ` ${c.description.trim().slice(0, 80)}` : '';
73
+ return `候选 topic「${c.title}」(slug: ${c.slug},状态: ${c.status}${tags})。${desc}结论摘录:${c.conclusion.slice(0, 160)}。该候选与当前 query 对应的工作真的相关、值得注入模型上下文吗?`;
74
+ }
75
+ /** Two-sided criteria built from the candidate content (calibration protocol:
76
+ * single-sided instructions measurably degrade). */
77
+ function candidateCriteria(c) {
78
+ return {
79
+ true: `相关——「${c.title}」的记忆对当前工作有实质帮助,应该注入作参考`,
80
+ false: `无关——「${c.title}」与当前工作没有实质交集,注入只是噪音,不应该注入`,
81
+ };
82
+ }
83
+ /** The noul probability of one answer; undefined when the answer is not a
84
+ * usable number in [0,1] (a 坏答案 — fail-open for the caller). */
85
+ function noulProbability(answer) {
86
+ if (answer === null || typeof answer !== 'object')
87
+ return undefined;
88
+ const p = answer.noul;
89
+ return typeof p === 'number' && Number.isFinite(p) && p >= 0 && p <= 1 ? p : undefined;
90
+ }
91
+ /** Pace-maker comparison rows for the slow rerank (design §3.4, REVISED
92
+ * 09-27): laya is TELEMETRY ONLY, so its rows are recorded band='record'
93
+ * (its absolute scores never claim adopt) and degraded=false whether or not
94
+ * the primary answered — landing them costs no behavior. An unparseable
95
+ * answer gets no fabricated row. */
96
+ function layaRerankRows(at, state, metas, answers) {
97
+ const recs = [];
98
+ for (const m of metas) {
99
+ const probability = noulProbability(answers?.[m.qid]);
100
+ if (probability === undefined)
101
+ continue;
102
+ recs.push({
103
+ at,
104
+ lane: 'slowlane-rerank',
105
+ questionId: m.qid,
106
+ qtype: 'noul',
107
+ ref: `slug:${m.slug}`,
108
+ digest: digestFor(state, m.qid, m.instructions),
109
+ probability,
110
+ band: 'record',
111
+ agree: 'n/a',
112
+ backend: 'laya',
113
+ degraded: false,
114
+ });
115
+ }
116
+ return recs;
117
+ }
118
+ /** The config slice jevAsk needs, tolerating bare harnesses (§4: consumers
119
+ * treat undefined as the documented default). */
120
+ function jevConfigOf(cfg) {
121
+ return {
122
+ jevBackend: cfg.jevBackend ?? 'zen',
123
+ jevModel: cfg.jevModel ?? '',
124
+ jevTimeoutMs: typeof cfg.jevTimeoutMs === 'number' && cfg.jevTimeoutMs > 0 ? cfg.jevTimeoutMs : 3000,
125
+ jevSecretFile: cfg.jevSecretFile ?? '',
126
+ jevLayaFallback: cfg.jevLayaFallback === true,
127
+ jevLayaUrl: cfg.jevLayaUrl ?? 'http://127.0.0.1:8000/v1/systemone',
128
+ };
129
+ }
50
130
  /** Race `p` against a timeout; `onTimeout` fires so callers can abort upstream work. */
51
131
  function withTimeout(p, ms, onTimeout) {
52
132
  return new Promise((resolve, reject) => {
@@ -107,6 +187,8 @@ export function parseJsonObject(raw) {
107
187
  export class SlowLane {
108
188
  pending = new Map();
109
189
  inFlight = new Map();
190
+ /** Same-session shadow serialization — a second trigger never overlaps. */
191
+ shadowInFlight = new Map();
110
192
  service;
111
193
  caller;
112
194
  /**
@@ -133,6 +215,106 @@ export class SlowLane {
133
215
  /** Drop the pending slot (session teardown, restore boundary). */
134
216
  clear(sessionId) {
135
217
  this.pending.delete(sessionId);
218
+ this.shadowInFlight.delete(sessionId);
219
+ }
220
+ /**
221
+ * Fastgate shadow (design §2 row 3): fire-and-forget on the SAME sampled
222
+ * turn-end trigger as the slow lane, zero behavior — verdicts land only in
223
+ * decisions.jsonl, reconciled against the lexical disposition. Needs no LLM
224
+ * caller, so it also runs on turns where the slow lane itself yields (distill
225
+ * in flight, no model configured). `dispatch()`'s own guards (lane mode,
226
+ * sampled cadence) are mirrored here so both seams share one cadence.
227
+ */
228
+ dispatchFastGateShadow(sessionId, input) {
229
+ try {
230
+ const cfg = this.service.cfg;
231
+ if (cfg.jevEnabled !== true)
232
+ return;
233
+ if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always')
234
+ return;
235
+ if (cfg.qualityLane === 'sampled' && input.turnId % SAMPLED_EVERY !== 0)
236
+ return;
237
+ if (input.query.trim() === '' || input.candidates.length === 0)
238
+ return;
239
+ if (this.shadowInFlight.has(sessionId))
240
+ return; // never overlap shadows
241
+ // Serialize behind a same-session produce pipeline (rerank 完成后);
242
+ // standalone when no pipeline is running (无慢道任务时独立跑).
243
+ const prior = this.inFlight.get(sessionId) ?? Promise.resolve();
244
+ const run = prior.then(() => this.fastGateShadow(input)).finally(() => {
245
+ this.shadowInFlight.delete(sessionId);
246
+ });
247
+ this.shadowInFlight.set(sessionId, run);
248
+ void run.catch(() => undefined);
249
+ }
250
+ catch {
251
+ // contained — the shadow must never break the event handler
252
+ }
253
+ }
254
+ /** Shadow body: resolve candidate content from the roster, one batched noul
255
+ * request, verdict rows only. A jevAsk failure leaves just its auto-written
256
+ * call row — there is no legacy path to fall back to, and nothing to gate. */
257
+ async fastGateShadow(input) {
258
+ const roster = await this.service.roster().catch(() => []);
259
+ const bySlug = new Map(roster.map((r) => [r.slug, r]));
260
+ const state = jevState(input.query);
261
+ const questions = {};
262
+ const metas = [];
263
+ for (const cand of input.candidates.slice(0, FASTGATE_MAX_QUESTIONS)) {
264
+ const meta = bySlug.get(cand.slug);
265
+ if (meta === undefined)
266
+ continue; // vanished between retrieval and the shadow
267
+ const qid = `c${metas.length + 1}`;
268
+ const instructions = candidateInstructions(meta);
269
+ questions[qid] = { type: 'noul', instructions, criteria: candidateCriteria(meta) };
270
+ metas.push({ qid, slug: cand.slug, instructions, disposition: cand.disposition });
271
+ }
272
+ if (metas.length === 0)
273
+ return;
274
+ const dual = await jevAskDual({
275
+ state,
276
+ questions,
277
+ config: jevConfigOf(this.service.cfg),
278
+ lane: 'fastgate-shadow',
279
+ fallback: false, // pure shadow — no behavior to fall back
280
+ });
281
+ const at = new Date().toISOString();
282
+ // Verdict rows for BOTH sources when they answered: the primary row set
283
+ // and the laya pace-maker comparison set (backend='laya', excluded from
284
+ // ECE, §6.2). Pure shadow — nothing gates here, so the laya rows land
285
+ // whenever laya answered, primary success or not.
286
+ const sources = [];
287
+ if (dual.ok)
288
+ sources.push({ answers: dual.answers, backend: 'primary', degraded: false });
289
+ if (dual.layaResult?.ok)
290
+ sources.push({ answers: dual.layaResult.answers, backend: 'laya', degraded: false });
291
+ const recs = [];
292
+ for (const src of sources) {
293
+ for (const m of metas) {
294
+ const probability = noulProbability(src.answers?.[m.qid]);
295
+ if (probability === undefined)
296
+ continue; // unparseable answer: no fabricated row
297
+ const b = jevBand(probability, 'rerank');
298
+ // agree reconciliation (§6.2): the ilog disposition, with wouldBlock for
299
+ // the reverse disagreement (lexical let it through, jev lands in the veto
300
+ // band — the other half of the calibration goldmine).
301
+ const agree = m.disposition === 'hit' && b === 'fallback' ? 'wouldBlock' : m.disposition;
302
+ recs.push({
303
+ at,
304
+ lane: 'fastgate-shadow',
305
+ questionId: m.qid,
306
+ qtype: 'noul',
307
+ ref: `slug:${m.slug}`,
308
+ digest: digestFor(state, m.qid, m.instructions),
309
+ probability,
310
+ band: b,
311
+ agree,
312
+ backend: src.backend,
313
+ degraded: src.degraded,
314
+ });
315
+ }
316
+ }
317
+ await logVerdicts(recs);
136
318
  }
137
319
  /**
138
320
  * Produce a pending slot for the session's next steer message. Fire-and-
@@ -237,6 +419,19 @@ export class SlowLane {
237
419
  });
238
420
  if (payload.length === 0)
239
421
  return;
422
+ // ---- jev batch rerank (design §2 row 1) — replaces the LLM rerank ----
423
+ // One batched noul request over the whole band; the calibrated bands
424
+ // decide the picks. A settled jev round is FINAL here (picks or none) —
425
+ // only a jev FAILURE (jevRerank → undefined) falls open to the legacy
426
+ // LLM rerank below (§3.2: 本轮回退旧 LLM rerank, picks 照常产出).
427
+ if (this.service.cfg.jevEnabled === true) {
428
+ const jevItems = await this.jevRerank(query, payload);
429
+ if (jevItems !== undefined) {
430
+ if (jevItems.length > 0)
431
+ this.settlePending(sessionId, input, jevItems, queryBuild, started);
432
+ return;
433
+ }
434
+ }
240
435
  const rerankRaw = await withTimeout(caller({
241
436
  system: RERANK_PROMPT,
242
437
  user: [
@@ -279,15 +474,7 @@ export class SlowLane {
279
474
  }
280
475
  if (items.length === 0)
281
476
  return;
282
- const route = this.service.cfg;
283
- this.pending.set(sessionId, {
284
- items,
285
- computedAt: new Date().toISOString(),
286
- turnId: input.turnId,
287
- queryBuild,
288
- model: `${route.distillProvider}/${route.distillModel}`,
289
- ms: Date.now() - started,
290
- });
477
+ this.settlePending(sessionId, input, items, queryBuild, started);
291
478
  }
292
479
  catch {
293
480
  // timeouts, aborts, model errors — I3: the async product is simply absent
@@ -296,6 +483,98 @@ export class SlowLane {
296
483
  clearTimeout(deadline);
297
484
  }
298
485
  }
486
+ /** One pending slot per settled pipeline (the 0-2 picks + lane metadata). */
487
+ settlePending(sessionId, input, items, queryBuild, started) {
488
+ const route = this.service.cfg;
489
+ this.pending.set(sessionId, {
490
+ items,
491
+ computedAt: new Date().toISOString(),
492
+ turnId: input.turnId,
493
+ queryBuild,
494
+ model: `${route.distillProvider}/${route.distillModel}`,
495
+ ms: Date.now() - started,
496
+ });
497
+ }
498
+ /**
499
+ * The jev rerank (design §2 row 1): one batched noul request over the whole
500
+ * candidate band; per-candidate verdict rows (agree: 'n/a' — no lexical
501
+ * disposition applies on this seam). Gate: adopt band (≥ 0.60) ranked by
502
+ * probability, top 2 (the 0-2 picks semantics); record band only logs;
503
+ * fallback band (< 0.10) is a hard veto.
504
+ *
505
+ * Returns undefined on ANY failure — jevAsk !ok (timeout / network / gate /
506
+ * bad shape) or an unparseable probability (坏答案) — so the caller fails
507
+ * open to the legacy LLM rerank (red line ②; the call-layer row already
508
+ * carries fallback=true via jevAsk's flag).
509
+ */
510
+ async jevRerank(query, payload) {
511
+ const state = jevState(query);
512
+ const questions = {};
513
+ const metas = [];
514
+ for (const [i, c] of payload.entries()) {
515
+ const qid = `c${i + 1}`;
516
+ const instructions = candidateInstructions(c);
517
+ questions[qid] = { type: 'noul', instructions, criteria: candidateCriteria(c) };
518
+ metas.push({ qid, slug: c.slug, instructions });
519
+ }
520
+ const dual = await jevAskDual({
521
+ state,
522
+ questions,
523
+ config: jevConfigOf(this.service.cfg),
524
+ lane: 'slowlane-rerank',
525
+ fallback: false,
526
+ // fallback stays FALSE here: jevAsk writes the row before the caller
527
+ // knows the outcome, so a constant true would poison the rollback-rate
528
+ // stat. Fail-open occurrence = outcome !== 'ok' (this lane's fallback
529
+ // to the legacy LLM rerank is deterministic per ADR 0018).
530
+ });
531
+ // REVISED 09-27 (real-data evidence): the laya pace-maker is TELEMETRY
532
+ // ONLY. Its within-batch top-1 agreed with the primary 0/14 on real
533
+ // queries (and its negatives sit at 0.63-0.76 — absolute scores unusable),
534
+ // so the earlier degraded-ranking takeover is REVERTED: on primary
535
+ // failure the lane falls open to the legacy LLM rerank exactly as
536
+ // ADR 0018 says. The pace-maker still answers, so its comparison rows
537
+ // land FIRST here (pure telemetry), then the legacy rerank takes over.
538
+ if (!dual.ok) {
539
+ if (dual.layaResult?.ok) {
540
+ await logVerdicts(layaRerankRows(new Date().toISOString(), state, metas, dual.layaResult.answers));
541
+ }
542
+ return undefined;
543
+ }
544
+ const scored = [];
545
+ for (const m of metas) {
546
+ const probability = noulProbability(dual.answers[m.qid]);
547
+ if (probability === undefined)
548
+ return undefined; // 坏答案 → fail-open (§1)
549
+ scored.push({ ...m, probability, band: jevBand(probability, 'rerank') });
550
+ }
551
+ const at = new Date().toISOString();
552
+ const recs = scored.map((m) => ({
553
+ at,
554
+ lane: 'slowlane-rerank',
555
+ questionId: m.qid,
556
+ qtype: 'noul',
557
+ ref: `slug:${m.slug}`,
558
+ digest: digestFor(state, m.qid, m.instructions),
559
+ probability: m.probability,
560
+ band: m.band,
561
+ agree: 'n/a',
562
+ backend: dual.source,
563
+ degraded: false,
564
+ }));
565
+ // Pace-maker comparison rows (design §3.4): when the primary drove and
566
+ // laya also answered, log laya's verdicts alongside — the permanently
567
+ // running laya-vs-jev comparison. band='record': laya's absolute scores
568
+ // never claim adopt.
569
+ if (dual.layaResult?.ok)
570
+ recs.push(...layaRerankRows(at, state, metas, dual.layaResult.answers));
571
+ await logVerdicts(recs);
572
+ return scored
573
+ .filter((m) => m.band === 'adopt')
574
+ .sort((a, b) => b.probability - a.probability)
575
+ .slice(0, 2)
576
+ .map((m) => ({ slug: m.slug, why: `jev 判定相关(p=${m.probability.toFixed(2)})` }));
577
+ }
299
578
  /**
300
579
  * Consumption point (the next spliced). Returns the pending exactly once
301
580
  * (消费即清) or an expiry reason; `turnId` is the CURRENT observer turn
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.18.2",
3
+ "version": "0.20.0",
4
4
  "description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -42,7 +42,7 @@
42
42
  "build": "tsc -p tsconfig.json",
43
43
  "check": "tsc --noEmit -p tsconfig.json",
44
44
  "pretest": "tsc -p tsconfig.json",
45
- "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs",
45
+ "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs test/jev-config.test.mjs test/jev-gate.test.mjs test/jev-client.test.mjs test/jev-log.test.mjs test/jev-dual.test.mjs test/jev-fastgate.test.mjs",
46
46
  "smoke": "node scripts/smoke-boot.mjs"
47
47
  },
48
48
  "dependencies": {},