@sdsrs/llm-wiki 0.9.1 → 0.9.2

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
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.2 (2026-07-12)
4
+
5
+ Robustness and UX fixes from an end-to-end usage audit; no change to core retrieval or
6
+ convert behavior. Suite 271 → 279.
7
+
8
+ **Fixed:**
9
+
10
+ - **Concurrent writes to a derived file no longer crash.** `writeFileAtomic` used a fixed
11
+ temp name, so two writers to the same file (concurrent `index`, or the MCP server and a
12
+ CLI command touching the same derived file) collided — the second renamed a temp the
13
+ first had already consumed and died with ENOENT. Each write now uses a unique temp; the
14
+ rename onto the target stays atomic, so readers still never see a torn write.
15
+ - **`graph` accepts `--kb` on the parent command** (`graph --kb ./kb hubs`), like every
16
+ other command — it previously errored "unknown option --kb" unless `--kb` came after the
17
+ subcommand.
18
+ - **`ask` rejects an empty/whitespace question** instead of running a no-op search.
19
+ - **`ask --retrieve-only` tells an empty wiki apart from a no-match query** — it no longer
20
+ tells a user with a populated KB to go build pages they already have.
21
+ - **`connect` reports a clear error for a missing/non-directory project path** instead of
22
+ leaking a raw ENOENT for an internal file.
23
+ - **`scan` no longer reports a phantom "+1 to convert" for an exact-duplicate file** — the
24
+ incremental count now agrees with the compile plan (duplicates are reported separately).
25
+ - **Clearer frontmatter error**: a page that opens `---` but never closes it is reported as
26
+ `unterminated-frontmatter`, not `missing-frontmatter`.
27
+ - **Clearer `embed` error** when no embedding model is configured (names the field to set,
28
+ for both remote and local).
29
+
30
+ **Added (lint):**
31
+
32
+ - `type-dir-mismatch` — flags a page whose `type` disagrees with its directory (e.g.
33
+ `type: source` in `concepts/`), which the indexer would otherwise silently miscategorize.
34
+ - `self-supersede` — flags a page whose `superseded_by` points at itself.
35
+
36
+ **Changed (internal):**
37
+
38
+ - `loadEmbedConfig` reads the global config once per call and resolves a provider-level
39
+ embedding model in priority order, consistent with chat provider selection.
40
+ - Local embedding no longer builds a network transport (no undici import / proxy agent),
41
+ since it makes no network calls.
42
+
3
43
  ## 0.9.1 (2026-07-12)
4
44
 
5
45
  Correctness and diagnostics for the 0.9.0 local-embedding path; no change to the
