@psiclawops/hypermem 0.9.6 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/INSTALL.md +29 -9
  3. package/README.md +5 -1
  4. package/assets/default-config.json +20 -5
  5. package/assets/runtime-validation-fixture.json +123 -0
  6. package/bin/hypermem-cleanup.mjs +334 -0
  7. package/bin/hypermem-doctor.mjs +71 -0
  8. package/bin/hypermem-validate-runtime.mjs +282 -0
  9. package/dist/compositor.d.ts +43 -5
  10. package/dist/compositor.d.ts.map +1 -1
  11. package/dist/compositor.js +802 -30
  12. package/dist/entity-bridge-backfill.d.ts +66 -0
  13. package/dist/entity-bridge-backfill.d.ts.map +1 -0
  14. package/dist/entity-bridge-backfill.js +145 -0
  15. package/dist/entity-bridge-store.d.ts +164 -0
  16. package/dist/entity-bridge-store.d.ts.map +1 -0
  17. package/dist/entity-bridge-store.js +488 -0
  18. package/dist/entity-extractor.d.ts +124 -0
  19. package/dist/entity-extractor.d.ts.map +1 -0
  20. package/dist/entity-extractor.js +382 -0
  21. package/dist/entity-ppr.d.ts +55 -0
  22. package/dist/entity-ppr.d.ts.map +1 -0
  23. package/dist/entity-ppr.js +180 -0
  24. package/dist/hybrid-retrieval.d.ts +27 -0
  25. package/dist/hybrid-retrieval.d.ts.map +1 -1
  26. package/dist/hybrid-retrieval.js +26 -1
  27. package/dist/index.d.ts +19 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +63 -13
  30. package/dist/message-store.d.ts +36 -0
  31. package/dist/message-store.d.ts.map +1 -1
  32. package/dist/message-store.js +155 -1
  33. package/dist/open-domain.d.ts +13 -4
  34. package/dist/open-domain.d.ts.map +1 -1
  35. package/dist/open-domain.js +222 -20
  36. package/dist/profiles.js +13 -13
  37. package/dist/question-shape.d.ts +73 -0
  38. package/dist/question-shape.d.ts.map +1 -0
  39. package/dist/question-shape.js +230 -0
  40. package/dist/schema.d.ts +1 -1
  41. package/dist/schema.d.ts.map +1 -1
  42. package/dist/schema.js +92 -1
  43. package/dist/topic-detector.d.ts.map +1 -1
  44. package/dist/topic-detector.js +22 -9
  45. package/dist/types.d.ts +176 -2
  46. package/dist/types.d.ts.map +1 -1
  47. package/dist/vector-store.d.ts +6 -0
  48. package/dist/vector-store.d.ts.map +1 -1
  49. package/dist/vector-store.js +3 -0
  50. package/docs/DIAGNOSTICS.md +47 -0
  51. package/docs/INTEGRATION_VALIDATION.md +24 -4
  52. package/docs/TUNING.md +21 -21
  53. package/memory-plugin/dist/index.d.ts +3 -3
  54. package/memory-plugin/dist/index.js +4 -2
  55. package/memory-plugin/openclaw.plugin.json +5 -0
  56. package/memory-plugin/package.json +10 -6
  57. package/package.json +22 -5
  58. package/plugin/dist/index.d.ts +3 -3
  59. package/plugin/dist/index.d.ts.map +1 -1
  60. package/plugin/dist/index.js +115 -13
  61. package/plugin/dist/index.js.map +1 -1
  62. package/plugin/package.json +10 -6
  63. package/scripts/install-runtime.mjs +4 -1
@@ -17,9 +17,113 @@
17
17
  * raw message history regardless of quality gate.
18
18
  */
19
19
  // ── Open-domain signal patterns ───────────────────────────────────────────
