neurain 0.1.0-alpha.4 → 0.1.0-alpha.6

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/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  - No unreleased changes recorded.
6
6
 
7
+ ## 0.1.0-alpha.6
8
+
9
+ - Performance (hybrid recall): `hybrid-search` now walks the markdown corpus ONCE and shares it across its semantic and routed-lexical branches instead of each branch re-walking and re-reading the whole vault. The walk is shared only when no `--area` is set (the two branches then select the same whole-vault corpus); with an area they still walk independently. Results stay byte-identical (golden-verified) because the shared file list is exactly what each branch would have walked. Measured: `recall hybrid-search` ~970ms -> ~763ms (warm median); combined with alpha.5 that is ~1234ms -> ~763ms (-38%). npm test 153/153.
10
+
11
+
12
+ ## 0.1.0-alpha.5
13
+
14
+ - Performance (recall processing): cut recall/search processing time without changing results. The semantic scorer now prepares the query once and precomputes per-doc trigrams (instead of re-tokenizing the query and rebuilding `charTrigrams` per document), and the lexical BM25 counts term frequency with an index loop instead of `String.split`. Measured: `recall hybrid-search` ~1234ms -> ~970ms, `semantic-search` ~1031ms -> ~750ms (warm median), with byte-identical ranking/scores/matched_terms (golden-verified) and npm test 153/153.
15
+
16
+
7
17
  ## 0.1.0-alpha.4
8
18
 
9
19
  - Performance: lazy dynamic-import CLI dispatch. Each command now imports only its own `core/*.mjs` module on demand instead of loading all ~54 command modules on every invocation. Engine subprocess latency drops ~60-75ms across the board (`tidy` 150->90ms, `structure-audit` 120->60ms, `--help`/`--version` 50ms), with no change to the command surface or behavior (npm test 153/153; reviewed).
package/README.md CHANGED
@@ -204,7 +204,7 @@ It exposes read/capture/scan/preview tools only. It does not silently compile, p
204
204
 
205
205
  ## Status
206
206
 
207
- This is `0.1.0-alpha.4`. It is not a public SaaS GA release. The alpha exists to prove installability, local-first onboarding, Codex, Claude, Gemini, and Runtime connectivity, plus safety receipts.
207
+ This is `0.1.0-alpha.6`. It is not a public SaaS GA release. The alpha exists to prove installability, local-first onboarding, Codex, Claude, Gemini, and Runtime connectivity, plus safety receipts.
208
208
 
209
209
  Alpha publish command:
210
210
 
@@ -1,9 +1,9 @@
1
1
  # Development Status
2
2
 
3
3
  Version: v0.1
4
- Last updated: 2026-06-19 KST
5
- Package: `neurain@0.1.0-alpha.4`
6
- Latest documented commit: `53aba29 perf(cli): lazy dynamic-import dispatch (load only the dispatched command)`
4
+ Last updated: 2026-06-20 KST
5
+ Package: `neurain@0.1.0-alpha.6`
6
+ Latest documented commit: `908d51d perf(recall): share one corpus walk across hybrid branches, byte-identical`
7
7
 
8
8
  This document is the canonical product development snapshot for the public package. It tracks what is shipped, what has evidence, and what must not be claimed yet.
9
9
 
@@ -1,9 +1,9 @@
1
1
  # 개발 진행 상태
2
2
 
3
3
  Version: v0.1
4
- Last updated: 2026-06-19 KST
5
- Package: `neurain@0.1.0-alpha.4`
6
- Latest documented commit: `53aba29 perf(cli): lazy dynamic-import dispatch (load only the dispatched command)`
4
+ Last updated: 2026-06-20 KST
5
+ Package: `neurain@0.1.0-alpha.6`
6
+ Latest documented commit: `908d51d perf(recall): share one corpus walk across hybrid branches, byte-identical`
7
7
 
8
8
  이 문서는 public package 기준의 canonical 개발 상태 스냅샷입니다. 무엇이 shipped인지, 어떤 증거가 있는지, 아직 주장하면 안 되는 것이 무엇인지 함께 기록합니다.
9
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neurain",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.6",
4
4
  "description": "Local-first Neurain Knowledge OS CLI and MCP connector.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -183,7 +183,7 @@ export async function searchRecall(root, query, { top = 10, host = '', fallback
183
183
  // corpus. No SQLite required (markdown stays canonical, the default provider