package/bin/llm-wiki.mjs CHANGED
@@ -89,10 +89,16 @@ program.command('ask <question>')
89
89
  .action(async (question, opts) => {
90
90
  const k = Number.parseInt(opts.k, 10)
91
91
  if (!Number.isFinite(k) || k < 1) { console.error(`invalid -k value: ${opts.k} (expected a positive integer)`); process.exit(1) }
92
+ if (!question.trim()) { console.error('empty question — provide something to ask'); process.exit(1) }
92
93
  const r = await askKb(opts.kb, question, { k, retrieveOnly: opts.retrieveOnly })
93
94
  if (opts.retrieveOnly) {
94
95
  if (r.pages.length === 0) {
95
- console.error('no pages located the KB has no matching wiki pages (build them with the wiki-build skill, or check that wiki/ contains pages)')
96
+ // Distinguish "wiki is empty" (build pages) from "wiki has pages but none
97
+ // matched" (rephrase) — the old fixed message told users with a full KB to go
98
+ // build pages they already have.
99
+ console.error(r.kbEmpty
100
+ ? 'no pages located — the wiki has no pages yet (build them with the wiki-build skill, or check that wiki/ contains pages)'
101
+ : 'no pages located — no wiki page matched your query (the wiki has pages; try different or fewer search terms)')
96
102
  return
97
103
  }
98
104
  for (const h of r.pages) console.log(`${h.score.toFixed(3)} ${h.relPath} [${(h.sources ?? ['bm25']).join('+')}]`)
@@ -164,12 +170,17 @@ program.command('export')
164
170
  })
165
171
 
166
172
  const graphCmd = program.command('graph').description('query wiki/graph.json: path | neighbors | hubs (zero-LLM traversal)')
173
+ // Accept --kb on the parent too, so `graph --kb ./kb hubs` works like every other
174
+ // command (`ask --kb`, `lint --kb`). Subcommands keep their own --kb (no default) and
175
+ // fall back to the parent's, so both placements resolve and neither silently wins '.'.
176
+ .option('--kb <dir>', 'knowledge base root', '.')
177
+ const graphKb = (opts) => opts.kb ?? graphCmd.opts().kb
167
178
 
168
179
  graphCmd.command('path <from> <to>')
169
180
  .description('shortest link chain between two page ids (either link direction)')
170
- .option('--kb <dir>', 'knowledge base root', '.')
181
+ .option('--kb <dir>', 'knowledge base root')
171
182
  .action((from, to, opts) => {
172
- const r = shortestPath(loadGraph(opts.kb), from, to)
183
+ const r = shortestPath(loadGraph(graphKb(opts)), from, to)
173
184
  if (!r) { console.log(`no path between ${from} and ${to}`); return }
174
185
  console.log(r.nodes[0])
175
186
  for (const h of r.hops) console.log(` ${h.dir === 'out' ? `-[${h.type}]->` : `<-[${h.type}]-`} ${h.to}${h.confidence ? ` (${h.confidence})` : ''}${h.status === 'invalidated' ? ' ⚠ invalidated' : ''}`)
@@ -178,11 +189,11 @@ graphCmd.command('path <from> <to>')
178
189
  graphCmd.command('neighbors <id>')
179
190
  .description('pages within N hops (links counted in both directions)')
180
191
  .option('-d, --depth <n>', 'expansion depth', '1')
181
- .option('--kb <dir>', 'knowledge base root', '.')
192
+ .option('--kb <dir>', 'knowledge base root')
182
193
  .action((id, opts) => {
183
194
  const depth = Number.parseInt(opts.depth, 10)
184
195
  if (!Number.isFinite(depth) || depth < 1) { console.error(`invalid depth: ${opts.depth} (expected a positive integer)`); process.exit(1) }
185
- const r = neighborhood(loadGraph(opts.kb), id, depth)
196
+ const r = neighborhood(loadGraph(graphKb(opts)), id, depth)
186
197
  if (!r.length) { console.log(`${id} has no linked neighbors`); return }
187
198
  for (const n of r) console.log(`d=${n.distance} ${n.id} [${n.type}${n.confidence ? '/' + n.confidence : ''} ${n.dir}]${n.status === 'invalidated' ? ' ⚠ invalidated' : ''}`)
188
199
  })
@@ -190,11 +201,11 @@ graphCmd.command('neighbors <id>')
190
201
  graphCmd.command('hubs')
191
202
  .description('most-connected pages (degree ranking; raw files excluded)')
192
203
  .option('--top <n>', 'how many to show', '10')
193
- .option('--kb <dir>', 'knowledge base root', '.')
204
+ .option('--kb <dir>', 'knowledge base root')
194
205
  .action((opts) => {
195
206
  const top = Number.parseInt(opts.top, 10)
196
207
  if (!Number.isFinite(top) || top < 1) { console.error(`invalid --top: ${opts.top} (expected a positive integer)`); process.exit(1) }
197
- for (const h of hubs(loadGraph(opts.kb), { top })) {
208
+ for (const h of hubs(loadGraph(graphKb(opts)), { top })) {
198
209
  console.log(`${String(h.degree).padStart(3)} ${h.id} (in ${h.in} / out ${h.out}) ${h.title}${h.status === 'invalidated' ? ' ⚠ invalidated' : ''}`)
199
210
  }
200
211
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/llm-wiki",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Compile messy document directories into Karpathy-style llm_wiki knowledge bases — standalone Q&A CLI + Claude Code/Codex skills",
5
5
  "directories": {
6
6
  "test": "test"
package/src/ask.mjs CHANGED
@@ -8,6 +8,7 @@ import { worstCaseTokens } from './scanner.mjs'
8
8
  import { loadLlmConfig, loadEmbedConfig, makeTransport } from './llm-config.mjs'
9
9
  import { loadVectorStore, normalize, cosineTopK } from './vector.mjs'
10
10
  import { embedTexts } from './embed.mjs'
11
+ import { isLocalModel } from './local-embed.mjs'
11
12
  import { fetchWithRetry } from './retry.mjs'
12
13
 
13
14
  // Two per-KB caches, both keyed by a cheap page-set freshness token so neither can
@@ -149,7 +150,11 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
149
150
  const injected = fetchImpl || pipelineFactory
150
151
  const t = injected
151
152
  ? { fetchImpl, dispatcher: undefined, retry, pipelineFactory }
152
- : { ...(await makeTransport()), retry }
153
+ // A local query embedding makes no network call (embedLocal ignores the
154
+ // transport), so skip makeTransport — no undici import / proxy agent for nothing.
155
+ : isLocalModel(cfg.embeddingModel)
156
+ ? { retry }
157
+ : { ...(await makeTransport()), retry }
153
158
  const [qv] = await embedTexts(cfg, t, [question], { role: 'query' })
154
159
  const qn = normalize(qv)
155
160
  const vecHits = qn ? cosineTopK(qn, store, k).map(v => ({ relPath: v.id, score: v.score })) : []
@@ -232,7 +237,9 @@ async function pickPagesFromListing(p, question, k, cfg, t, validIds) {
232
237
  export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fetchImpl, retrieval = 'auto', retry, pipelineFactory } = {}) {
233
238
  const p = kbPaths(kbRoot)
234
239
  let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval, retry, pipelineFactory })
235
- if (retrieveOnly) return { pages: hits, answer: null }
240
+ // kbEmpty lets the CLI tell "no pages built yet" apart from "pages exist but none
241
+ // matched". Only pay the livePages listing when there were zero hits to explain.
242
+ if (retrieveOnly) return { pages: hits, answer: null, kbEmpty: hits.length === 0 && livePages(kbRoot).length === 0 }
236
243
  let validIds
237
244
  if (hits.length === 0) {
238
245
  validIds = new Set(livePages(kbRoot).map(pg => pg.relPath.replace(/\.md$/, '')))
package/src/connect.mjs CHANGED
@@ -15,6 +15,10 @@ function renderBlock(kbs) {
15
15
  }
16
16
 
17
17
  export function connectProject(projectDir, { kb, role = 'project', remove = false }) {
18
+ // Fail with a clear message instead of leaking a raw ENOENT for an internal file
19
+ // (.llm-wiki.json) when the user points connect at a missing/typo'd project path.
20
+ if (!fs.existsSync(projectDir)) throw new Error(`project directory not found: ${projectDir}`)
21
+ if (!fs.statSync(projectDir).isDirectory()) throw new Error(`not a directory: ${projectDir}`)
18
22
  const regFile = path.join(projectDir, '.llm-wiki.json')
19
23
  const regExists = fs.existsSync(regFile)
20
24
  const registry = regExists ? readJsonFile(regFile) : { kbs: [] }
package/src/embed.mjs CHANGED
@@ -120,7 +120,11 @@ export async function embedKb(kbRoot, { fetchImpl, retry, pipelineFactory } = {}
120
120
  const injected = fetchImpl || pipelineFactory
121
121
  const t = injected
122
122
  ? { fetchImpl, dispatcher: undefined, retry, pipelineFactory }
123
- : { ...(await makeTransport()), retry }
123
+ // A local model makes no network call (embedLocal ignores the transport), so
124
+ // skip makeTransport — it would import undici / build a proxy agent for nothing.
125
+ : local
126
+ ? { retry }
127
+ : { ...(await makeTransport()), retry }
124
128
  for (let i = 0; i < jobs.length; i += BATCH) {
125
129
  const batch = jobs.slice(i, i + BATCH)
126
130
  const vecs = await embedTexts(cfg, t, batch.map(j => j.text))
@@ -1,10 +1,17 @@
1
1
  import YAML from 'yaml'
2
2
 
3
3
  const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
4
+ const FM_OPEN_RE = /^---\r?\n/
4
5
 
5
6
  export function parseFrontmatter(text) {
6
7
  const m = text.match(FM_RE)
7
- if (!m) return null
8
+ if (!m) {
9
+ // Opens with a `---` delimiter but never closes it — an unterminated block, not
10
+ // an absent one. A forgotten closing `---` is a common authoring slip; report it
11
+ // distinctly so the fix is "close the frontmatter", not "add frontmatter".
12
+ if (FM_OPEN_RE.test(text)) return { error: 'unterminated-frontmatter' }
13
+ return null
14
+ }
8
15
  let data
9
16
  try { data = YAML.parse(m[1]) } catch { return { error: 'invalid-yaml' } }
10
17
  if (data === null || typeof data !== 'object') return { error: 'invalid-yaml' }
package/src/json.mjs CHANGED
@@ -1,4 +1,9 @@
1
1
  import fs from 'node:fs'
2
+ import { randomBytes } from 'node:crypto'
3
+
4
+ // Per-write temp counter: combined with the pid it keeps concurrent writers to the
5
+ // same target on distinct temp files even within one process (see writeFileAtomic).
6
+ let atomicSeq = 0
2
7
 
3
8
  // JSON.parse with the offending file named in the error — a bare SyntaxError
4
9
  // from a corrupt state file gives the user nothing to act on.
@@ -22,7 +27,19 @@ export function readJsonFile(file, { redactContents = false } = {}) {
22
27
  // only ever sees the whole old file or the whole new one. Single source of the pattern
23
28
  // that manifest/vector already used and buildIndex's derived stores previously missed.
24
29
  export function writeFileAtomic(file, data) {
25
- const tmp = `${file}.tmp`
26
- fs.writeFileSync(tmp, data)
27
- fs.renameSync(tmp, file)
30
+ // Unique temp name per write. A fixed `${file}.tmp` makes two concurrent writers to
31
+ // the same target share one temp file: the first rename consumes it, the second then
32
+ // renames a now-missing file and crashes with ENOENT (concurrent `index`, or MCP +
33
+ // CLI touching the same derived file). pid + seq + random keeps temps independent.
34
+ // The rename onto `file` stays atomic, so readers still never see a torn write.
35
+ const tmp = `${file}.${process.pid}.${(atomicSeq++).toString(36)}.${randomBytes(4).toString('hex')}.tmp`
36
+ try {
37
+ fs.writeFileSync(tmp, data)
38
+ fs.renameSync(tmp, file)
39
+ } catch (err) {
40
+ // Unique temps don't self-overwrite on the next run, so a failed write must clean
41
+ // up after itself or it leaks a stray .tmp beside the target.
42
+ try { fs.unlinkSync(tmp) } catch { /* best-effort */ }
43
+ throw err
44
+ }
28
45
  }
package/src/lint.mjs CHANGED
@@ -7,6 +7,13 @@ import { extractWikilinks, buildIndex } from './indexer.mjs'
7
7
  import { loadManifest } from './manifest.mjs'
8
8
  import { writeFileAtomic } from './json.mjs'
9
9
 
10
+ // The AGENTS.md contract binds each page type to its directory (source↔sources/, …).
11
+ // A page whose `type` disagrees with its directory is silently miscategorized: the
12
+ // indexer groups index.md sections by `type`, but ids / wikilinks / graph nodes use
13
+ // the directory — so e.g. a `type: source` page in concepts/ is filed under "Sources"
14
+ // yet linked as `concepts/…`.
15
+ const DIR_TYPE = { sources: 'source', entities: 'entity', concepts: 'concept', comparisons: 'comparison' }
16
+
10
17
  export async function lintKb(kbRoot, { fix = false } = {}) {
11
18
  const p = kbPaths(kbRoot)
12
19
  const cfg = loadKbConfig(kbRoot)
@@ -20,6 +27,12 @@ export async function lintKb(kbRoot, { fix = false } = {}) {
20
27
  for (const pg of pages) {
21
28
  if (pg.error) { mechanical.push({ rule: 'invalid-frontmatter', path: pg.relPath, detail: pg.error }); continue }
22
29
  for (const issue of validatePage(pg)) mechanical.push({ rule: 'missing-field', path: pg.relPath, detail: issue })
30
+ // type must match the directory (AGENTS.md contract) — a mismatch is miscategorized
31
+ // by the indexer. Skip when type is absent (already flagged as a missing field).
32
+ const dir = pg.relPath.split('/')[0]
33
+ if (DIR_TYPE[dir] && pg.data.type !== undefined && pg.data.type !== DIR_TYPE[dir]) {
34
+ mechanical.push({ rule: 'type-dir-mismatch', path: pg.relPath, detail: `type "${pg.data.type}" in ${dir}/ (expected "${DIR_TYPE[dir]}")` })
35
+ }
23
36
  for (const target of extractWikilinks(pg.body)) {
24
37
  if (target.startsWith('raw/')) {
25
38
  if (!fs.existsSync(path.join(kbRoot, target)) && !fs.existsSync(path.join(kbRoot, target + '.md'))) mechanical.push({ rule: 'broken-raw-link', path: pg.relPath, detail: `-> ${target}` })
@@ -34,8 +47,15 @@ export async function lintKb(kbRoot, { fix = false } = {}) {
34
47
  if (pg.data.status !== undefined && !PAGE_STATUSES.includes(pg.data.status)) {
35
48
  mechanical.push({ rule: 'invalid-status', path: pg.relPath, detail: `status "${pg.data.status}" (expected ${PAGE_STATUSES.join(' | ')})` })
36
49
  }
37
- if (pg.data.superseded_by !== undefined && !ids.has(String(pg.data.superseded_by))) {
38
- mechanical.push({ rule: 'superseded-target-missing', path: pg.relPath, detail: `superseded_by -> ${pg.data.superseded_by}` })
50
+ if (pg.data.superseded_by !== undefined) {
51
+ const supTarget = String(pg.data.superseded_by)
52
+ // A page cannot be its own replacement: superseded_by == self invalidates the page
53
+ // with no valid successor (checked before target-missing, since the self id exists).
54
+ if (supTarget === pg.relPath.replace(/\.md$/, '')) {
55
+ mechanical.push({ rule: 'self-supersede', path: pg.relPath, detail: 'superseded_by points at itself' })
56
+ } else if (!ids.has(supTarget)) {
57
+ mechanical.push({ rule: 'superseded-target-missing', path: pg.relPath, detail: `superseded_by -> ${pg.data.superseded_by}` })
58
+ }
39
59
  }
40
60
  if (pg.data.relations !== undefined && !Array.isArray(pg.data.relations)) {
41
61
  mechanical.push({ rule: 'invalid-relation-entry', path: pg.relPath, detail: 'relations must be a YAML list' })
@@ -23,12 +23,36 @@ function resolveProviders(fileCfg) {
23
23
  return null
24
24
  }
25
25
 
26
- export function loadLlmConfig(kbRoot) {
26
+ // The embeddingModel a credential-less (local) config would use: the flat top-level
27
+ // form wins, else the first provider IN PRIORITY ORDER that declares one — the same
28
+ // ordering resolveProviders uses, so the chat and embed paths can't disagree on which
29
+ // provider wins. Only file-configured providers are scanned (BUILTIN declares none).
30
+ function resolveEmbeddingModel(fileCfg) {
31
+ if (typeof fileCfg.embeddingModel === 'string') return fileCfg.embeddingModel
32
+ const { priority, providers } = fileCfg
33
+ if (!providers) return null
34
+ for (const name of priority ?? Object.keys(providers)) {
35
+ const em = providers[name]?.embeddingModel
36
+ if (em) return em
37
+ }
38
+ return null
39
+ }
40
+
41
+ // Read ~/.llm-wiki/config.json once per entry point. redactContents: this file holds
42
+ // API keys — a corrupt-JSON error must not echo a fragment of it to the terminal.
43
+ function readGlobalConfig() {
27
44
  const dir = process.env.LLM_WIKI_CONFIG_DIR ?? path.join(os.homedir(), '.llm-wiki')
28
45
  const globalFile = path.join(dir, 'config.json')
29
- // redactContents: this file holds API keys a corrupt-JSON error must not
30
- // echo a fragment of it to the terminal.
31
- const fileCfg = fs.existsSync(globalFile) ? readJsonFile(globalFile, { redactContents: true }) : {}
46
+ return fs.existsSync(globalFile) ? readJsonFile(globalFile, { redactContents: true }) : {}
47
+ }
48
+
49
+ export function loadLlmConfig(kbRoot) {
50
+ return loadChatConfig(kbRoot, readGlobalConfig())
51
+ }
52
+
53
+ // Resolve a chat-complete config from an already-read global fileCfg (+ the KB's
54
+ // optional llm override). Returns null unless baseURL/apiKey/model are all present.
55
+ function loadChatConfig(kbRoot, fileCfg) {
32
56
  // flat form: explicit custom endpoint wins outright
33
57
  let cfg = (fileCfg.baseURL && fileCfg.apiKey && fileCfg.model)
34
58
  ? { baseURL: fileCfg.baseURL, apiKey: fileCfg.apiKey, model: fileCfg.model, ...(fileCfg.embeddingModel ? { embeddingModel: fileCfg.embeddingModel } : {}) }
@@ -72,14 +96,10 @@ export function loadLlmConfig(kbRoot) {
72
96
  // call the API, so it resolves only when loadLlmConfig does. loadLlmConfig's own
73
97
  // (chat-complete-or-null) contract is unchanged.
74
98
  export function loadEmbedConfig(kbRoot) {
75
- const chat = loadLlmConfig(kbRoot)
99
+ const fileCfg = readGlobalConfig()
100
+ const chat = loadChatConfig(kbRoot, fileCfg)
76
101
  if (chat?.embeddingModel) return chat
77
- const dir = process.env.LLM_WIKI_CONFIG_DIR ?? path.join(os.homedir(), '.llm-wiki')
78
- const globalFile = path.join(dir, 'config.json')
79
- if (!fs.existsSync(globalFile)) return null
80
- const fileCfg = readJsonFile(globalFile, { redactContents: true })
81
- const em = fileCfg.embeddingModel
82
- ?? Object.values(fileCfg.providers ?? {}).map(p => p?.embeddingModel).find(Boolean)
102
+ const em = resolveEmbeddingModel(fileCfg)
83
103
  return (typeof em === 'string' && em.startsWith('local:')) ? { embeddingModel: em } : null
84
104
  }
85
105
 
package/src/scanner.mjs CHANGED
@@ -150,6 +150,12 @@ export async function scanSource(srcDir, kbRoot, { exclude = [], persist = true,
150
150
  const diff = diffManifest(loadManifest(kbRoot), files.map(f => ({ rel: f.rel, hash: f.hash })))
151
151
  const toCompileSet = new Set([...diff.added, ...diff.changed].map(e => e.rel))
152
152
  const toCompile = files.filter(f => toCompileSet.has(f.rel) && !exactDups.has(f.rel))
153
+ // Exact duplicates are excluded from the compile batches (toCompile), so they must not
154
+ // inflate the reported added/changed counts either — a deduped file is never written to
155
+ // the manifest, so it would otherwise read as a permanent "+1 to convert" that `convert`
156
+ // never clears and that contradicts the "0 batches" plan. Dups stay in `duplicates.exact`.
157
+ const incAdded = diff.added.filter(e => !exactDups.has(e.rel)).length
158
+ const incChanged = diff.changed.filter(e => !exactDups.has(e.rel)).length
153
159
  const batches = []
154
160
  for (let i = 0; i < toCompile.length; i += cfg.batchSize) {
155
161
  batches.push(toCompile.slice(i, i + cfg.batchSize).map(f => f.rel))
@@ -165,7 +171,7 @@ export async function scanSource(srcDir, kbRoot, { exclude = [], persist = true,
165
171
  files: files.map(({ _sig, ...f }) => f),
166
172
  skipped,
167
173
  duplicates: { exact, near },
168
- incremental: { added: diff.added.length, changed: diff.changed.length, removed: diff.removed.length, unchanged: diff.unchanged.length },
174
+ incremental: { added: incAdded, changed: incChanged, removed: diff.removed.length, unchanged: diff.unchanged.length },
169
175
  domainMixture: detectDomainMixture(files, kbRoot),
170
176
  batches,
171
177
  estimate,