@aiwayds/dsh-topics-memory 0.6.0 → 0.8.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/quality.js ADDED
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Slow quality lane (v4 dual-channel design §4.2) — asynchronous, turn-end
3
+ * triggered, LLM-gated injection. Two serial aux calls per run:
4
+ *
5
+ * 1. intent-query build over the observer's last-K ring buffer — kills
6
+ * verbatim priming at the root (the query is what the model needs, not
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.
10
+ *
11
+ * Picks rest in a per-session pending slot and inject at the NEXT spliced
12
+ * (steer message), then the slot is gone (消费即清). Hard bounds: per-call
13
+ * 20s, pipeline 45s, pending TTL 10min, turn-lag ≤2. Every failure is
14
+ * contained and silent — I3 (可丢弃性): async products may be missing, never
15
+ * blocking. The lexical re-gate at consumption is SHADOW-ONLY (v4 B3): it
16
+ * records verdicts into ilog and never blocks a pick.
17
+ *
18
+ * @module quality
19
+ */
20
+ import { searchTopics } from "./retrieval.js";
21
+ export const PENDING_TTL_MS = 10 * 60_000;
22
+ /** Hard drift bound: a pending older than this many turn-ends expires. */
23
+ export const TURN_LAG_LIMIT = 2;
24
+ export const CALL_TIMEOUT_MS = 20_000;
25
+ export const PIPELINE_TIMEOUT_MS = 45_000;
26
+ /** qualityLane 'sampled' runs on every Nth turn (design default 1/3). */
27
+ export const SAMPLED_EVERY = 3;
28
+ /** Max ring-buffer chars fed to the query build (features budget, not a transcript). */
29
+ const RING_CHARS = 6000;
30
+ /** Candidate band size fed to the rerank. */
31
+ const CANDIDATE_LIMIT = 6;
32
+ export const QUERY_BUILD_PROMPT = [
33
+ '你是检索查询构建器。输入是最近几轮对话。任务:判断「模型此刻需要什么背景知识」,输出一个用于关键词检索的 query。',
34
+ '只输出一个 JSON 对象,不要任何其他文字:{"needs": true, "query": "3-8 个检索词加一句意图", "ignore": ["被你忽略的内容类型"]}',
35
+ '规则:',
36
+ '- query 给词法检索用:写具体的名词、术语、项目名,不要照抄对话原句。',
37
+ '- 对话里粘贴的日志、代码块、URL、命令输出等大段内容必须忽略,并把类型写进 ignore 数组(如 "粘贴的命令输出")。',
38
+ '- 没有需要补充背景知识的迹象时输出 {"needs": false}。',
39
+ ].join('\n');
40
+ export const RERANK_PROMPT = [
41
+ '你是注入门禁。输入一个检索 query 和候选 topic 列表。逐个判断候选是否「真的与当前工作相关」。',
42
+ '只输出一个 JSON 对象,不要任何其他文字:{"picks": [{"slug": "候选里的 slug", "why": "一句话说明为什么现在需要它"}]}',
43
+ '规则:',
44
+ '- 最多 2 条,宁缺勿滥;没有真相关的就输出 {"picks": []}。',
45
+ '- why 面向模型自己读:说清这条记忆能帮上当前哪一步。',
46
+ '- slug 必须逐字取自候选列表,禁止编造。',
47
+ ].join('\n');
48
+ /** Race `p` against a timeout; `onTimeout` fires so callers can abort upstream work. */
49
+ function withTimeout(p, ms, onTimeout) {
50
+ return new Promise((resolve, reject) => {
51
+ const timer = setTimeout(() => {
52
+ onTimeout();
53
+ reject(new Error(`slow-lane call timeout (${ms}ms)`));
54
+ }, ms);
55
+ timer.unref?.();
56
+ p.then((v) => {
57
+ clearTimeout(timer);
58
+ resolve(v);
59
+ }, (e) => {
60
+ clearTimeout(timer);
61
+ reject(e);
62
+ });
63
+ });
64
+ }
65
+ /** Extract the first balanced JSON object from model output (tolerates fences). */
66
+ export function parseJsonObject(raw) {
67
+ let text = raw.trim();
68
+ const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
69
+ if (fence !== null)
70
+ text = fence[1].trim();
71
+ const start = text.indexOf('{');
72
+ if (start < 0)
73
+ throw new Error('no JSON object in output');
74
+ let depth = 0;
75
+ let inString = false;
76
+ let escaped = false;
77
+ for (let i = start; i < text.length; i += 1) {
78
+ const ch = text[i];
79
+ if (escaped) {
80
+ escaped = false;
81
+ continue;
82
+ }
83
+ if (ch === '\\') {
84
+ escaped = true;
85
+ continue;
86
+ }
87
+ if (ch === '"')
88
+ inString = !inString;
89
+ if (inString)
90
+ continue;
91
+ if (ch === '{')
92
+ depth += 1;
93
+ if (ch === '}') {
94
+ depth -= 1;
95
+ if (depth === 0) {
96
+ const parsed = JSON.parse(text.slice(start, i + 1));
97
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
98
+ throw new Error('not an object');
99
+ return parsed;
100
+ }
101
+ }
102
+ }
103
+ throw new Error('unbalanced JSON in output');
104
+ }
105
+ export class SlowLane {
106
+ pending = new Map();
107
+ inFlight = new Map();
108
+ service;
109
+ caller;
110
+ /**
111
+ * Settle hook: fires when a session's pipeline leaves the in-flight map.
112
+ * The host uses it to re-check the sessionLlm release guards — a disposal
113
+ * that landed mid-pipeline held the entry open for THIS pipeline, and this
114
+ * callback is the only chance to release it afterwards (the distill lane
115
+ * has the same shape via its run.finally).
116
+ */
117
+ onSettle;
118
+ constructor(service, caller, onSettle = () => undefined) {
119
+ this.service = service;
120
+ this.caller = caller;
121
+ this.onSettle = onSettle;
122
+ }
123
+ /** True while the session has a pending slot awaiting the next spliced. */
124
+ hasPending(sessionId) {
125
+ return this.pending.has(sessionId);
126
+ }
127
+ /** True while the session's produce pipeline is still running (llm-capture guard). */
128
+ hasInFlight(sessionId) {
129
+ return this.inFlight.has(sessionId);
130
+ }
131
+ /** Drop the pending slot (session teardown, restore boundary). */
132
+ clear(sessionId) {
133
+ this.pending.delete(sessionId);
134
+ }
135
+ /**
136
+ * Produce a pending slot for the session's next steer message. Fire-and-
137
+ * forget; every guard failure is silent. Policy gates live here (config,
138
+ * sampling, in-flight); host gates (delegation, distill yield) live with
139
+ * the caller, which owns those services.
140
+ */
141
+ dispatch(sessionId, input) {
142
+ try {
143
+ const cfg = this.service.cfg;
144
+ if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always')
145
+ return;
146
+ if (cfg.qualityLane === 'sampled' && input.turnId % SAMPLED_EVERY !== 0)
147
+ return;
148
+ if (this.caller === undefined || this.inFlight.has(sessionId))
149
+ return;
150
+ if (input.ring.length === 0)
151
+ return;
152
+ const run = this.produce(sessionId, input).finally(() => {
153
+ this.inFlight.delete(sessionId);
154
+ this.onSettle(sessionId);
155
+ });
156
+ this.inFlight.set(sessionId, run);
157
+ void run.catch(() => undefined);
158
+ }
159
+ catch {
160
+ // contained — the lane must never break the event handler
161
+ }
162
+ }
163
+ async produce(sessionId, input) {
164
+ const caller = this.caller;
165
+ if (caller === undefined)
166
+ return;
167
+ const started = Date.now();
168
+ const controller = new AbortController();
169
+ const deadline = setTimeout(() => controller.abort(), PIPELINE_TIMEOUT_MS);
170
+ deadline.unref?.();
171
+ try {
172
+ const cfg = this.service.cfg;
173
+ const ringText = input.ring
174
+ .map((e, i) => {
175
+ const turn = `【轮 ${i + 1}】\n用户: ${e.user}\n助手: ${e.assistant}`;
176
+ return turn.length > RING_CHARS / input.ring.length ? turn.slice(0, Math.floor(RING_CHARS / input.ring.length)) : turn;
177
+ })
178
+ .join('\n\n');
179
+ const buildRaw = await withTimeout(caller({
180
+ system: QUERY_BUILD_PROMPT,
181
+ user: ringText,
182
+ purpose: 'topics-quality',
183
+ sessionId,
184
+ maxTokens: 300,
185
+ signal: controller.signal,
186
+ }), CALL_TIMEOUT_MS, () => controller.abort());
187
+ let built;
188
+ try {
189
+ built = parseJsonObject(buildRaw);
190
+ }
191
+ catch {
192
+ return; // unparseable build → no pending, nothing logged, next turn retries
193
+ }
194
+ if (built.needs !== true || typeof built.query !== 'string' || built.query.trim() === '')
195
+ return;
196
+ const query = built.query.trim();
197
+ const stripped = Array.isArray(built.ignore)
198
+ ? built.ignore.filter((s) => typeof s === 'string' && s.trim() !== '').slice(0, 5)
199
+ : [];
200
+ const queryBuild = { rawChars: ringText.length, keptChars: query.length, stripped };
201
+ const roster = await this.service.roster().catch(() => []);
202
+ if (roster.length === 0)
203
+ return;
204
+ // Recall-oriented band: gate OFF (the rerank IS this lane's gate);
205
+ // candidates = threshold passers + the near-miss band beneath them.
206
+ const band = searchTopics(query, roster, {
207
+ threshold: this.service.cfg.matchThreshold,
208
+ topK: CANDIDATE_LIMIT,
209
+ tagBoost: this.service.cfg.tagBoost,
210
+ graphDepth: 0,
211
+ recencyWindowDays: this.service.cfg.recencyWindowDays,
212
+ structuralGate: false,
213
+ });
214
+ const candidates = [...band.hits, ...band.nearMisses].slice(0, CANDIDATE_LIMIT);
215
+ if (candidates.length === 0)
216
+ return;
217
+ const bySlug = new Map(roster.map((r) => [r.slug, r]));
218
+ const payload = candidates.flatMap((c) => {
219
+ const meta = bySlug.get(c.slug);
220
+ if (meta === undefined)
221
+ return [];
222
+ const conclusionFirst = meta.conclusion.split('\n').find((l) => l.trim() !== '') ?? '';
223
+ return [
224
+ {
225
+ slug: c.slug,
226
+ title: meta.title,
227
+ status: meta.status,
228
+ description: meta.description ?? '',
229
+ conclusion: conclusionFirst.slice(0, 160),
230
+ },
231
+ ];
232
+ });
233
+ if (payload.length === 0)
234
+ return;
235
+ const rerankRaw = await withTimeout(caller({
236
+ system: RERANK_PROMPT,
237
+ user: [
238
+ `检索 query:${query}`,
239
+ '',
240
+ `候选 topic(${payload.length} 个):`,
241
+ JSON.stringify(payload),
242
+ ].join('\n'),
243
+ purpose: 'topics-quality',
244
+ sessionId,
245
+ maxTokens: 400,
246
+ signal: controller.signal,
247
+ }), CALL_TIMEOUT_MS, () => controller.abort());
248
+ let reranked;
249
+ try {
250
+ reranked = parseJsonObject(rerankRaw);
251
+ }
252
+ catch {
253
+ return;
254
+ }
255
+ const legal = new Set(payload.map((p) => p.slug));
256
+ const rawPicks = Array.isArray(reranked.picks) ? reranked.picks : [];
257
+ const items = [];
258
+ const picked = new Set();
259
+ for (const pick of rawPicks) {
260
+ if (pick === null || typeof pick !== 'object')
261
+ continue;
262
+ const slug = pick.slug;
263
+ const why = pick.why;
264
+ if (typeof slug !== 'string' || !legal.has(slug))
265
+ continue;
266
+ if (typeof why !== 'string' || why.trim() === '')
267
+ continue;
268
+ if (picked.has(slug))
269
+ continue;
270
+ picked.add(slug);
271
+ items.push({ slug, why: why.trim().slice(0, 200) });
272
+ if (items.length >= 2)
273
+ break;
274
+ }
275
+ if (items.length === 0)
276
+ return;
277
+ const route = this.service.cfg;
278
+ this.pending.set(sessionId, {
279
+ items,
280
+ computedAt: new Date().toISOString(),
281
+ turnId: input.turnId,
282
+ queryBuild,
283
+ model: `${route.distillProvider}/${route.distillModel}`,
284
+ ms: Date.now() - started,
285
+ });
286
+ }
287
+ catch {
288
+ // timeouts, aborts, model errors — I3: the async product is simply absent
289
+ }
290
+ finally {
291
+ clearTimeout(deadline);
292
+ }
293
+ }
294
+ /**
295
+ * Consumption point (the next spliced). Returns the pending exactly once
296
+ * (消费即清) or an expiry reason; `turnId` is the CURRENT observer turn
297
+ * count so the drift bound can judge. Undefined when nothing is pending.
298
+ */
299
+ consume(sessionId, turnId) {
300
+ const pending = this.pending.get(sessionId);
301
+ if (pending === undefined)
302
+ return undefined;
303
+ const produced = Date.parse(pending.computedAt);
304
+ if (!Number.isNaN(produced) && Date.now() - produced > PENDING_TTL_MS) {
305
+ this.pending.delete(sessionId);
306
+ return { expired: 'ttl' };
307
+ }
308
+ if (turnId - pending.turnId > TURN_LAG_LIMIT) {
309
+ this.pending.delete(sessionId);
310
+ return { expired: 'turn-lag' };
311
+ }
312
+ this.pending.delete(sessionId);
313
+ return { pending };
314
+ }
315
+ }
@@ -17,6 +17,8 @@ export interface RetrievableTopic extends TopicMeta {
17
17
  conclusion: string;
18
18
  /** Body-referenced topic slugs ([[wikilinks]] + markdown links) — graph edges. */
19
19
  links?: string[];
20
+ /** Self-declared recall triggers (v4 structural gate strong field). */
21
+ triggers?: string[];
20
22
  }
21
23
  export interface RetrievalConfig {
22
24
  threshold: number;
@@ -27,13 +29,27 @@ export interface RetrievalConfig {
27
29
  now?: Date;
28
30
  /** Slugs currently conflicted — demoted, not dropped. */
29
31
  conflicts?: ReadonlySet<string>;
32
+ /**
33
+ * v4 structural gate v0 (design §4.1): a threshold-passing candidate only
34
+ * injects when it ALSO hits a strong field (triggers/title/slug) or ≥2
35
+ * distinct body terms — tags alone and single body terms can no longer
36
+ * self-cross. Off for the explicit topic_search tool (the model asked).
37
+ */
38
+ structuralGate: boolean;
30
39
  }
31
40
  export declare const DEFAULT_RETRIEVAL: RetrievalConfig;
41
+ /** Trigger hits outrank every other lexical field (pi-llm-wiki: highest weight). */
42
+ export declare const TRIGGER_WEIGHT = 5;
32
43
  export interface SearchHit {
33
44
  slug: string;
34
45
  score: number;
35
46
  reasons: string[];
36
47
  viaGraph: boolean;
48
+ /** Structural gate v0 evidence (absent on graph-expanded entries). */
49
+ strong?: boolean;
50
+ bodyHits?: number;
51
+ /** Recency-free score; graph decay starts from it (settle ties with the gate). */
52
+ gateScore?: number;
37
53
  }
38
54
  export interface SearchOutcome {
39
55
  hits: SearchHit[];
@@ -50,12 +66,28 @@ export interface ScoredTopic {
50
66
  slug: string;
51
67
  score: number;
52
68
  reasons: string[];
69
+ /** Strong-field hit (triggers/title/slug) — gate v0 pass condition A. */
70
+ strong: boolean;
71
+ /** Distinct query terms found in description ∪ conclusion — pass condition B is ≥2. */
72
+ bodyHits: number;
73
+ /** Score with the recency tiebreaker removed — the only score the gate looks at. */
74
+ gateScore: number;
53
75
  }
54
76
  /** Score one topic against the query tokens. */
55
77
  export declare function scoreTopic(queryTokens: ReadonlySet<string>, topic: RetrievableTopic, cfg: RetrievalConfig): ScoredTopic;
78
+ /** Structural gate v0 verdict: strong-field hit, or ≥2 distinct body terms. */
79
+ export declare function passesGate(s: {
80
+ strong: boolean;
81
+ bodyHits: number;
82
+ }): boolean;
56
83
  /**
57
- * Search the roster: score every topic, split hits from near-misses, then
58
- * expand seeds through the `depends` graph (both directions, geometric decay,
59
- * depth ≤ cfg.graphDepth). Expanded entries enter at the back of the hits.
84
+ * Search the roster: score every topic, apply the numeric threshold to the
85
+ * gate score (recency excluded), split by the structural gate, then expand
86
+ * gate-passed seeds through the `depends` graph (both directions, geometric
87
+ * decay, depth ≤ cfg.graphDepth). Expanded entries enter at the back of the
88
+ * hits and are exempt from the structural gate — they carry no lexical
89
+ * evidence by design, only graph adjacency. Threshold-passing candidates the
90
+ * gate rejects lead the near-misses (reason `gate-blocked`) so the replay
91
+ * evidence shows what the gate is costing.
60
92
  */
61
93
  export declare function searchTopics(query: string, roster: readonly RetrievableTopic[], cfg?: Partial<RetrievalConfig>): SearchOutcome;
package/lib/retrieval.js CHANGED
@@ -18,7 +18,10 @@ export const DEFAULT_RETRIEVAL = {
18
18
  tagBoost: 0.15,
19
19
  graphDepth: 2,
20
20
  recencyWindowDays: 7,
21
+ structuralGate: true,
21
22
  };
23
+ /** Trigger hits outrank every other lexical field (pi-llm-wiki: highest weight). */
24
+ export const TRIGGER_WEIGHT = 5;
22
25
  const CJK_RUN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+/gu;
23
26
  const WORD_RUN = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}][\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}+#.-]*/gu;
24
27
  /**
@@ -60,20 +63,34 @@ function overlap(a, b) {
60
63
  export function scoreTopic(queryTokens, topic, cfg) {
61
64
  const title = new Set(tokenize(topic.title));
62
65
  const slug = new Set(tokenize(topic.slug));
66
+ const triggers = new Set((topic.triggers ?? []).flatMap((t) => tokenize(t)));
63
67
  const tags = new Set(topic.tags.flatMap((t) => tokenize(t)));
64
68
  const description = new Set(tokenize(topic.description ?? ''));
65
69
  const conclusion = new Set(tokenize(topic.conclusion));
66
70
  const reasons = [];
67
71
  let score = 0;
72
+ const cTriggers = containment(queryTokens, triggers);
73
+ if (cTriggers > 0) {
74
+ score += TRIGGER_WEIGHT * cTriggers;
75
+ reasons.push(`triggers:${cTriggers.toFixed(2)}`);
76
+ }
68
77
  const cTitle = containment(queryTokens, title);
69
78
  if (cTitle > 0) {
70
79
  score += 3 * cTitle;
71
80
  reasons.push(`title:${cTitle.toFixed(2)}`);
72
81
  }
73
- const cTag = containment(queryTokens, new Set([...tags, ...slug]));
74
- if (cTag > 0) {
75
- score += 2 * cTag;
76
- reasons.push(`tags:${cTag.toFixed(2)}`);
82
+ // Slug and tags are scored separately since the structural gate (v4): the
83
+ // slug is a strong field, tags are not. Weights unchanged (2) so historical
84
+ // score distributions stay comparable.
85
+ const cSlug = containment(queryTokens, slug);
86
+ if (cSlug > 0) {
87
+ score += 2 * cSlug;
88
+ reasons.push(`slug:${cSlug.toFixed(2)}`);
89
+ }
90
+ const cTags = containment(queryTokens, tags);
91
+ if (cTags > 0) {
92
+ score += 2 * cTags;
93
+ reasons.push(`tags:${cTags.toFixed(2)}`);
77
94
  }
78
95
  const cDesc = containment(queryTokens, description);
79
96
  if (cDesc > 0) {
@@ -85,26 +102,29 @@ export function scoreTopic(queryTokens, topic, cfg) {
85
102
  score += 0.8 * cConc;
86
103
  reasons.push(`conclusion:${cConc.toFixed(2)}`);
87
104
  }
88
- // Exact tag hit boost, additive and capped (pi-topic-memory lesson: add,
89
- // never fold into the token set — dilution breaks the guard).
105
+ // Exact tag hit boost, additive and capped at ONE boost's worth (v4 design:
106
+ // the old ×3 cap let shared tags mint a 0.45 floor — the priming main
107
+ // culprit). Never folded into the token set (dilution breaks the guard).
90
108
  let tagHits = 0;
91
109
  for (const t of tags) {
92
110
  if (overlap(queryTokens, new Set([...tokenize(t)])))
93
111
  tagHits += 1;
94
112
  }
95
113
  if (tagHits > 0) {
96
- const boost = Math.min(cfg.tagBoost * tagHits, cfg.tagBoost * 3);
114
+ const boost = Math.min(cfg.tagBoost * tagHits, cfg.tagBoost);
97
115
  score += boost;
98
116
  reasons.push(`tag-boost:+${boost.toFixed(2)}`);
99
117
  }
100
- // Recency is a tiebreaker among relevant topics, never a free pass: only
101
- // fires when the query already matched some content above.
118
+ // Recency is a RANKING tiebreaker only (v4): it pads `score` but is kept
119
+ // out of `gateScore`, so it can never carry a candidate across the numeric
120
+ // threshold. Conflict demotion applies to the pre-tiebreaker score.
121
+ let recencyBonus = 0;
102
122
  if (score > 0 && cfg.recencyWindowDays > 0) {
103
123
  const generated = Date.parse(topic.generatedAt);
104
124
  if (!Number.isNaN(generated)) {
105
125
  const ageDays = ((cfg.now?.getTime() ?? Date.now()) - generated) / 86_400_000;
106
126
  if (ageDays >= 0 && ageDays <= cfg.recencyWindowDays) {
107
- score += 0.2;
127
+ recencyBonus = 0.2;
108
128
  reasons.push('recency');
109
129
  }
110
130
  }
@@ -113,12 +133,36 @@ export function scoreTopic(queryTokens, topic, cfg) {
113
133
  score *= 0.3;
114
134
  reasons.push('conflicted-demoted');
115
135
  }
116
- return { slug: topic.slug, score: Math.round(score * 1000) / 1000, reasons };
136
+ const gateScore = score;
137
+ score += recencyBonus;
138
+ const bodySet = new Set([...description, ...conclusion]);
139
+ let bodyHits = 0;
140
+ for (const t of queryTokens)
141
+ if (bodySet.has(t))
142
+ bodyHits += 1;
143
+ const strong = cTriggers > 0 || cTitle > 0 || cSlug > 0;
144
+ return {
145
+ slug: topic.slug,
146
+ score: Math.round(score * 1000) / 1000,
147
+ reasons,
148
+ strong,
149
+ bodyHits,
150
+ gateScore: Math.round(gateScore * 1000) / 1000,
151
+ };
152
+ }
153
+ /** Structural gate v0 verdict: strong-field hit, or ≥2 distinct body terms. */
154
+ export function passesGate(s) {
155
+ return s.strong || s.bodyHits >= 2;
117
156
  }
118
157
  /**
119
- * Search the roster: score every topic, split hits from near-misses, then
120
- * expand seeds through the `depends` graph (both directions, geometric decay,
121
- * depth ≤ cfg.graphDepth). Expanded entries enter at the back of the hits.
158
+ * Search the roster: score every topic, apply the numeric threshold to the
159
+ * gate score (recency excluded), split by the structural gate, then expand
160
+ * gate-passed seeds through the `depends` graph (both directions, geometric
161
+ * decay, depth ≤ cfg.graphDepth). Expanded entries enter at the back of the
162
+ * hits and are exempt from the structural gate — they carry no lexical
163
+ * evidence by design, only graph adjacency. Threshold-passing candidates the
164
+ * gate rejects lead the near-misses (reason `gate-blocked`) so the replay
165
+ * evidence shows what the gate is costing.
122
166
  */
123
167
  export function searchTopics(query, roster, cfg = {}) {
124
168
  const c = { ...DEFAULT_RETRIEVAL, ...cfg };
@@ -127,9 +171,17 @@ export function searchTopics(query, roster, cfg = {}) {
127
171
  return { hits: [], nearMisses: [], rosterSize: roster.length };
128
172
  }
129
173
  const scored = roster.map((t) => scoreTopic(queryTokens, t, c)).sort((a, b) => b.score - a.score);
130
- const direct = scored.filter((s) => s.score >= c.threshold).slice(0, c.topK);
131
- const near = scored.filter((s) => s.score < c.threshold && s.score >= c.threshold * 0.5).slice(0, 8);
132
- const hits = direct.map((s) => ({ ...s, viaGraph: false }));
174
+ const passing = scored.filter((s) => s.gateScore >= c.threshold);
175
+ const directHits = passing.filter((s) => !c.structuralGate || passesGate(s)).slice(0, c.topK);
176
+ const gateBlocked = c.structuralGate
177
+ ? passing.filter((s) => !passesGate(s)).slice(0, c.topK)
178
+ : [];
179
+ const nearFloor = scored.filter((s) => s.gateScore < c.threshold && s.gateScore >= c.threshold * 0.5).slice(0, 8);
180
+ const hits = directHits.map((s) => ({ ...s, viaGraph: false }));
181
+ const nearMisses = [
182
+ ...gateBlocked.map((s) => ({ ...s, reasons: [...s.reasons, 'gate-blocked'], viaGraph: false })),
183
+ ...nearFloor.map((s) => ({ ...s, viaGraph: false })),
184
+ ].slice(0, 8);
133
185
  // Graph expansion (ADR 0005 graph-shaped bundle): walk depends + body
134
186
  // links, both directions, from the seeds; include up to topK+2 slots at
135
187
  // decaying score.
@@ -153,7 +205,9 @@ export function searchTopics(query, roster, cfg = {}) {
153
205
  }
154
206
  }
155
207
  const seen = new Set(hits.map((h) => h.slug));
156
- let frontier = hits.map((h) => ({ slug: h.slug, score: h.score }));
208
+ // Decay starts from the gateScore: recency must not re-enter through the
209
+ // graph channel after being banned from the direct gate.
210
+ let frontier = hits.map((h) => ({ slug: h.slug, score: h.gateScore ?? h.score }));
157
211
  for (let depth = 1; depth <= c.graphDepth && frontier.length > 0; depth += 1) {
158
212
  const next = [];
159
213
  for (const node of frontier) {
@@ -174,5 +228,5 @@ export function searchTopics(query, roster, cfg = {}) {
174
228
  frontier = next;
175
229
  }
176
230
  }
177
- return { hits, nearMisses: near.map((s) => ({ ...s, viaGraph: false })), rosterSize: roster.length };
231
+ return { hits, nearMisses, rosterSize: roster.length };
178
232
  }
package/lib/service.d.ts CHANGED
@@ -10,7 +10,7 @@ import { type RetrievableTopic, type SearchOutcome } from './retrieval.ts';
10
10
  import { type AssembleResult } from './digest.ts';
11
11
  import type { BundleStore, Observation, SaveResult } from './store.ts';
12
12
  import type { TopicsConfigValue } from './config.ts';
13
- import { type AggregateStats } from './ilog.ts';
13
+ import { type AggregateStats, type QueryBuildShape } from './ilog.ts';
14
14
  import type { Sync } from './sync.ts';
15
15
  export interface RetrieveOutcome {
16
16
  outcome: SearchOutcome;
@@ -18,6 +18,22 @@ export interface RetrieveOutcome {
18
18
  /** Roster was empty or injection disabled — no record written for pure no-ops. */
19
19
  recorded: boolean;
20
20
  }
21
+ /**
22
+ * Slow-lane payload handed to the hot path at consumption time (v4 §4.2).
23
+ * `items` are the rerank picks (0-2, why lines included); metadata feeds the
24
+ * ilog lane field family. The hot path resolves titles, filters dedup,
25
+ * renders, and takes the log-only shadow verdicts.
26
+ */
27
+ export interface SlowDelivery {
28
+ items: readonly {
29
+ slug: string;
30
+ why: string;
31
+ }[];
32
+ computedAt: string;
33
+ queryBuild: QueryBuildShape;
34
+ model: string;
35
+ ms: number;
36
+ }
21
37
  export declare class TopicsService {
22
38
  private cache;
23
39
  readonly store: BundleStore;
@@ -43,20 +59,44 @@ export declare class TopicsService {
43
59
  * would always lose the race and inject nothing. Reads files with sync fs
44
60
  * behind the mtime cache; the log record is written fire-and-forget.
45
61
  *
46
- * `dedup.exclude` (session-level injection dedup) filters hits AFTER
47
- * retrieval and BEFORE assembly: excluded slugs are reported back as
48
- * `deduped` (and logged as `record.deduped`), never packed. `included`
49
- * mirrors what actually entered the context — only these may be marked
50
- * as injected by the caller; budget-dropped slugs stay injectable later.
62
+ * `dedup.exclude` (session-level injection dedup) filters hits AND slow
63
+ * picks before assembly; excluded slugs are reported back as `deduped`
64
+ * (and logged as `record.deduped`), never packed. `included` mirrors the
65
+ * fast pointers that entered the context and `slowIncluded` the slow ones
66
+ * — only these may be marked as injected by the caller; budget-dropped
67
+ * slugs stay injectable later.
68
+ *
69
+ * `slow` (v4 §4.2) carries the consumed pending when one exists: picks are
70
+ * packed after the fast pointers under the same budget, take log-only
71
+ * shadow re-gate verdicts against the CURRENT query (B3: record, never
72
+ * block), and fill the record's lane field family.
51
73
  */
52
74
  retrieveSync(query: string, sessionId?: string, dedup?: {
53
75
  exclude?: ReadonlySet<string>;
54
- }): {
76
+ }, slow?: SlowDelivery, slowExpired?: 'ttl' | 'turn-lag'): {
55
77
  text: string;
56
78
  outcome: SearchOutcome;
57
79
  deduped: string[];
58
80
  included: string[];
81
+ slowIncluded: string[];
59
82
  };
83
+ /**
84
+ * topic_open tool path (v4 §4.1): full conclusion / open questions /
85
+ * recommendations plus the staleness notice timestamp (fm.generated.at —
86
+ * the store stamps it on every write, so it IS the last-updated instant).
87
+ * Logs an open record for the pointer-open-rate stat.
88
+ */
89
+ openTopic(slug: string, sessionId?: string): Promise<{
90
+ found: boolean;
91
+ slug: string;
92
+ title?: string;
93
+ status?: okf.TopicStatus;
94
+ updatedAt?: string;
95
+ description?: string;
96
+ conclusion?: string;
97
+ openQuestions?: string[];
98
+ recommendations?: string;
99
+ }>;
60
100
  /** Sync roster read (same mtime cache as roster()). */
61
101
  rosterSync(): RetrievableTopic[];
62
102
  private readDocSync;
@@ -66,6 +106,7 @@ export declare class TopicsService {
66
106
  title: string;
67
107
  description?: string;
68
108
  tags?: string[];
109
+ triggers?: string[];
69
110
  depends?: string[];
70
111
  openQuestions?: string[];
71
112
  impact?: string[];