184
184
  // needs no generated index), no model calls, no external calls. Private and
185
185
  // unsafe docs are excluded exactly like the exact-token path.
186
- export async function semanticSearchRecall(root, query, { top = 10, host = '', provider = 'local-lexical', minScore = 0.34, scope = '' } = {}) {
186
+ export async function semanticSearchRecall(root, query, { top = 10, host = '', provider = 'local-lexical', minScore = 0.34, scope = '', markdownFiles } = {}) {
187
187
  const prov = getProvider(provider);
188
188
  const text = String(query || '');
189
189
  if (!text.trim()) throw new Error('Recall semantic search requires a query.');
@@ -191,13 +191,19 @@ export async function semanticSearchRecall(root, query, { top = 10, host = '', p
191
191
  const hostFilter = String(host || '');
192
192
  const scopeFilter = String(scope || '');
193
193
  const floor = Number.isFinite(Number(minScore)) ? Math.max(0, Math.min(Number(minScore), 1)) : 0.34;
194
- const docs = collectRecallDocs(root)
194
+ const docs = collectRecallDocs(root, { markdownFiles })
195
195
  .filter((doc) => doc.sensitivity !== 'private')
196
196
  .filter((doc) => !hostFilter || doc.host === hostFilter)
197
197
  .filter((doc) => !scopeFilter || doc.scope === scopeFilter);
198
+ // Prepare the query ONCE, then score every doc against it (avoids re-tokenizing the
199
+ // query per document). Falls back to provider.score for providers without the fast path.
200
+ const preparedQuery = prov.prepareQuery ? prov.prepareQuery(text) : null;
198
201
  const scored = docs
199
202
  .map((doc) => {
200
- const scoredDoc = prov.score(text, `${doc.title} ${doc.body}`);
203
+ const docText = `${doc.title} ${doc.body}`;
204
+ const scoredDoc = (preparedQuery && prov.scorePrepared)
205
+ ? prov.scorePrepared(preparedQuery, docText)
206
+ : prov.score(text, docText);
201
207
  return { doc, score: Number(scoredDoc.score || 0), matched_terms: scoredDoc.matched_terms || [] };
202
208
  })
203
209
  .filter((item) => item.score >= floor)
@@ -282,7 +288,14 @@ export async function hybridSearchRecall(root, query, { top = 10, host = '', pro
282
288
  const scope = scopeForArea(areaDir);
283
289
  const routedEnabled = decideRouting(routing, areaDir, root, recallCfg);
284
290
  const exact = await searchRecall(root, text, { top: limit, host, scope });
285
- const semantic = await semanticSearchRecall(root, text, { top: limit, host, provider, minScore, scope });
291
+ // Walk the markdown corpus ONCE and share it across the semantic and (routed)
292
+ // lexical branches, which otherwise each re-walk+read the whole vault. Only when
293
+ // no area is set, because then both branches select the same whole-vault corpus;
294
+ // with an area, semantic stays whole-vault while lexical scopes to the area, so
295
+ // their selections differ and each must walk its own. The shared array is exactly
296
+ // what each branch would have walked, so results stay byte-identical.
297
+ const sharedFiles = areaDir ? null : listRecallMarkdownFiles(root, recallCfg);
298
+ const semantic = await semanticSearchRecall(root, text, { top: limit, host, provider, minScore, scope, markdownFiles: sharedFiles });
286
299
 
287
300
  if (!routedEnabled) {
288
301
  const merged = mergeHybridResults(exact.results, semantic.results);
@@ -310,7 +323,7 @@ export async function hybridSearchRecall(root, query, { top = 10, host = '', pro
310
323
  };
311
324
  }
312
325
 
313
- const lexicalCtx = buildLexicalContext(root, { area: areaDir, recallCfg });
326
+ const lexicalCtx = buildLexicalContext(root, { area: areaDir, recallCfg, markdownFiles: sharedFiles });
314
327
  const lexical = lexicalSearchWithContext(lexicalCtx, text, { top: limit });
315
328
  const merged = mergeRoutedHybridResults(lexical.results, exact.results, semantic.results);
316
329
  return {
@@ -1597,9 +1610,9 @@ function buildSqliteIndex(DatabaseSync, file, docs, manifestHash) {
1597
1610
  }
1598
1611
  }
1599
1612
 
1600
- function collectRecallDocs(root, { recallCfg = recallConfig(root) } = {}) {
1613
+ function collectRecallDocs(root, { recallCfg = recallConfig(root), markdownFiles } = {}) {
1601
1614
  const docs = [
1602
- ...collectMarkdownDocs(root, recallCfg),
1615
+ ...collectMarkdownDocs(root, recallCfg, markdownFiles),
1603
1616
  ...collectEventDocs(root),
1604
1617
  ...collectReceiptDocs(root),
1605
1618
  ];
@@ -1614,8 +1627,12 @@ function collectRecallDocs(root, { recallCfg = recallConfig(root) } = {}) {
1614
1627
  // label resolver (per-file frontmatter + area baseline + boundary path markers),
1615
1628
  // which fixes the old substring gate that dropped `..._tokenomics/` because the
1616
1629
  // path contained `token`. config.recall.include/exclude extend the whitelist.
1617
- function collectMarkdownDocs(root, recallCfg = recallConfig(root)) {
1618
- return listRecallMarkdownFiles(root, recallCfg).map(({ rel, text, sensitivity }) => docFromText({
1630
+ // `markdownFiles`, when given, is a pre-walked listRecallMarkdownFiles() result
1631
+ // for the SAME (root, recallCfg, whole-vault) selection, so a caller that already
1632
+ // walked the corpus (e.g. hybrid sharing one walk across branches) can skip the
1633
+ // redundant walk+read. The mapping is identical, so the docs are byte-identical.
1634
+ function collectMarkdownDocs(root, recallCfg = recallConfig(root), markdownFiles) {
1635
+ return (markdownFiles || listRecallMarkdownFiles(root, recallCfg)).map(({ rel, text, sensitivity }) => docFromText({
1619
1636
  path: rel,
1620
1637
  kind: kindForPath(rel),
1621
1638
  host: 'markdown',
@@ -111,11 +111,14 @@ function slugish(value) {
111
111
  // intel/facts/alias snapshots + the held-aside queue doc), reused across many
112
112
  // queries. intel/facts/aliasMap can be injected (tests); otherwise loaded from
113
113
  // the registry, degrading to empty when files are absent.
114
- export function buildLexicalContext(root, { area = '', recallCfg, intel, facts, aliasMap } = {}) {
114
+ export function buildLexicalContext(root, { area = '', recallCfg, intel, facts, aliasMap, markdownFiles } = {}) {
115
115
  if (!recallCfg) throw new Error('buildLexicalContext requires recallCfg');
116
116
  const dirs = dirsFromConfig(recallCfg);
117
117
  const classify = makeLayerClassifier(dirs);
118
- const files = listRecallMarkdownFiles(root, recallCfg, { area });
118
+ // `markdownFiles`, when given, must be a pre-walked listRecallMarkdownFiles()
119
+ // result for this exact (root, recallCfg, area) selection; a caller that already
120
+ // walked the corpus (hybrid sharing one walk) passes it to skip the redundant walk.
121
+ const files = markdownFiles || listRecallMarkdownFiles(root, recallCfg, { area });
119
122
  const baseDocs = files.map(({ rel, text }) => ({
120
123
  text,
121
124
  lower: text.toLowerCase(),
@@ -192,7 +195,10 @@ export function lexicalSearchWithContext(ctx, query, { top = 10, maxPerLayer = 3
192
195
 
193
196
  let bm25 = 0;
194
197
  for (const term of searchTerms) {
195
- const tf = lower.split(term).length - 1;
198
+ // Non-overlapping occurrence count (identical to `lower.split(term).length - 1`)
199
+ // without allocating the split array on every doc/term pair.
200
+ let tf = 0;
201
+ for (let i = lower.indexOf(term); i !== -1; i = lower.indexOf(term, i + term.length)) tf += 1;
196
202
  if (tf === 0) continue;
197
203
  const denom = tf + BM25_K1 * (1 - BM25_B + (BM25_B * length) / avgLength);
198
204
  bm25 += (idf[term] || 0) * ((tf * (BM25_K1 + 1)) / denom);
@@ -117,9 +117,13 @@ function charTrigrams(token) {
117
117
  export function fuzzyOverlap(a, b) {
118
118
  if (!a || !b) return 0;
119
119
  if (a === b) return 1;
120
- const ga = charTrigrams(a);
121
- const gb = charTrigrams(b);
122
- if (!ga.size || !gb.size) return 0;
120
+ return trigramJaccard(charTrigrams(a), charTrigrams(b));
121
+ }
122
+
123
+ // Jaccard over two PRE-COMPUTED trigram sets (same math as fuzzyOverlap's tail),
124
+ // so the per-doc fuzzy loop can reuse cached trigrams instead of rebuilding them.
125
+ function trigramJaccard(ga, gb) {
126
+ if (!ga || !gb || !ga.size || !gb.size) return 0;
123
127
  let inter = 0;
124
128
  for (const g of ga) if (gb.has(g)) inter += 1;
125
129
  return inter / (ga.size + gb.size - inter);
@@ -127,13 +131,25 @@ export function fuzzyOverlap(a, b) {
127
131
 
128
132
  // Deterministic lexical-semantic score of a query against a document body.
129
133
  // Returns { score: 0..1 normalized by query length, matched_terms: [...] }.
130
- export function lexicalSemanticScore(query, docText) {
131
- const queryExpanded = tokenize(query).map(expandToken);
134
+ // Prepare a query ONCE (tokenize + expand) so a corpus scan can reuse it across all
135
+ // docs instead of re-tokenizing the query per document (the per-doc hot path).
136
+ export function prepareSemanticQuery(query) {
137
+ // Precompute each term's trigrams ONCE so the per-doc fuzzy loop never rebuilds them.
138
+ return tokenize(query).map(expandToken).map((q) => ({ ...q, trigrams: charTrigrams(q.stem) }));
139
+ }
140
+
141
+ // Score a pre-prepared query against a document body. Behaviour is identical to
142
+ // lexicalSemanticScore; only the query preparation is hoisted out.
143
+ export function scorePreparedSemantic(queryExpanded, docText) {
132
144
  if (!queryExpanded.length) return { score: 0, matched_terms: [] };
133
145
  const docTokens = tokenize(docText).map(expandToken);
134
146
  if (!docTokens.length) return { score: 0, matched_terms: [] };
135
147
  const docStems = new Set(docTokens.map((d) => d.stem));
136
148
  const docCanons = new Set(docTokens.map((d) => d.canon).filter(Boolean));
149
+ // Build each unique doc-stem's trigrams ONCE per doc (was recomputed per query term
150
+ // inside fuzzyOverlap -> the charTrigrams hot path).
151
+ const docStemTrigrams = new Map();
152
+ for (const s of docStems) docStemTrigrams.set(s, charTrigrams(s));
137
153
  const matched = [];
138
154
  let total = 0;
139
155
  for (const q of queryExpanded) {
@@ -142,9 +158,11 @@ export function lexicalSemanticScore(query, docText) {
142
158
  if (docStems.has(q.stem)) { best = 1; how = 'exact'; }
143
159
  else if (q.canon && docCanons.has(q.canon)) { best = 0.75; how = 'synonym'; }
144
160
  else {
145
- // fuzzy: best trigram overlap against any doc stem (typos / variants)
161
+ // fuzzy: best trigram overlap against any doc stem (typos / variants), using
162
+ // the precomputed query + doc-stem trigrams instead of rebuilding them.
163
+ const qTri = q.trigrams || charTrigrams(q.stem);
146
164
  for (const d of docStems) {
147
- const ov = fuzzyOverlap(q.stem, d);
165
+ const ov = trigramJaccard(qTri, docStemTrigrams.get(d));
148
166
  if (ov > best) { best = ov; how = 'fuzzy'; }
149
167
  }
150
168
  if (best < 0.6) best = 0;
@@ -155,6 +173,10 @@ export function lexicalSemanticScore(query, docText) {
155
173
  return { score: Number((total / queryExpanded.length).toFixed(4)), matched_terms: matched };
156
174
  }
157
175
 
176
+ export function lexicalSemanticScore(query, docText) {
177
+ return scorePreparedSemantic(prepareSemanticQuery(query), docText);
178
+ }
179
+
158
180
  const PROVIDERS = new Map();
159
181
 
160
182
  export function registerProvider(name, impl) {
@@ -181,6 +203,12 @@ registerProvider('local-lexical', {
181
203
  expandQuery(query) {
182
204
  return tokenize(query).map(expandToken);
183
205
  },
206
+ prepareQuery(query) {
207
+ return prepareSemanticQuery(query);
208
+ },
209
+ scorePrepared(prepared, docText) {
210
+ return scorePreparedSemantic(prepared, docText);
211
+ },
184
212
  score(query, docText) {
185
213
  return lexicalSemanticScore(query, docText);
186
214
  },