@aiwayds/dsh-topics-memory 0.6.0 → 0.7.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/src/observer.ts CHANGED
@@ -24,8 +24,25 @@ interface SessionState {
24
24
  userText: string
25
25
  assistantText: string
26
26
  capturing: boolean
27
+ /** Last-K completed turns (v4 slow-lane data source, K≈3). */
28
+ ring: RingEntry[]
27
29
  }
28
30
 
31
+ /** One completed turn in the ring buffer — the slow lane's only context source. */
32
+ export interface RingEntry {
33
+ user: string
34
+ assistant: string
35
+ at: string
36
+ }
37
+
38
+ /**
39
+ * Ring capacity (v4 §4.2 B1): the slow lane sees at most the last 3 completed
40
+ * turns. Deliberately small — query building wants the current intent, not a
41
+ * transcript; larger windows re-introduce the verbatim-priming surface the
42
+ * lane exists to remove.
43
+ */
44
+ export const RING_CAPACITY = 3
45
+
29
46
  export interface UserMessageLike {
30
47
  source?: { kind?: string }
31
48
  content: readonly { type: string; text?: string }[]
@@ -58,12 +75,22 @@ export class Observer {
58
75
  private stateFor(sessionId: string): SessionState {
59
76
  let s = this.sessions.get(sessionId)
60
77
  if (s === undefined) {
61
- s = { turnCount: 0, userText: '', assistantText: '', capturing: false }
78
+ s = { turnCount: 0, userText: '', assistantText: '', capturing: false, ring: [] }
62
79
  this.sessions.set(sessionId, s)
63
80
  }
64
81
  return s
65
82
  }
66
83
 
84
+ /** Completed-turn snapshot for the slow lane (deep copy — callers may hold it). */
85
+ recentTurns(sessionId: string): RingEntry[] {
86
+ return (this.sessions.get(sessionId)?.ring ?? []).map((e) => ({ ...e }))
87
+ }
88
+
89
+ /** Current turn count for the session (0 before the first turn ends). */
90
+ turnCountOf(sessionId: string): number {
91
+ return this.sessions.get(sessionId)?.turnCount ?? 0
92
+ }
93
+
67
94
  /** Entry point wired to ctx.on('session/event', …). */
68
95
  onSessionEvent(sessionId: string, eventType: string, data: unknown): void {
69
96
  try {
@@ -91,8 +118,22 @@ export class Observer {
91
118
  }
92
119
  case 'turn/end': {
93
120
  state.turnCount += 1
121
+ // Cadence trigger FIRST and synchronously: index.ts dispatches the
122
+ // slow quality lane the moment onSessionEvent returns — which happens
123
+ // at the first await below (the observation write). The lane's
124
+ // distill-yield guard reads distiller.hasPending, so the every-N slot
125
+ // must be claimed before that first await or the %15 collision would
126
+ // never yield. The trigger's run fetches observations lazily; this
127
+ // turn's capture below still lands in the pool for a later run.
128
+ if (cfg.distillEveryTurns > 0 && state.turnCount % cfg.distillEveryTurns === 0) {
129
+ this.onRequestDistill(sessionId, 'every-n')
130
+ }
94
131
  const user = state.userText.trim()
95
132
  const assistant = state.assistantText.trim()
133
+ if (user !== '' || assistant !== '') {
134
+ state.ring.push({ user, assistant, at: new Date().toISOString() })
135
+ if (state.ring.length > RING_CAPACITY) state.ring.splice(0, state.ring.length - RING_CAPACITY)
136
+ }
96
137
  if (cfg.autoObserve && user !== '' && assistant !== '') {
97
138
  await this.service.store.appendObservation({
98
139
  kind: 'turn',
@@ -106,9 +147,6 @@ export class Observer {
106
147
  }
107
148
  state.userText = ''
108
149
  state.assistantText = ''
109
- if (cfg.distillEveryTurns > 0 && state.turnCount % cfg.distillEveryTurns === 0) {
110
- this.onRequestDistill(sessionId, 'every-n')
111
- }
112
150
  return
113
151
  }
114
152
  // Real session teardown: both cordis events fire at teardown (agent
package/src/okf.ts CHANGED
@@ -36,6 +36,13 @@ export interface TopicFrontmatter {
36
36
  title: string
37
37
  description?: string
38
38
  tags: string[]
39
+ /**
40
+ * Self-declared recall triggers (v4 dual-channel design §4.1): short
41
+ * phrases that should recall this topic when they appear in a query.
42
+ * Optional; bundles written before the field simply have no trigger
43
+ * matching (degraded, never an error). Highest-weight strong field.
44
+ */
45
+ triggers?: string[]
39
46
  /** Bundle-relative paths: `topics/<slug>.md`. */
40
47
  depends: string[]
41
48
  open_questions: string[]
@@ -123,6 +130,14 @@ export function parseTopicDoc(raw: string): TopicDoc {
123
130
  generated,
124
131
  }
125
132
  if (typeof fmRaw.description === 'string' && fmRaw.description !== '') fm.description = fmRaw.description
133
+ // Triggers are producer-optional and must never invalidate an otherwise
134
+ // sound doc: non-string entries are filtered, not thrown (degrade, per the
135
+ // v4 design's "旧 bundle 无此字段降级不命中").
136
+ if (fmRaw.triggers !== undefined && fmRaw.triggers !== null) {
137
+ const raw = Array.isArray(fmRaw.triggers) ? fmRaw.triggers : [fmRaw.triggers]
138
+ const triggers = raw.filter((t): t is string => typeof t === 'string' && t.trim() !== '').map((t) => t.trim())
139
+ if (triggers.length > 0) fm.triggers = triggers
140
+ }
126
141
  if ('verified' in fmRaw && fmRaw.verified !== null && fmRaw.verified !== undefined) fm.verified = asTrust(fmRaw.verified, 'verified')
127
142
  if (typeof fmRaw.stale_after === 'string' && fmRaw.stale_after !== '') fm.stale_after = fmRaw.stale_after
128
143
  if (Array.isArray(fmRaw.sources)) {
@@ -134,7 +149,7 @@ export function parseTopicDoc(raw: string): TopicDoc {
134
149
  })
135
150
  }
136
151
  // Preserve unknown producer-defined keys verbatim (OKF §4.1 extensions).
137
- const known = new Set(['type', 'title', 'description', 'tags', 'depends', 'open_questions', 'impact', 'status', 'generated', 'verified', 'stale_after', 'sources'])
152
+ const known = new Set(['type', 'title', 'description', 'tags', 'triggers', 'depends', 'open_questions', 'impact', 'status', 'generated', 'verified', 'stale_after', 'sources'])
138
153
  for (const [k, v] of Object.entries(fmRaw)) {
139
154
  if (!known.has(k)) fm[k] = v
140
155
  }
@@ -150,6 +165,7 @@ export function serializeTopicDoc(doc: TopicDoc): string {
150
165
  }
151
166
  if (fm.description !== undefined) out.description = fm.description
152
167
  out.tags = fm.tags
168
+ if (fm.triggers !== undefined && fm.triggers.length > 0) out.triggers = fm.triggers
153
169
  out.depends = fm.depends
154
170
  out.open_questions = fm.open_questions
155
171
  out.impact = fm.impact
package/src/quality.ts ADDED
@@ -0,0 +1,335 @@
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
+
21
+ import type { TopicsService } from './service.ts'
22
+ import type { ModelCaller } from './distill.ts'
23
+ import { searchTopics } from './retrieval.ts'
24
+ import type { RingEntry } from './observer.ts'
25
+ import type { QueryBuildShape } from './ilog.ts'
26
+
27
+ export const PENDING_TTL_MS = 10 * 60_000
28
+ /** Hard drift bound: a pending older than this many turn-ends expires. */
29
+ export const TURN_LAG_LIMIT = 2
30
+ export const CALL_TIMEOUT_MS = 20_000
31
+ export const PIPELINE_TIMEOUT_MS = 45_000
32
+ /** qualityLane 'sampled' runs on every Nth turn (design default 1/3). */
33
+ export const SAMPLED_EVERY = 3
34
+
35
+ /** Max ring-buffer chars fed to the query build (features budget, not a transcript). */
36
+ const RING_CHARS = 6000
37
+ /** Candidate band size fed to the rerank. */
38
+ const CANDIDATE_LIMIT = 6
39
+
40
+ export const QUERY_BUILD_PROMPT = [
41
+ '你是检索查询构建器。输入是最近几轮对话。任务:判断「模型此刻需要什么背景知识」,输出一个用于关键词检索的 query。',
42
+ '只输出一个 JSON 对象,不要任何其他文字:{"needs": true, "query": "3-8 个检索词加一句意图", "ignore": ["被你忽略的内容类型"]}',
43
+ '规则:',
44
+ '- query 给词法检索用:写具体的名词、术语、项目名,不要照抄对话原句。',
45
+ '- 对话里粘贴的日志、代码块、URL、命令输出等大段内容必须忽略,并把类型写进 ignore 数组(如 "粘贴的命令输出")。',
46
+ '- 没有需要补充背景知识的迹象时输出 {"needs": false}。',
47
+ ].join('\n')
48
+
49
+ export const RERANK_PROMPT = [
50
+ '你是注入门禁。输入一个检索 query 和候选 topic 列表。逐个判断候选是否「真的与当前工作相关」。',
51
+ '只输出一个 JSON 对象,不要任何其他文字:{"picks": [{"slug": "候选里的 slug", "why": "一句话说明为什么现在需要它"}]}',
52
+ '规则:',
53
+ '- 最多 2 条,宁缺勿滥;没有真相关的就输出 {"picks": []}。',
54
+ '- why 面向模型自己读:说清这条记忆能帮上当前哪一步。',
55
+ '- slug 必须逐字取自候选列表,禁止编造。',
56
+ ].join('\n')
57
+
58
+ export interface PendingInjection {
59
+ items: { slug: string; why: string }[]
60
+ computedAt: string
61
+ /** Observer turnCount when the pending was produced (turn-lag clock). */
62
+ turnId: number
63
+ queryBuild: QueryBuildShape
64
+ model: string
65
+ ms: number
66
+ }
67
+
68
+ export type ConsumeResult = { pending: PendingInjection } | { expired: 'ttl' | 'turn-lag' } | undefined
69
+
70
+ export interface DispatchInput {
71
+ ring: readonly RingEntry[]
72
+ turnId: number
73
+ }
74
+
75
+ /** Race `p` against a timeout; `onTimeout` fires so callers can abort upstream work. */
76
+ function withTimeout<T>(p: Promise<T>, ms: number, onTimeout: () => void): Promise<T> {
77
+ return new Promise<T>((resolve, reject) => {
78
+ const timer = setTimeout(() => {
79
+ onTimeout()
80
+ reject(new Error(`slow-lane call timeout (${ms}ms)`))
81
+ }, ms)
82
+ timer.unref?.()
83
+ p.then(
84
+ (v) => {
85
+ clearTimeout(timer)
86
+ resolve(v)
87
+ },
88
+ (e) => {
89
+ clearTimeout(timer)
90
+ reject(e)
91
+ },
92
+ )
93
+ })
94
+ }
95
+
96
+ /** Extract the first balanced JSON object from model output (tolerates fences). */
97
+ export function parseJsonObject(raw: string): Record<string, unknown> {
98
+ let text = raw.trim()
99
+ const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(text)
100
+ if (fence !== null) text = fence[1].trim()
101
+ const start = text.indexOf('{')
102
+ if (start < 0) throw new Error('no JSON object in output')
103
+ let depth = 0
104
+ let inString = false
105
+ let escaped = false
106
+ for (let i = start; i < text.length; i += 1) {
107
+ const ch = text[i]
108
+ if (escaped) {
109
+ escaped = false
110
+ continue
111
+ }
112
+ if (ch === '\\') {
113
+ escaped = true
114
+ continue
115
+ }
116
+ if (ch === '"') inString = !inString
117
+ if (inString) continue
118
+ if (ch === '{') depth += 1
119
+ if (ch === '}') {
120
+ depth -= 1
121
+ if (depth === 0) {
122
+ const parsed: unknown = JSON.parse(text.slice(start, i + 1))
123
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object')
124
+ return parsed as Record<string, unknown>
125
+ }
126
+ }
127
+ }
128
+ throw new Error('unbalanced JSON in output')
129
+ }
130
+
131
+ export class SlowLane {
132
+ private pending = new Map<string, PendingInjection>()
133
+ private inFlight = new Map<string, Promise<void>>()
134
+ private readonly service: TopicsService
135
+ private readonly caller: ModelCaller | undefined
136
+ /**
137
+ * Settle hook: fires when a session's pipeline leaves the in-flight map.
138
+ * The host uses it to re-check the sessionLlm release guards — a disposal
139
+ * that landed mid-pipeline held the entry open for THIS pipeline, and this
140
+ * callback is the only chance to release it afterwards (the distill lane
141
+ * has the same shape via its run.finally).
142
+ */
143
+ private readonly onSettle: (sessionId: string) => void
144
+
145
+ constructor(service: TopicsService, caller: ModelCaller | undefined, onSettle: (sessionId: string) => void = () => undefined) {
146
+ this.service = service
147
+ this.caller = caller
148
+ this.onSettle = onSettle
149
+ }
150
+
151
+ /** True while the session has a pending slot awaiting the next spliced. */
152
+ hasPending(sessionId: string): boolean {
153
+ return this.pending.has(sessionId)
154
+ }
155
+
156
+ /** True while the session's produce pipeline is still running (llm-capture guard). */
157
+ hasInFlight(sessionId: string): boolean {
158
+ return this.inFlight.has(sessionId)
159
+ }
160
+
161
+ /** Drop the pending slot (session teardown, restore boundary). */
162
+ clear(sessionId: string): void {
163
+ this.pending.delete(sessionId)
164
+ }
165
+
166
+ /**
167
+ * Produce a pending slot for the session's next steer message. Fire-and-
168
+ * forget; every guard failure is silent. Policy gates live here (config,
169
+ * sampling, in-flight); host gates (delegation, distill yield) live with
170
+ * the caller, which owns those services.
171
+ */
172
+ dispatch(sessionId: string, input: DispatchInput): void {
173
+ try {
174
+ const cfg = this.service.cfg
175
+ if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always') return
176
+ if (cfg.qualityLane === 'sampled' && input.turnId % SAMPLED_EVERY !== 0) return
177
+ if (this.caller === undefined || this.inFlight.has(sessionId)) return
178
+ if (input.ring.length === 0) return
179
+ const run = this.produce(sessionId, input).finally(() => {
180
+ this.inFlight.delete(sessionId)
181
+ this.onSettle(sessionId)
182
+ })
183
+ this.inFlight.set(sessionId, run)
184
+ void run.catch(() => undefined)
185
+ } catch {
186
+ // contained — the lane must never break the event handler
187
+ }
188
+ }
189
+
190
+ private async produce(sessionId: string, input: DispatchInput): Promise<void> {
191
+ const caller = this.caller
192
+ if (caller === undefined) return
193
+ const started = Date.now()
194
+ const controller = new AbortController()
195
+ const deadline = setTimeout(() => controller.abort(), PIPELINE_TIMEOUT_MS)
196
+ deadline.unref?.()
197
+ try {
198
+ const cfg = this.service.cfg
199
+ const ringText = input.ring
200
+ .map((e, i) => {
201
+ const turn = `【轮 ${i + 1}】\n用户: ${e.user}\n助手: ${e.assistant}`
202
+ return turn.length > RING_CHARS / input.ring.length ? turn.slice(0, Math.floor(RING_CHARS / input.ring.length)) : turn
203
+ })
204
+ .join('\n\n')
205
+ const buildRaw = await withTimeout(
206
+ caller({
207
+ system: QUERY_BUILD_PROMPT,
208
+ user: ringText,
209
+ purpose: 'topics-quality',
210
+ sessionId,
211
+ maxTokens: 300,
212
+ signal: controller.signal,
213
+ }),
214
+ CALL_TIMEOUT_MS,
215
+ () => controller.abort(),
216
+ )
217
+ let built: Record<string, unknown>
218
+ try {
219
+ built = parseJsonObject(buildRaw)
220
+ } catch {
221
+ return // unparseable build → no pending, nothing logged, next turn retries
222
+ }
223
+ if (built.needs !== true || typeof built.query !== 'string' || built.query.trim() === '') return
224
+ const query = built.query.trim()
225
+ const stripped = Array.isArray(built.ignore)
226
+ ? built.ignore.filter((s): s is string => typeof s === 'string' && s.trim() !== '').slice(0, 5)
227
+ : []
228
+ const queryBuild: QueryBuildShape = { rawChars: ringText.length, keptChars: query.length, stripped }
229
+
230
+ const roster = await this.service.roster().catch(() => [])
231
+ if (roster.length === 0) return
232
+ // Recall-oriented band: gate OFF (the rerank IS this lane's gate);
233
+ // candidates = threshold passers + the near-miss band beneath them.
234
+ const band = searchTopics(query, roster, {
235
+ threshold: this.service.cfg.matchThreshold,
236
+ topK: CANDIDATE_LIMIT,
237
+ tagBoost: this.service.cfg.tagBoost,
238
+ graphDepth: 0,
239
+ recencyWindowDays: this.service.cfg.recencyWindowDays,
240
+ structuralGate: false,
241
+ })
242
+ const candidates = [...band.hits, ...band.nearMisses].slice(0, CANDIDATE_LIMIT)
243
+ if (candidates.length === 0) return
244
+ const bySlug = new Map(roster.map((r) => [r.slug, r]))
245
+ const payload = candidates.flatMap((c) => {
246
+ const meta = bySlug.get(c.slug)
247
+ if (meta === undefined) return []
248
+ const conclusionFirst = meta.conclusion.split('\n').find((l) => l.trim() !== '') ?? ''
249
+ return [
250
+ {
251
+ slug: c.slug,
252
+ title: meta.title,
253
+ status: meta.status,
254
+ description: meta.description ?? '',
255
+ conclusion: conclusionFirst.slice(0, 160),
256
+ },
257
+ ]
258
+ })
259
+ if (payload.length === 0) return
260
+ const rerankRaw = await withTimeout(
261
+ caller({
262
+ system: RERANK_PROMPT,
263
+ user: [
264
+ `检索 query:${query}`,
265
+ '',
266
+ `候选 topic(${payload.length} 个):`,
267
+ JSON.stringify(payload),
268
+ ].join('\n'),
269
+ purpose: 'topics-quality',
270
+ sessionId,
271
+ maxTokens: 400,
272
+ signal: controller.signal,
273
+ }),
274
+ CALL_TIMEOUT_MS,
275
+ () => controller.abort(),
276
+ )
277
+ let reranked: Record<string, unknown>
278
+ try {
279
+ reranked = parseJsonObject(rerankRaw)
280
+ } catch {
281
+ return
282
+ }
283
+ const legal = new Set(payload.map((p) => p.slug))
284
+ const rawPicks = Array.isArray(reranked.picks) ? reranked.picks : []
285
+ const items: { slug: string; why: string }[] = []
286
+ const picked = new Set<string>()
287
+ for (const pick of rawPicks) {
288
+ if (pick === null || typeof pick !== 'object') continue
289
+ const slug = (pick as { slug?: unknown }).slug
290
+ const why = (pick as { why?: unknown }).why
291
+ if (typeof slug !== 'string' || !legal.has(slug)) continue
292
+ if (typeof why !== 'string' || why.trim() === '') continue
293
+ if (picked.has(slug)) continue
294
+ picked.add(slug)
295
+ items.push({ slug, why: why.trim().slice(0, 200) })
296
+ if (items.length >= 2) break
297
+ }
298
+ if (items.length === 0) return
299
+ const route = this.service.cfg
300
+ this.pending.set(sessionId, {
301
+ items,
302
+ computedAt: new Date().toISOString(),
303
+ turnId: input.turnId,
304
+ queryBuild,
305
+ model: `${route.distillProvider}/${route.distillModel}`,
306
+ ms: Date.now() - started,
307
+ })
308
+ } catch {
309
+ // timeouts, aborts, model errors — I3: the async product is simply absent
310
+ } finally {
311
+ clearTimeout(deadline)
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Consumption point (the next spliced). Returns the pending exactly once
317
+ * (消费即清) or an expiry reason; `turnId` is the CURRENT observer turn
318
+ * count so the drift bound can judge. Undefined when nothing is pending.
319
+ */
320
+ consume(sessionId: string, turnId: number): ConsumeResult {
321
+ const pending = this.pending.get(sessionId)
322
+ if (pending === undefined) return undefined
323
+ const produced = Date.parse(pending.computedAt)
324
+ if (!Number.isNaN(produced) && Date.now() - produced > PENDING_TTL_MS) {
325
+ this.pending.delete(sessionId)
326
+ return { expired: 'ttl' }
327
+ }
328
+ if (turnId - pending.turnId > TURN_LAG_LIMIT) {
329
+ this.pending.delete(sessionId)
330
+ return { expired: 'turn-lag' }
331
+ }
332
+ this.pending.delete(sessionId)
333
+ return { pending }
334
+ }
335
+ }
package/src/retrieval.ts CHANGED
@@ -20,6 +20,8 @@ export interface RetrievableTopic extends TopicMeta {
20
20
  conclusion: string
21
21
  /** Body-referenced topic slugs ([[wikilinks]] + markdown links) — graph edges. */
22
22
  links?: string[]
23
+ /** Self-declared recall triggers (v4 structural gate strong field). */
24
+ triggers?: string[]
23
25
  }
24
26
 
25
27
  export interface RetrievalConfig {
@@ -31,6 +33,13 @@ export interface RetrievalConfig {
31
33
  now?: Date
32
34
  /** Slugs currently conflicted — demoted, not dropped. */
33
35
  conflicts?: ReadonlySet<string>
36
+ /**
37
+ * v4 structural gate v0 (design §4.1): a threshold-passing candidate only
38
+ * injects when it ALSO hits a strong field (triggers/title/slug) or ≥2
39
+ * distinct body terms — tags alone and single body terms can no longer
40
+ * self-cross. Off for the explicit topic_search tool (the model asked).
41
+ */
42
+ structuralGate: boolean
34
43
  }
35
44
 
36
45
  export const DEFAULT_RETRIEVAL: RetrievalConfig = {
@@ -39,13 +48,22 @@ export const DEFAULT_RETRIEVAL: RetrievalConfig = {
39
48
  tagBoost: 0.15,
40
49
  graphDepth: 2,
41
50
  recencyWindowDays: 7,
51
+ structuralGate: true,
42
52
  }
43
53
 
54
+ /** Trigger hits outrank every other lexical field (pi-llm-wiki: highest weight). */
55
+ export const TRIGGER_WEIGHT = 5
56
+
44
57
  export interface SearchHit {
45
58
  slug: string
46
59
  score: number
47
60
  reasons: string[]
48
61
  viaGraph: boolean
62
+ /** Structural gate v0 evidence (absent on graph-expanded entries). */
63
+ strong?: boolean
64
+ bodyHits?: number
65
+ /** Recency-free score; graph decay starts from it (settle ties with the gate). */
66
+ gateScore?: number
49
67
  }
50
68
 
51
69
  export interface SearchOutcome {
@@ -93,6 +111,12 @@ export interface ScoredTopic {
93
111
  slug: string
94
112
  score: number
95
113
  reasons: string[]
114
+ /** Strong-field hit (triggers/title/slug) — gate v0 pass condition A. */
115
+ strong: boolean
116
+ /** Distinct query terms found in description ∪ conclusion — pass condition B is ≥2. */
117
+ bodyHits: number
118
+ /** Score with the recency tiebreaker removed — the only score the gate looks at. */
119
+ gateScore: number
96
120
  }
97
121
 
98
122
  /** Score one topic against the query tokens. */
@@ -103,21 +127,35 @@ export function scoreTopic(
103
127
  ): ScoredTopic {
104
128
  const title = new Set(tokenize(topic.title))
105
129
  const slug = new Set(tokenize(topic.slug))
130
+ const triggers = new Set((topic.triggers ?? []).flatMap((t) => tokenize(t)))
106
131
  const tags = new Set(topic.tags.flatMap((t) => tokenize(t)))
107
132
  const description = new Set(tokenize(topic.description ?? ''))
108
133
  const conclusion = new Set(tokenize(topic.conclusion))
109
134
  const reasons: string[] = []
110
135
  let score = 0
111
136
 
137
+ const cTriggers = containment(queryTokens, triggers)
138
+ if (cTriggers > 0) {
139
+ score += TRIGGER_WEIGHT * cTriggers
140
+ reasons.push(`triggers:${cTriggers.toFixed(2)}`)
141
+ }
112
142
  const cTitle = containment(queryTokens, title)
113
143
  if (cTitle > 0) {
114
144
  score += 3 * cTitle
115
145
  reasons.push(`title:${cTitle.toFixed(2)}`)
116
146
  }
117
- const cTag = containment(queryTokens, new Set([...tags, ...slug]))
118
- if (cTag > 0) {
119
- score += 2 * cTag
120
- reasons.push(`tags:${cTag.toFixed(2)}`)
147
+ // Slug and tags are scored separately since the structural gate (v4): the
148
+ // slug is a strong field, tags are not. Weights unchanged (2) so historical
149
+ // score distributions stay comparable.
150
+ const cSlug = containment(queryTokens, slug)
151
+ if (cSlug > 0) {
152
+ score += 2 * cSlug
153
+ reasons.push(`slug:${cSlug.toFixed(2)}`)
154
+ }
155
+ const cTags = containment(queryTokens, tags)
156
+ if (cTags > 0) {
157
+ score += 2 * cTags
158
+ reasons.push(`tags:${cTags.toFixed(2)}`)
121
159
  }
122
160
  const cDesc = containment(queryTokens, description)
123
161
  if (cDesc > 0) {
@@ -129,25 +167,28 @@ export function scoreTopic(
129
167
  score += 0.8 * cConc
130
168
  reasons.push(`conclusion:${cConc.toFixed(2)}`)
131
169
  }
132
- // Exact tag hit boost, additive and capped (pi-topic-memory lesson: add,
133
- // never fold into the token set — dilution breaks the guard).
170
+ // Exact tag hit boost, additive and capped at ONE boost's worth (v4 design:
171
+ // the old ×3 cap let shared tags mint a 0.45 floor — the priming main
172
+ // culprit). Never folded into the token set (dilution breaks the guard).
134
173
  let tagHits = 0
135
174
  for (const t of tags) {
136
175
  if (overlap(queryTokens, new Set([...tokenize(t)]))) tagHits += 1
137
176
  }
138
177
  if (tagHits > 0) {
139
- const boost = Math.min(cfg.tagBoost * tagHits, cfg.tagBoost * 3)
178
+ const boost = Math.min(cfg.tagBoost * tagHits, cfg.tagBoost)
140
179
  score += boost
141
180
  reasons.push(`tag-boost:+${boost.toFixed(2)}`)
142
181
  }
143
- // Recency is a tiebreaker among relevant topics, never a free pass: only
144
- // fires when the query already matched some content above.
182
+ // Recency is a RANKING tiebreaker only (v4): it pads `score` but is kept
183
+ // out of `gateScore`, so it can never carry a candidate across the numeric
184
+ // threshold. Conflict demotion applies to the pre-tiebreaker score.
185
+ let recencyBonus = 0
145
186
  if (score > 0 && cfg.recencyWindowDays > 0) {
146
187
  const generated = Date.parse(topic.generatedAt)
147
188
  if (!Number.isNaN(generated)) {
148
189
  const ageDays = ((cfg.now?.getTime() ?? Date.now()) - generated) / 86_400_000
149
190
  if (ageDays >= 0 && ageDays <= cfg.recencyWindowDays) {
150
- score += 0.2
191
+ recencyBonus = 0.2
151
192
  reasons.push('recency')
152
193
  }
153
194
  }
@@ -156,13 +197,36 @@ export function scoreTopic(
156
197
  score *= 0.3
157
198
  reasons.push('conflicted-demoted')
158
199
  }
159
- return { slug: topic.slug, score: Math.round(score * 1000) / 1000, reasons }
200
+ const gateScore = score
201
+ score += recencyBonus
202
+ const bodySet = new Set([...description, ...conclusion])
203
+ let bodyHits = 0
204
+ for (const t of queryTokens) if (bodySet.has(t)) bodyHits += 1
205
+ const strong = cTriggers > 0 || cTitle > 0 || cSlug > 0
206
+ return {
207
+ slug: topic.slug,
208
+ score: Math.round(score * 1000) / 1000,
209
+ reasons,
210
+ strong,
211
+ bodyHits,
212
+ gateScore: Math.round(gateScore * 1000) / 1000,
213
+ }
214
+ }
215
+
216
+ /** Structural gate v0 verdict: strong-field hit, or ≥2 distinct body terms. */
217
+ export function passesGate(s: { strong: boolean; bodyHits: number }): boolean {
218
+ return s.strong || s.bodyHits >= 2
160
219
  }
161
220
 
162
221
  /**
163
- * Search the roster: score every topic, split hits from near-misses, then
164
- * expand seeds through the `depends` graph (both directions, geometric decay,
165
- * depth ≤ cfg.graphDepth). Expanded entries enter at the back of the hits.
222
+ * Search the roster: score every topic, apply the numeric threshold to the
223
+ * gate score (recency excluded), split by the structural gate, then expand
224
+ * gate-passed seeds through the `depends` graph (both directions, geometric
225
+ * decay, depth ≤ cfg.graphDepth). Expanded entries enter at the back of the
226
+ * hits and are exempt from the structural gate — they carry no lexical
227
+ * evidence by design, only graph adjacency. Threshold-passing candidates the
228
+ * gate rejects lead the near-misses (reason `gate-blocked`) so the replay
229
+ * evidence shows what the gate is costing.
166
230
  */
167
231
  export function searchTopics(query: string, roster: readonly RetrievableTopic[], cfg: Partial<RetrievalConfig> = {}): SearchOutcome {
168
232
  const c: RetrievalConfig = { ...DEFAULT_RETRIEVAL, ...cfg }
@@ -171,10 +235,18 @@ export function searchTopics(query: string, roster: readonly RetrievableTopic[],
171
235
  return { hits: [], nearMisses: [], rosterSize: roster.length }
172
236
  }
173
237
  const scored = roster.map((t) => scoreTopic(queryTokens, t, c)).sort((a, b) => b.score - a.score)
174
- const direct = scored.filter((s) => s.score >= c.threshold).slice(0, c.topK)
175
- const near = scored.filter((s) => s.score < c.threshold && s.score >= c.threshold * 0.5).slice(0, 8)
238
+ const passing = scored.filter((s) => s.gateScore >= c.threshold)
239
+ const directHits = passing.filter((s) => !c.structuralGate || passesGate(s)).slice(0, c.topK)
240
+ const gateBlocked = c.structuralGate
241
+ ? passing.filter((s) => !passesGate(s)).slice(0, c.topK)
242
+ : []
243
+ const nearFloor = scored.filter((s) => s.gateScore < c.threshold && s.gateScore >= c.threshold * 0.5).slice(0, 8)
176
244
 
177
- const hits: SearchHit[] = direct.map((s) => ({ ...s, viaGraph: false }))
245
+ const hits: SearchHit[] = directHits.map((s) => ({ ...s, viaGraph: false }))
246
+ const nearMisses: SearchHit[] = [
247
+ ...gateBlocked.map((s) => ({ ...s, reasons: [...s.reasons, 'gate-blocked'], viaGraph: false })),
248
+ ...nearFloor.map((s) => ({ ...s, viaGraph: false })),
249
+ ].slice(0, 8)
178
250
  // Graph expansion (ADR 0005 graph-shaped bundle): walk depends + body
179
251
  // links, both directions, from the seeds; include up to topK+2 slots at
180
252
  // decaying score.
@@ -196,7 +268,9 @@ export function searchTopics(query: string, roster: readonly RetrievableTopic[],
196
268
  }
197
269
  }
198
270
  const seen = new Set(hits.map((h) => h.slug))
199
- let frontier = hits.map((h) => ({ slug: h.slug, score: h.score }))
271
+ // Decay starts from the gateScore: recency must not re-enter through the
272
+ // graph channel after being banned from the direct gate.
273
+ let frontier = hits.map((h) => ({ slug: h.slug, score: h.gateScore ?? h.score }))
200
274
  for (let depth = 1; depth <= c.graphDepth && frontier.length > 0; depth += 1) {
201
275
  const next: { slug: string; score: number }[] = []
202
276
  for (const node of frontier) {
@@ -215,5 +289,5 @@ export function searchTopics(query: string, roster: readonly RetrievableTopic[],
215
289
  frontier = next
216
290
  }
217
291
  }
218
- return { hits, nearMisses: near.map((s) => ({ ...s, viaGraph: false })), rosterSize: roster.length }
292
+ return { hits, nearMisses, rosterSize: roster.length }
219
293
  }