20
- const BROAD_INTERROGATIVE = /\b(what did|what does|what has|what was|what were|what is|how did|how does|how has|tell me about|describe|explain|summarize|overview|recap|what do you know about|what have|who is|who was|who did)\b/i;
21
- const SPECIFIC_ANCHOR = /\b([A-Z][a-z]{2,}(?:\s+[A-Z][a-z]{2,})+|v\d+\.\d+|#\d{2,}|https?:\/\/|[A-Z]{2,}-\d+)\b/;
20
+ const BROAD_INTERROGATIVE = /\b(what did|what does|what has|what was|what were|what is|what are|how did|how does|how has|tell me about|describe|explain|summarize|overview|recap|what do you know about|what have|who is|who was|who did)\b/i;
21
+ // LoCoMo category-3/open-domain questions are often inferential rather than
22
+ // classic WH recall. Keep this benchmark-agnostic: these are question shapes
23
+ // that need raw dialogue evidence, not answer terms.
24
+ const INFERENTIAL_OPEN_DOMAIN = /\b(what might|would\b.*\b(enjoy|consider|considered|likely|pursue|be)\b|could\b.*\b(enjoy|consider|likely|pursue|be)\b|should\b.*\b(enjoy|consider|likely|pursue|be)\b|is it likely|which country|in what country|what fields?|suspected health|financial status)\b/i;
25
+ const SPECIFIC_NON_DIALOG_ANCHOR = /\b(v\d+\.\d+|#\d{2,}|https?:\/\/|[A-Z]{2,}-\d+)\b/;
22
26
  const TEMPORAL_SIGNALS = /\b(before|after|when|last\s+\w+|yesterday|today|recently|between|since|until|ago|this\s+week|this\s+month|in\s+(january|february|march|april|may|june|july|august|september|october|november|december))\b/i;
27
+ const OPEN_DOMAIN_FACETS = [
28
+ {
29
+ name: 'education-career',
30
+ pattern: /\b(educat\w*|field|fields|career|pursue|certification|training|study|school|college|class|degree)\b/i,
31
+ terms: ['education', 'school', 'college', 'study', 'class', 'degree', 'training', 'certificate', 'certification', 'career', 'work', 'job', 'interest', 'interested'],
32
+ },
33
+ {
34
+ name: 'financial-status',
35
+ pattern: /\b(financial|status|wealth|wealthy|money|afford|income|class|expensive|cost)\b/i,
36
+ terms: ['money', 'financial', 'finance', 'wealth', 'wealthy', 'income', 'afford', 'expensive', 'cost', 'job', 'work', 'salary', 'rent', 'house', 'apartment', 'vacation', 'donate', 'donation', 'charity', 'fundraiser'],
37
+ },
38
+ {
39
+ name: 'social-circle',
40
+ pattern: /\b(friend|friends|besides|teammate|teammates|team|group|social)\b/i,
41
+ terms: ['friend', 'friends', 'teammate', 'teammates', 'team', 'group', 'club', 'community', 'classmate', 'coworker', 'game', 'games', 'gaming', 'video', 'online', 'player', 'players'],
42
+ },
43
+ {
44
+ name: 'reading-preference',
45
+ pattern: /\b(read|reading|book|books|author|novel|writer|lewis|greene|green)\b/i,
46
+ terms: ['read', 'reading', 'book', 'books', 'author', 'authors', 'novel', 'writer', 'story', 'stories', 'fiction', 'fantasy', 'literature', 'library', 'recommendation', 'recommend'],
47
+ },
48
+ {
49
+ name: 'activity-pet',
50
+ pattern: /\b(indoor|activity|activities|dog|dogs|puppy|pet|happy|hobby|hobbies|treat|treats)\b/i,
51
+ terms: ['indoor', 'activity', 'activities', 'dog', 'dogs', 'puppy', 'pet', 'happy', 'hobby', 'hobbies', 'cook', 'cooking', 'bake', 'baking', 'recipe', 'treat', 'treats', 'kitchen', 'homemade', 'cookie', 'cookies', 'biscuit', 'biscuits'],
52
+ },
53
+ {
54
+ name: 'health-status',
55
+ pattern: /\b(health|problem|problems|suspected|medical|condition|weight|exercise|diet|symptom|symptoms)\b/i,
56
+ terms: ['health', 'medical', 'condition', 'problem', 'problems', 'weight', 'exercise', 'diet', 'doctor', 'symptom', 'symptoms'],
57
+ },
58
+ {
59
+ name: 'travel-country',
60
+ pattern: /\b(country|visiting|visit|visited|travel|trip|vacation|pendant|souvenir|mother)\b/i,
61
+ terms: ['country', 'visit', 'visited', 'visiting', 'travel', 'trip', 'vacation', 'souvenir', 'pendant', 'mother', 'abroad'],
62
+ },
63
+ {
64
+ name: 'civic-patriotic',
65
+ pattern: /\b(patriotic|patriot|country|flag|military|veteran|service|civic|community)\b/i,
66
+ terms: ['patriotic', 'patriot', 'country', 'flag', 'military', 'veteran', 'service', 'civic', 'community', 'charity', 'fundraiser', 'volunteer', 'memorial', 'parade', 'independence', 'america', 'american', 'national', 'vote', 'voting', 'election'],
67
+ },
68
+ ];
69
+ const QUERY_INITIAL_WORDS = new Set([
70
+ 'what', 'which', 'would', 'could', 'should', 'is', 'in', 'how', 'who',
71
+ ]);
72
+ export function extractOpenDomainAnchors(query) {
73
+ const anchors = [];
74
+ const tokens = query.match(/\b[A-Z][a-zA-Z]{2,}\b/g) ?? [];
75
+ for (const token of tokens) {
76
+ const lower = token.toLowerCase();
77
+ if (QUERY_INITIAL_WORDS.has(lower))
78
+ continue;
79
+ anchors.push(lower);
80
+ }
81
+ return [...new Set(anchors)];
82
+ }
83
+ function matchedOpenDomainFacets(query) {
84
+ return OPEN_DOMAIN_FACETS.filter(facet => facet.pattern.test(query));
85
+ }
86
+ export function expandOpenDomainQueryTerms(query, terms) {
87
+ const expanded = [...extractOpenDomainAnchors(query), ...terms];
88
+ for (const facet of matchedOpenDomainFacets(query)) {
89
+ expanded.push(...facet.terms);
90
+ }
91
+ return [...new Set(expanded)].slice(0, 40);
92
+ }
93
+ function toFtsAndQuery(anchorTerms, facetTerms, limit) {
94
+ const anchors = [...new Set(anchorTerms)]
95
+ .map(w => w.replace(/"/g, '').trim())
96
+ .filter(Boolean)
97
+ .slice(0, 4);
98
+ const facets = [...new Set(facetTerms)]
99
+ .map(w => w.replace(/"/g, '').trim())
100
+ .filter(Boolean)
101
+ .slice(0, limit);
102
+ if (anchors.length === 0 || facets.length === 0)
103
+ return null;
104
+ const anchorQuery = anchors.map(w => `"${w}"*`).join(' OR ');
105
+ const facetQuery = facets.map(w => `"${w}"*`).join(' OR ');
106
+ return `(${anchorQuery}) AND (${facetQuery})`;
107
+ }
108
+ export function scoreOpenDomainEvidence(content, query, baseTerms) {
109
+ const lower = content.toLowerCase();
110
+ let score = 0;
111
+ for (const anchor of extractOpenDomainAnchors(query)) {
112
+ if (lower.includes(anchor))
113
+ score += 8;
114
+ }
115
+ for (const term of baseTerms) {
116
+ if (lower.includes(term))
117
+ score += term.length >= 6 ? 2 : 1;
118
+ }
119
+ for (const facet of matchedOpenDomainFacets(query)) {
120
+ for (const term of facet.terms) {
121
+ if (lower.includes(term))
122
+ score += 1;
123
+ }
124
+ }
125
+ return score;
126
+ }
23
127
  /**
24
128
  * Returns true if the query looks like an open-domain question:
25
129
  * broad, exploratory, no specific anchors, no temporal signals.
@@ -27,18 +131,25 @@ const TEMPORAL_SIGNALS = /\b(before|after|when|last\s+\w+|yesterday|today|recent
27
131
  export function isOpenDomainQuery(query) {
28
132
  if (!query || query.trim().length < 8)
29
133
  return false;
30
- // Has temporal signals → temporal path handles it
31
- if (TEMPORAL_SIGNALS.test(query))
134
+ // Has temporal signals → temporal path handles it, unless the query is also
135
+ // a broad/inferential open-domain question. LoCoMo category-3 questions often
136
+ // mention dates while still requiring raw-message inference rather than a
137
+ // pure temporal answer.
138
+ const broad = BROAD_INTERROGATIVE.test(query) || INFERENTIAL_OPEN_DOMAIN.test(query);
139
+ if (TEMPORAL_SIGNALS.test(query) && !broad)
32
140
  return false;
33
- // Has specific named entity / version / ticket anchor not open-domain
34
- if (SPECIFIC_ANCHOR.test(query))
141
+ // Version, ticket, and URL anchors usually belong to specific retrieval paths.
142
+ // Do not exclude named people/places here: LoCoMo open-domain questions often
143
+ // ask broad questions about a named speaker, and the entity is the useful
144
+ // retrieval anchor rather than a reason to bypass raw-message recall.
145
+ if (SPECIFIC_NON_DIALOG_ANCHOR.test(query))
35
146
  return false;
36
147
  // Must match a broad interrogative pattern
37
- if (!BROAD_INTERROGATIVE.test(query))
148
+ if (!broad)
38
149
  return false;
39
150
  // Sanity: query should not be too long (long queries are usually specific)
40
151
  const wordCount = query.trim().split(/\s+/).length;
41
- if (wordCount > 20)
152
+ if (wordCount > 28)
42
153
  return false;
43
154
  return true;
44
155
  }
@@ -48,23 +159,55 @@ export function isOpenDomainQuery(query) {
48
159
  * Strips stop words, question words, and punctuation.
49
160
  * Returns up to 6 prefix-matched terms joined with OR.
50
161
  */
51
- export function buildOpenDomainFtsQuery(query) {
162
+ function tokenizeOpenDomainQuery(query) {
52
163
  const STOP_WORDS = new Set([
53
164
  'what', 'did', 'does', 'has', 'was', 'were', 'is', 'are', 'how',
54
165
  'tell', 'me', 'about', 'describe', 'explain', 'summarize', 'overview',
55
166
  'recap', 'who', 'do', 'you', 'know', 'have', 'the', 'a', 'an', 'of',
56
167
  'in', 'on', 'at', 'to', 'for', 'and', 'or', 'but', 'with', 'from',
168
+ 'their', 'them', 'they', 'your', 'his', 'her', 'him', 'she', 'he',
169
+ 'would', 'could', 'should', 'might', 'likely', 'considered', 'consider',
170
+ 'besides', 'while', 'make', 'doing', 'person',
57
171
  ]);
58
172
  const terms = query
59
173
  .toLowerCase()
60
- .replace(/[^a-z0-9\s]/g, ' ')
174
+ .replace(/[^a-z0-9\s-]/g, ' ')
175
+ .replace(/-/g, ' ')
61
176
  .split(/\s+/)
62
- .filter(w => w.length >= 3 && !STOP_WORDS.has(w))
63
- .slice(0, 6)
64
- .map(w => `"${w}"*`);
65
- if (terms.length === 0)
177
+ .map(w => w.trim())
178
+ .filter(w => w.length >= 3 && !STOP_WORDS.has(w));
179
+ return expandOpenDomainQueryTerms(query, terms);
180
+ }
181
+ function toFtsOrQuery(terms, limit) {
182
+ const unique = [...new Set(terms)]
183
+ .slice(0, limit)
184
+ .map(w => `"${w.replace(/"/g, '')}"*`);
185
+ if (unique.length === 0)
66
186
  return null;
67
- return terms.join(' OR ');
187
+ return unique.join(' OR ');
188
+ }
189
+ export function buildOpenDomainFtsQuery(query) {
190
+ return toFtsOrQuery(tokenizeOpenDomainQuery(query), 8);
191
+ }
192
+ /**
193
+ * Build multiple prompt-only FTS probes for broad open-domain questions.
194
+ * The primary query favors specific terms; the secondary query preserves the
195
+ * natural query order so shorter but important entity/activity terms are not
196
+ * lost when the broad question contains many long words.
197
+ */
198
+ export function buildOpenDomainFtsQueries(query) {
199
+ const terms = tokenizeOpenDomainQuery(query);
200
+ const anchors = extractOpenDomainAnchors(query);
201
+ const baseTerms = terms.filter(term => !anchors.includes(term));
202
+ const facetQueries = matchedOpenDomainFacets(query)
203
+ .map(facet => toFtsAndQuery(anchors, facet.terms, 10))
204
+ .filter((q) => Boolean(q));
205
+ const queries = [
206
+ ...facetQueries,
207
+ toFtsOrQuery(terms, 10),
208
+ toFtsOrQuery(baseTerms, 12),
209
+ ].filter((q) => Boolean(q));
210
+ return [...new Set(queries)];
68
211
  }
69
212
  /**
70
213
  * Search raw message history via FTS5 for open-domain queries.
@@ -76,11 +219,12 @@ export function buildOpenDomainFtsQuery(query) {
76
219
  * @param limit — max results (default 10)
77
220
  */
78
221
  export function searchOpenDomain(db, query, existingContent, limit = 10) {
79
- const ftsQuery = buildOpenDomainFtsQuery(query);
80
- if (!ftsQuery)
222
+ const ftsQueries = buildOpenDomainFtsQueries(query);
223
+ if (ftsQueries.length === 0)
81
224
  return [];
82
225
  try {
83
- const rows = db.prepare(`
226
+ const rowsById = new Map();
227
+ const hitStmt = db.prepare(`
84
228
  WITH fts_matches AS (
85
229
  SELECT rowid, rank
86
230
  FROM messages_fts
@@ -89,9 +233,13 @@ export function searchOpenDomain(db, query, existingContent, limit = 10) {
89
233
  LIMIT ?
90
234
  )
91
235
  SELECT
236
+ m.id,
237
+ m.conversation_id AS conversationId,
92
238
  m.role,
93
239
  m.text_content AS content,
94
- m.created_at AS createdAt
240
+ m.created_at AS createdAt,
241
+ m.message_index AS messageIndex,
242
+ fts_matches.rank AS rank
95
243
  FROM messages m
96
244
  JOIN fts_matches ON m.id = fts_matches.rowid
97
245
  WHERE m.role IN ('user', 'assistant')
@@ -99,7 +247,61 @@ export function searchOpenDomain(db, query, existingContent, limit = 10) {
99
247
  AND trim(m.text_content) != ''
100
248
  AND m.is_heartbeat = 0
101
249
  ORDER BY fts_matches.rank
102
- `).all(ftsQuery, limit * 2);
250
+ `);
251
+ const neighborStmt = db.prepare(`
252
+ SELECT
253
+ id,
254
+ conversation_id AS conversationId,
255
+ role,
256
+ text_content AS content,
257
+ created_at AS createdAt,
258
+ message_index AS messageIndex
259
+ FROM messages
260
+ WHERE conversation_id = ?
261
+ AND message_index BETWEEN ? AND ?
262
+ AND role IN ('user', 'assistant')
263
+ AND text_content IS NOT NULL
264
+ AND trim(text_content) != ''
265
+ AND is_heartbeat = 0
266
+ ORDER BY message_index ASC
267
+ `);
268
+ for (const ftsQuery of ftsQueries) {
269
+ const hits = hitStmt.all(ftsQuery, limit * 2);
270
+ for (const hit of hits) {
271
+ if (!rowsById.has(hit.id))
272
+ rowsById.set(hit.id, hit);
273
+ // Preserve local dialogue context. Open-domain answers often live in the
274
+ // assistant turn adjacent to a broad user turn, or vice versa.
275
+ if (hit.conversationId == null)
276
+ continue;
277
+ const messageIndex = hit.messageIndex ?? 0;
278
+ const neighbors = neighborStmt.all(hit.conversationId, messageIndex - 2, messageIndex + 2);
279
+ for (const neighbor of neighbors) {
280
+ if (!rowsById.has(neighbor.id))
281
+ rowsById.set(neighbor.id, {
282
+ ...neighbor,
283
+ rank: hit.rank,
284
+ });
285
+ }
286
+ }
287
+ }
288
+ const baseTerms = tokenizeOpenDomainQuery(query);
289
+ for (const row of rowsById.values()) {
290
+ row.anchorScore = scoreOpenDomainEvidence(row.content ?? '', query, baseTerms);
291
+ }
292
+ const rows = [...rowsById.values()].sort((a, b) => {
293
+ const scoreA = a.anchorScore ?? 0;
294
+ const scoreB = b.anchorScore ?? 0;
295
+ if (scoreA !== scoreB)
296
+ return scoreB - scoreA;
297
+ const rankA = a.rank ?? Number.MAX_SAFE_INTEGER;
298
+ const rankB = b.rank ?? Number.MAX_SAFE_INTEGER;
299
+ if (rankA !== rankB)
300
+ return rankA - rankB;
301
+ if ((a.conversationId ?? 0) !== (b.conversationId ?? 0))
302
+ return (a.conversationId ?? 0) - (b.conversationId ?? 0);
303
+ return (a.messageIndex ?? 0) - (b.messageIndex ?? 0);
304
+ });
103
305
  // Deduplicate against existing context and filter short content
104
306
  const seen = new Set();
105
307
  const results = [];
package/dist/profiles.js CHANGED
@@ -108,21 +108,21 @@ export const lightProfile = {
108
108
  // ---------------------------------------------------------------------------
109
109
  const STANDARD_COMPOSITOR = {
110
110
  // ── Primary budget controls ──
111
- budgetFraction: 0.703, // 90k effective at 128k window
111
+ budgetFraction: 0.60, // operational default: ~77k effective at 128k before reserve
112
112
  reserveFraction: 0.25, // balanced — leaves room for large tool results
113
113
  historyFraction: 0.40, // ~27k tokens of conversation history
114
114
  memoryFraction: 0.40, // ~27k tokens for facts/wiki/semantic
115
115
  // ── Absolute fallback ──
116
116
  defaultTokenBudget: 90000,
117
117
  // ── History internals ──
118
- maxHistoryMessages: 500,
119
- warmHistoryBudgetFraction: 0.40,
120
- keystoneHistoryFraction: 0.20,
121
- keystoneMaxMessages: 15,
118
+ maxHistoryMessages: 250,
119
+ warmHistoryBudgetFraction: 0.27,
120
+ keystoneHistoryFraction: 0.15,
121
+ keystoneMaxMessages: 12,
122
122
  keystoneMinSignificance: 0.5,
123
123
  // ── Memory internals ──
124
- maxFacts: 30,
125
- maxCrossSessionContext: 4000,
124
+ maxFacts: 25,
125
+ maxCrossSessionContext: 0,
126
126
  maxTotalTriggerTokens: 4000,
127
127
  wikiTokenCap: 600,
128
128
  // ── Tool gradient (internal — safe floor enforced automatically) ──
@@ -173,14 +173,14 @@ const EXTENDED_COMPOSITOR = {
173
173
  // ── Absolute fallback ──
174
174
  defaultTokenBudget: 160000,
175
175
  // ── History internals ──
176
- maxHistoryMessages: 1000,
177
- warmHistoryBudgetFraction: 0.45,
178
- keystoneHistoryFraction: 0.25,
179
- keystoneMaxMessages: 30,
176
+ maxHistoryMessages: 500,
177
+ warmHistoryBudgetFraction: 0.27,
178
+ keystoneHistoryFraction: 0.15,
179
+ keystoneMaxMessages: 12,
180
180
  keystoneMinSignificance: 0.4,
181
181
  // ── Memory internals ──
182
- maxFacts: 60,
183
- maxCrossSessionContext: 12000,
182
+ maxFacts: 25,
183
+ maxCrossSessionContext: 4000,
184
184
  maxTotalTriggerTokens: 10000,
185
185
  wikiTokenCap: 800,
186
186
  // ── Tool gradient (internal — safe floor enforced automatically) ──
@@ -0,0 +1,73 @@
1
+ /**
2
+ * question-shape.ts — Heuristic v1 multi-hop question shape detector
3
+ *
4
+ * Sprint A of the multi-hop closure plan: deterministic, no model call.
5
+ * Classifies a query as 'multi-hop' when it appears to require bridging
6
+ * evidence across two or more distinct named entities or entity+facet pairs.
7
+ *
8
+ * Detection logic (all deterministic):
9
+ * - Extract named entities (TitleCase spans, quoted strings, capitalized 2+ tokens)
10
+ * - Extract facet terms from the LoCoMo answer-bearing noun lexicon
11
+ * - Multi-hop if: (2+ entities) OR (1 entity + 1 facet), PLUS a relation word
12
+ *
13
+ * False-positive gate: temporal-anchor / single-hop-span queries are NOT
14
+ * multi-hop even when they have multiple entity tokens. A FP-rate check
15
+ * hook is available via `questionShapeFalsePositiveScore`.
16
+ *
17
+ * Exported symbols:
18
+ * detectQuestionShape(query) → QuestionShape
19
+ * questionShapeFalsePositiveScore(query) → number (0 = likely real, 1 = likely FP)
20
+ */
21
+ export interface QuestionShape {
22
+ /** 'multi-hop' = requires bridging evidence across 2+ entities or entity+facet */
23
+ kind: 'multi-hop' | 'single-hop';
24
+ /** Named entity tokens extracted from the query */
25
+ entities: string[];
26
+ /** Facet terms matched from the LoCoMo facet lexicon */
27
+ facets: string[];
28
+ /**
29
+ * Confidence in the multi-hop classification. 0–1.
30
+ * For 'single-hop', this is a confidence in NOT being multi-hop.
31
+ */
32
+ confidence: number;
33
+ }
34
+ export declare const QUESTION_SHAPE_FACETS: Array<{
35
+ name: string;
36
+ terms: string[];
37
+ }>;
38
+ /**
39
+ * Extract named entity candidates from a query string.
40
+ * Returns deduplicated lowercase entity tokens.
41
+ */
42
+ export declare function extractQueryEntities(query: string): string[];
43
+ /**
44
+ * Extract facet terms from a query string using the LoCoMo facet lexicon.
45
+ * Returns matched facet group names (deduplicated).
46
+ */
47
+ export declare function extractQueryFacets(query: string): string[];
48
+ /**
49
+ * Extract matched facet terms (raw tokens, not group names) from a query.
50
+ * Used for structured handoff header annotation.
51
+ */
52
+ export declare function extractQueryFacetTerms(query: string): string[];
53
+ /**
54
+ * Estimate the probability that a 'multi-hop' classification is a false positive.
55
+ * Returns 0.0 (clearly multi-hop) to 1.0 (clearly single-hop / FP).
56
+ *
57
+ * High FP score → do not apply structured handoff even if multi-hop shape detected.
58
+ * Spec threshold: FP rate > 0.30 on held-out single-hop-multi-entity set →
59
+ * add a negative check. This function provides that check.
60
+ */
61
+ export declare function questionShapeFalsePositiveScore(query: string): number;
62
+ /**
63
+ * Detect whether a query has multi-hop shape.
64
+ *
65
+ * Multi-hop criteria (all must be true):
66
+ * 1. 2+ named entities OR (1 entity + 1 facet group)
67
+ * 2. At least one relation/intersection word
68
+ * 3. False-positive score < 0.60
69
+ *
70
+ * Returns a QuestionShape with extracted entities, facets, and confidence.
71
+ */
72
+ export declare function detectQuestionShape(query: string): QuestionShape;
73
+ //# sourceMappingURL=question-shape.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"question-shape.d.ts","sourceRoot":"","sources":["../src/question-shape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,WAAW,aAAa;IAC5B,kFAAkF;IAClF,IAAI,EAAE,WAAW,GAAG,YAAY,CAAC;IACjC,mDAAmD;IACnD,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,wDAAwD;IACxD,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAiBD,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,CAmC1E,CAAC;AA+CF;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAuB5D;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAc1D;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAS9D;AAYD;;;;;;;GAOG;AACH,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAyBrE;AAID;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,CA0BhE"}
@@ -0,0 +1,230 @@
1
+ /**
2
+ * question-shape.ts — Heuristic v1 multi-hop question shape detector
3
+ *
4
+ * Sprint A of the multi-hop closure plan: deterministic, no model call.
5
+ * Classifies a query as 'multi-hop' when it appears to require bridging
6
+ * evidence across two or more distinct named entities or entity+facet pairs.
7
+ *
8
+ * Detection logic (all deterministic):
9
+ * - Extract named entities (TitleCase spans, quoted strings, capitalized 2+ tokens)
10
+ * - Extract facet terms from the LoCoMo answer-bearing noun lexicon
11
+ * - Multi-hop if: (2+ entities) OR (1 entity + 1 facet), PLUS a relation word
12
+ *
13
+ * False-positive gate: temporal-anchor / single-hop-span queries are NOT
14
+ * multi-hop even when they have multiple entity tokens. A FP-rate check
15
+ * hook is available via `questionShapeFalsePositiveScore`.
16
+ *
17
+ * Exported symbols:
18
+ * detectQuestionShape(query) → QuestionShape
19
+ * questionShapeFalsePositiveScore(query) → number (0 = likely real, 1 = likely FP)
20
+ */
21
+ // ── Relation lexicon ──────────────────────────────────────────────────────
22
+ // Spec: "relation/intersection word" — signals that the question asks about
23
+ // a shared attribute, comparison, or intersection across entities.
24
+ const RELATION_WORDS = new Set([
25
+ 'common', 'share', 'shared', 'both', 'same', 'between',
26
+ 'bought', 'lost', 'planned', 'pursued', 'interested',
27
+ 'also', 'together', 'neither', 'either',
28
+ 'compare', 'comparing', 'overlap', 'overlapping', 'link', 'connect',
29
+ 'relationship', 'relation', 'difference', 'similar', 'similarity',
30
+ ]);
31
+ // ── Facet lexicon ─────────────────────────────────────────────────────────
32
+ // LoCoMo answer-bearing nouns. Kept minimal for Sprint A; promoted in Sprint B.
33
+ export const QUESTION_SHAPE_FACETS = [
34
+ {
35
+ name: 'job',
36
+ terms: ['job', 'jobs', 'work', 'career', 'occupation', 'profession', 'employment', 'fired', 'hired', 'promotion'],
37
+ },
38
+ {
39
+ name: 'death',
40
+ terms: ['death', 'died', 'passed away', 'funeral', 'loss', 'deceased', 'passing'],
41
+ },
42
+ {
43
+ name: 'hobby',
44
+ terms: ['hobby', 'hobbies', 'activity', 'activities', 'interest', 'interests', 'passion', 'pastime', 'free time'],
45
+ },
46
+ {
47
+ name: 'purchase',
48
+ terms: ['bought', 'purchase', 'purchased', 'buy', 'buying', 'item', 'items', 'shopping', 'order', 'ordered'],
49
+ },
50
+ {
51
+ name: 'venue',
52
+ terms: ['place', 'places', 'venue', 'venues', 'location', 'where', 'meet', 'met', 'visited', 'restaurant', 'bar', 'club'],
53
+ },
54
+ {
55
+ name: 'activity',
56
+ terms: ['planned', 'planning', 'event', 'events', 'trip', 'trips', 'vacation', 'travel'],
57
+ },
58
+ {
59
+ name: 'time',
60
+ terms: ['month', 'months', 'year', 'years', 'january', 'february', 'march', 'april', 'may', 'june',
61
+ 'july', 'august', 'september', 'october', 'november', 'december', 'spring', 'summer', 'fall', 'winter'],
62
+ },
63
+ {
64
+ name: 'relationship',
65
+ terms: ['friend', 'friends', 'partner', 'boyfriend', 'girlfriend', 'husband', 'wife', 'family',
66
+ 'sibling', 'brother', 'sister', 'parent', 'mother', 'father', 'colleague', 'coworker'],
67
+ },
68
+ ];
69
+ // Flat set for quick lookup
70
+ const FACET_TERM_SET = new Set(QUESTION_SHAPE_FACETS.flatMap(f => f.terms));
71
+ // ── Temporal-anchor / single-hop span patterns ────────────────────────────
72
+ // Used by the FP-score hook. A temporal question about one entity's history
73
+ // looks multi-hop by entity count but should not trigger structured handoff.
74
+ const TEMPORAL_SINGLE_HOP_PATTERNS = [
75
+ /\bwhen (did|was|were|is|are)\b/i,
76
+ /^what (year|month|date) did\b/i,
77
+ /\b(first|last)\s+(time|year|month|day|week)\b/i,
78
+ /\bhow long (ago|since|has|have)\b/i,
79
+ /\b(how many|what number|count of)\b/i,
80
+ /\b(date|dates|year|years)\s+(of|for|when|that)\b/i,
81
+ ];
82
+ // Strong single-hop signals — query is about a single subject's attribute
83
+ const SINGLE_HOP_SUBJECT_PATTERNS = [
84
+ /^(what|who|where|which|when|how) (is|was|are|were|did|does|do) [A-Z][a-z]+/,
85
+ /^(tell me|describe|explain|summarize)/i,
86
+ /\b(his|her|their|its) (name|job|career|hobby|hobbies|death|purchase|friend|partner)\b/i,
87
+ ];
88
+ // ── Entity extraction ─────────────────────────────────────────────────────
89
+ /** TitleCase word pattern (starts with capital, >= 2 chars) */
90
+ const TITLE_CASE_WORD = /\b[A-Z][a-z][a-zA-Z]*\b/g;
91
+ /** Quoted string pattern */
92
+ const QUOTED_STRING = /["']([^"']{2,30})["']/g;
93
+ /** ALL-CAPS abbreviation (e.g. VR, NBA, UCSF) */
94
+ const ALLCAPS_ABBREV = /\b[A-Z]{2,6}\b/g;
95
+ const COMMON_TITLE_CASE_STOP_WORDS = new Set([
96
+ 'The', 'A', 'An', 'In', 'On', 'At', 'To', 'For', 'Of', 'And', 'Or', 'But',
97
+ 'By', 'Is', 'It', 'If', 'So', 'Do', 'Be', 'My', 'We', 'He', 'She', 'They',
98
+ 'You', 'Me', 'Us', 'His', 'Her', 'Its', 'Our', 'Your', 'Who', 'What', 'How',
99
+ 'When', 'Where', 'Which', 'Why', 'Would', 'Could', 'Should', 'Did', 'Does',
100
+ 'Was', 'Were', 'Has', 'Have', 'Had', 'Will', 'Can', 'May', 'Might', 'Shall',
101
+ 'Just', 'Also', 'Both', 'Each', 'With', 'From', 'That', 'This', 'These', 'Those',
102
+ 'Any', 'All', 'Not', 'Now', 'Well', 'Too', 'Very', 'More', 'Most', 'Some',
103
+ 'Same', 'Last', 'Next', 'New', 'Old', 'Then', 'Than', 'Into', 'Upon',
104
+ ]);
105
+ /**
106
+ * Extract named entity candidates from a query string.
107
+ * Returns deduplicated lowercase entity tokens.
108
+ */
109
+ export function extractQueryEntities(query) {
110
+ const candidates = new Set();
111
+ // Quoted strings first (highest confidence)
112
+ for (const m of query.matchAll(QUOTED_STRING)) {
113
+ const val = m[1].trim();
114
+ if (val.length >= 2)
115
+ candidates.add(val.toLowerCase());
116
+ }
117
+ // TitleCase words (excluding common stop words)
118
+ for (const m of query.matchAll(TITLE_CASE_WORD)) {
119
+ const word = m[0];
120
+ if (!COMMON_TITLE_CASE_STOP_WORDS.has(word)) {
121
+ candidates.add(word.toLowerCase());
122
+ }
123
+ }
124
+ // ALL-CAPS abbreviations
125
+ for (const m of query.matchAll(ALLCAPS_ABBREV)) {
126
+ candidates.add(m[0].toLowerCase());
127
+ }
128
+ return [...candidates];
129
+ }
130
+ /**
131
+ * Extract facet terms from a query string using the LoCoMo facet lexicon.
132
+ * Returns matched facet group names (deduplicated).
133
+ */
134
+ export function extractQueryFacets(query) {
135
+ const lower = query.toLowerCase();
136
+ const matchedFacets = new Set();
137
+ for (const facet of QUESTION_SHAPE_FACETS) {
138
+ for (const term of facet.terms) {
139
+ if (lower.includes(term)) {
140
+ matchedFacets.add(facet.name);
141
+ break;
142
+ }
143
+ }
144
+ }
145
+ return [...matchedFacets];
146
+ }
147
+ /**
148
+ * Extract matched facet terms (raw tokens, not group names) from a query.
149
+ * Used for structured handoff header annotation.
150
+ */
151
+ export function extractQueryFacetTerms(query) {
152
+ const lower = query.toLowerCase();
153
+ const matched = [];
154
+ for (const term of FACET_TERM_SET) {
155
+ if (lower.includes(term))
156
+ matched.push(term);
157
+ }
158
+ return [...new Set(matched)];
159
+ }
160
+ // ── Relation word detection ───────────────────────────────────────────────
161
+ function hasRelationWord(query) {
162
+ const lower = query.toLowerCase();
163
+ const words = lower.split(/\s+/);
164
+ return words.some(w => RELATION_WORDS.has(w.replace(/[^a-z]/g, '')));
165
+ }
166
+ // ── False-positive scoring ────────────────────────────────────────────────
167
+ /**
168
+ * Estimate the probability that a 'multi-hop' classification is a false positive.
169
+ * Returns 0.0 (clearly multi-hop) to 1.0 (clearly single-hop / FP).
170
+ *
171
+ * High FP score → do not apply structured handoff even if multi-hop shape detected.
172
+ * Spec threshold: FP rate > 0.30 on held-out single-hop-multi-entity set →
173
+ * add a negative check. This function provides that check.
174
+ */
175
+ export function questionShapeFalsePositiveScore(query) {
176
+ let fpScore = 0;
177
+ for (const pattern of TEMPORAL_SINGLE_HOP_PATTERNS) {
178
+ if (pattern.test(query)) {
179
+ fpScore += 0.35;
180
+ break;
181
+ }
182
+ }
183
+ for (const pattern of SINGLE_HOP_SUBJECT_PATTERNS) {
184
+ if (pattern.test(query)) {
185
+ fpScore += 0.25;
186
+ break;
187
+ }
188
+ }
189
+ // Short queries with < 7 words are unlikely to be true multi-hop
190
+ const wordCount = query.trim().split(/\s+/).length;
191
+ if (wordCount < 7)
192
+ fpScore += 0.15;
193
+ // If query has no relation word at all, reduce confidence
194
+ if (!hasRelationWord(query))
195
+ fpScore += 0.30;
196
+ return Math.min(1.0, fpScore);
197
+ }
198
+ // ── Main detector ─────────────────────────────────────────────────────────
199
+ /**
200
+ * Detect whether a query has multi-hop shape.
201
+ *
202
+ * Multi-hop criteria (all must be true):
203
+ * 1. 2+ named entities OR (1 entity + 1 facet group)
204
+ * 2. At least one relation/intersection word
205
+ * 3. False-positive score < 0.60
206
+ *
207
+ * Returns a QuestionShape with extracted entities, facets, and confidence.
208
+ */
209
+ export function detectQuestionShape(query) {
210
+ if (!query || !query.trim()) {
211
+ return { kind: 'single-hop', entities: [], facets: [], confidence: 0.9 };
212
+ }
213
+ const entities = extractQueryEntities(query);
214
+ const facets = extractQueryFacets(query);
215
+ const fpScore = questionShapeFalsePositiveScore(query);
216
+ const hasEnoughSignals = entities.length >= 2 ||
217
+ (entities.length >= 1 && facets.length >= 1);
218
+ const hasRelation = hasRelationWord(query);
219
+ const isSafe = fpScore < 0.35;
220
+ if (hasEnoughSignals && hasRelation && isSafe) {
221
+ // Confidence: scale down by FP score
222
+ const baseConfidence = Math.min(1.0, 0.5 + (entities.length * 0.15) + (facets.length * 0.10));
223
+ const confidence = Math.max(0.1, baseConfidence * (1 - fpScore));
224
+ return { kind: 'multi-hop', entities, facets, confidence };
225
+ }
226
+ // Single-hop: confidence is inverse of multi-hop signals
227
+ const singleHopConfidence = Math.min(1.0, 0.5 + fpScore * 0.5);
228
+ return { kind: 'single-hop', entities, facets, confidence: singleHopConfidence };
229
+ }
230
+ //# sourceMappingURL=question-shape.js.map