daftari 1.29.0 → 1.31.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.
Files changed (369) hide show
  1. package/CHANGELOG.md +281 -0
  2. package/README.md +263 -9
  3. package/dist/access/rbac.d.ts +1 -0
  4. package/dist/access/rbac.d.ts.map +1 -1
  5. package/dist/access/rbac.js +7 -0
  6. package/dist/access/rbac.js.map +1 -1
  7. package/dist/asof/git-read.d.ts +17 -0
  8. package/dist/asof/git-read.d.ts.map +1 -0
  9. package/dist/asof/git-read.js +163 -0
  10. package/dist/asof/git-read.js.map +1 -0
  11. package/dist/asof/index.d.ts +2 -0
  12. package/dist/asof/index.d.ts.map +1 -0
  13. package/dist/asof/index.js +151 -0
  14. package/dist/asof/index.js.map +1 -0
  15. package/dist/asof/report.d.ts +11 -0
  16. package/dist/asof/report.d.ts.map +1 -0
  17. package/dist/asof/report.js +119 -0
  18. package/dist/asof/report.js.map +1 -0
  19. package/dist/asof/snapshot.d.ts +72 -0
  20. package/dist/asof/snapshot.d.ts.map +1 -0
  21. package/dist/asof/snapshot.js +207 -0
  22. package/dist/asof/snapshot.js.map +1 -0
  23. package/dist/audit/checks/broken_refs.d.ts +1 -1
  24. package/dist/audit/checks/broken_refs.d.ts.map +1 -1
  25. package/dist/audit/checks/broken_refs.js +77 -22
  26. package/dist/audit/checks/broken_refs.js.map +1 -1
  27. package/dist/audit/collect.d.ts +1 -0
  28. package/dist/audit/collect.d.ts.map +1 -1
  29. package/dist/audit/collect.js +22 -2
  30. package/dist/audit/collect.js.map +1 -1
  31. package/dist/audit/index.d.ts.map +1 -1
  32. package/dist/audit/index.js +9 -3
  33. package/dist/audit/index.js.map +1 -1
  34. package/dist/audit/links.d.ts.map +1 -1
  35. package/dist/audit/links.js +9 -3
  36. package/dist/audit/links.js.map +1 -1
  37. package/dist/audit/report.d.ts.map +1 -1
  38. package/dist/audit/report.js +2 -0
  39. package/dist/audit/report.js.map +1 -1
  40. package/dist/audit/types.d.ts +4 -1
  41. package/dist/audit/types.d.ts.map +1 -1
  42. package/dist/audit/types.js.map +1 -1
  43. package/dist/backfill/apply.js +2 -2
  44. package/dist/backfill/apply.js.map +1 -1
  45. package/dist/backfill/derive.d.ts.map +1 -1
  46. package/dist/backfill/derive.js +1 -0
  47. package/dist/backfill/derive.js.map +1 -1
  48. package/dist/backfill/plan.js +2 -2
  49. package/dist/backfill/plan.js.map +1 -1
  50. package/dist/cli.d.ts.map +1 -1
  51. package/dist/cli.js +36 -0
  52. package/dist/cli.js.map +1 -1
  53. package/dist/consolidate/call-budget.d.ts +3 -0
  54. package/dist/consolidate/call-budget.d.ts.map +1 -0
  55. package/dist/consolidate/call-budget.js +37 -0
  56. package/dist/consolidate/call-budget.js.map +1 -0
  57. package/dist/consolidate/constants.d.ts +1 -0
  58. package/dist/consolidate/constants.d.ts.map +1 -1
  59. package/dist/consolidate/constants.js +5 -0
  60. package/dist/consolidate/constants.js.map +1 -1
  61. package/dist/consolidate/index.d.ts.map +1 -1
  62. package/dist/consolidate/index.js +96 -27
  63. package/dist/consolidate/index.js.map +1 -1
  64. package/dist/court/docket.d.ts +39 -0
  65. package/dist/court/docket.d.ts.map +1 -0
  66. package/dist/court/docket.js +103 -0
  67. package/dist/court/docket.js.map +1 -0
  68. package/dist/court/index.d.ts +2 -0
  69. package/dist/court/index.d.ts.map +1 -0
  70. package/dist/court/index.js +169 -0
  71. package/dist/court/index.js.map +1 -0
  72. package/dist/court/precedent.d.ts +18 -0
  73. package/dist/court/precedent.d.ts.map +1 -0
  74. package/dist/court/precedent.js +89 -0
  75. package/dist/court/precedent.js.map +1 -0
  76. package/dist/court/report.d.ts +10 -0
  77. package/dist/court/report.d.ts.map +1 -0
  78. package/dist/court/report.js +77 -0
  79. package/dist/court/report.js.map +1 -0
  80. package/dist/curation/consumes.d.ts +24 -0
  81. package/dist/curation/consumes.d.ts.map +1 -0
  82. package/dist/curation/consumes.js +122 -0
  83. package/dist/curation/consumes.js.map +1 -0
  84. package/dist/curation/edge-staleness.d.ts +47 -0
  85. package/dist/curation/edge-staleness.d.ts.map +1 -0
  86. package/dist/curation/edge-staleness.js +258 -0
  87. package/dist/curation/edge-staleness.js.map +1 -0
  88. package/dist/curation/edges.d.ts.map +1 -1
  89. package/dist/curation/edges.js +164 -10
  90. package/dist/curation/edges.js.map +1 -1
  91. package/dist/curation/lint.d.ts +4 -1
  92. package/dist/curation/lint.d.ts.map +1 -1
  93. package/dist/curation/lint.js +61 -6
  94. package/dist/curation/lint.js.map +1 -1
  95. package/dist/curation/provenance.d.ts +2 -0
  96. package/dist/curation/provenance.d.ts.map +1 -1
  97. package/dist/curation/provenance.js +2 -0
  98. package/dist/curation/provenance.js.map +1 -1
  99. package/dist/curation/read-log.d.ts +19 -0
  100. package/dist/curation/read-log.d.ts.map +1 -0
  101. package/dist/curation/read-log.js +120 -0
  102. package/dist/curation/read-log.js.map +1 -0
  103. package/dist/curation/review-throughput.d.ts +24 -0
  104. package/dist/curation/review-throughput.d.ts.map +1 -0
  105. package/dist/curation/review-throughput.js +102 -0
  106. package/dist/curation/review-throughput.js.map +1 -0
  107. package/dist/curation/staged-actions.d.ts +11 -1
  108. package/dist/curation/staged-actions.d.ts.map +1 -1
  109. package/dist/curation/staged-actions.js +117 -30
  110. package/dist/curation/staged-actions.js.map +1 -1
  111. package/dist/curation/staleness.js +1 -1
  112. package/dist/curation/staleness.js.map +1 -1
  113. package/dist/curation/structural.d.ts +16 -0
  114. package/dist/curation/structural.d.ts.map +1 -0
  115. package/dist/curation/structural.js +64 -0
  116. package/dist/curation/structural.js.map +1 -0
  117. package/dist/curation/tension-access.d.ts +7 -0
  118. package/dist/curation/tension-access.d.ts.map +1 -0
  119. package/dist/curation/tension-access.js +44 -0
  120. package/dist/curation/tension-access.js.map +1 -0
  121. package/dist/curation/tension-blast.d.ts +6 -1
  122. package/dist/curation/tension-blast.d.ts.map +1 -1
  123. package/dist/curation/tension-blast.js +11 -2
  124. package/dist/curation/tension-blast.js.map +1 -1
  125. package/dist/curation/tension-clusters.d.ts +1 -1
  126. package/dist/curation/tension-clusters.d.ts.map +1 -1
  127. package/dist/curation/tension-clusters.js +5 -2
  128. package/dist/curation/tension-clusters.js.map +1 -1
  129. package/dist/curation/tension.d.ts +6 -2
  130. package/dist/curation/tension.d.ts.map +1 -1
  131. package/dist/curation/tension.js +86 -35
  132. package/dist/curation/tension.js.map +1 -1
  133. package/dist/curation/tier0.d.ts +27 -0
  134. package/dist/curation/tier0.d.ts.map +1 -0
  135. package/dist/curation/tier0.js +196 -0
  136. package/dist/curation/tier0.js.map +1 -0
  137. package/dist/curation/tier1.d.ts +30 -0
  138. package/dist/curation/tier1.d.ts.map +1 -0
  139. package/dist/curation/tier1.js +142 -0
  140. package/dist/curation/tier1.js.map +1 -0
  141. package/dist/curation/tier2.d.ts +43 -0
  142. package/dist/curation/tier2.d.ts.map +1 -0
  143. package/dist/curation/tier2.js +182 -0
  144. package/dist/curation/tier2.js.map +1 -0
  145. package/dist/curation/vault-docs.d.ts +5 -1
  146. package/dist/curation/vault-docs.d.ts.map +1 -1
  147. package/dist/curation/vault-docs.js +15 -1
  148. package/dist/curation/vault-docs.js.map +1 -1
  149. package/dist/eval/generate.js +22 -5
  150. package/dist/eval/generate.js.map +1 -1
  151. package/dist/eval/index.d.ts.map +1 -1
  152. package/dist/eval/index.js +173 -25
  153. package/dist/eval/index.js.map +1 -1
  154. package/dist/eval/llm-openrouter.d.ts +9 -0
  155. package/dist/eval/llm-openrouter.d.ts.map +1 -0
  156. package/dist/eval/llm-openrouter.js +171 -0
  157. package/dist/eval/llm-openrouter.js.map +1 -0
  158. package/dist/eval/llm.js +1 -1
  159. package/dist/eval/llm.js.map +1 -1
  160. package/dist/eval/prune.d.ts +24 -0
  161. package/dist/eval/prune.d.ts.map +1 -0
  162. package/dist/eval/prune.js +83 -0
  163. package/dist/eval/prune.js.map +1 -0
  164. package/dist/eval/storage.d.ts +3 -0
  165. package/dist/eval/storage.d.ts.map +1 -1
  166. package/dist/eval/storage.js +14 -0
  167. package/dist/eval/storage.js.map +1 -1
  168. package/dist/frontmatter/parser.d.ts +1 -0
  169. package/dist/frontmatter/parser.d.ts.map +1 -1
  170. package/dist/frontmatter/parser.js +12 -0
  171. package/dist/frontmatter/parser.js.map +1 -1
  172. package/dist/frontmatter/schema.d.ts.map +1 -1
  173. package/dist/frontmatter/schema.js +38 -3
  174. package/dist/frontmatter/schema.js.map +1 -1
  175. package/dist/frontmatter/types.d.ts +4 -1
  176. package/dist/frontmatter/types.d.ts.map +1 -1
  177. package/dist/frontmatter/types.js +8 -0
  178. package/dist/frontmatter/types.js.map +1 -1
  179. package/dist/import/index.d.ts.map +1 -1
  180. package/dist/import/index.js +44 -9
  181. package/dist/import/index.js.map +1 -1
  182. package/dist/import/langgraph-store.d.ts +53 -0
  183. package/dist/import/langgraph-store.d.ts.map +1 -0
  184. package/dist/import/langgraph-store.js +292 -0
  185. package/dist/import/langgraph-store.js.map +1 -0
  186. package/dist/index.d.ts +5 -0
  187. package/dist/index.d.ts.map +1 -1
  188. package/dist/index.js +56 -18
  189. package/dist/index.js.map +1 -1
  190. package/dist/lifecycle/lock.d.ts +9 -1
  191. package/dist/lifecycle/lock.d.ts.map +1 -1
  192. package/dist/lifecycle/lock.js +34 -9
  193. package/dist/lifecycle/lock.js.map +1 -1
  194. package/dist/okf/export.d.ts +12 -0
  195. package/dist/okf/export.d.ts.map +1 -0
  196. package/dist/okf/export.js +115 -0
  197. package/dist/okf/export.js.map +1 -0
  198. package/dist/okf/import.d.ts +24 -0
  199. package/dist/okf/import.d.ts.map +1 -0
  200. package/dist/okf/import.js +107 -0
  201. package/dist/okf/import.js.map +1 -0
  202. package/dist/okf/index.d.ts +2 -0
  203. package/dist/okf/index.d.ts.map +1 -0
  204. package/dist/okf/index.js +147 -0
  205. package/dist/okf/index.js.map +1 -0
  206. package/dist/okf/map.d.ts +16 -0
  207. package/dist/okf/map.d.ts.map +1 -0
  208. package/dist/okf/map.js +149 -0
  209. package/dist/okf/map.js.map +1 -0
  210. package/dist/okf/types.d.ts +9 -0
  211. package/dist/okf/types.d.ts.map +1 -0
  212. package/dist/okf/types.js +48 -0
  213. package/dist/okf/types.js.map +1 -0
  214. package/dist/search/contested.d.ts +18 -0
  215. package/dist/search/contested.d.ts.map +1 -0
  216. package/dist/search/contested.js +119 -0
  217. package/dist/search/contested.js.map +1 -0
  218. package/dist/search/coverage.d.ts.map +1 -1
  219. package/dist/search/coverage.js +1 -1
  220. package/dist/search/coverage.js.map +1 -1
  221. package/dist/search/hybrid.d.ts +23 -0
  222. package/dist/search/hybrid.d.ts.map +1 -1
  223. package/dist/search/hybrid.js +75 -15
  224. package/dist/search/hybrid.js.map +1 -1
  225. package/dist/search/reindex.d.ts +1 -0
  226. package/dist/search/reindex.d.ts.map +1 -1
  227. package/dist/search/reindex.js +57 -24
  228. package/dist/search/reindex.js.map +1 -1
  229. package/dist/search/watcher.d.ts +2 -0
  230. package/dist/search/watcher.d.ts.map +1 -1
  231. package/dist/search/watcher.js +23 -12
  232. package/dist/search/watcher.js.map +1 -1
  233. package/dist/serve/index.d.ts +36 -0
  234. package/dist/serve/index.d.ts.map +1 -0
  235. package/dist/serve/index.js +507 -0
  236. package/dist/serve/index.js.map +1 -0
  237. package/dist/server.d.ts +10 -1
  238. package/dist/server.d.ts.map +1 -1
  239. package/dist/server.js +91 -12
  240. package/dist/server.js.map +1 -1
  241. package/dist/sleep/cycle.d.ts +59 -0
  242. package/dist/sleep/cycle.d.ts.map +1 -0
  243. package/dist/sleep/cycle.js +127 -0
  244. package/dist/sleep/cycle.js.map +1 -0
  245. package/dist/sleep/index.d.ts +5 -0
  246. package/dist/sleep/index.d.ts.map +1 -0
  247. package/dist/sleep/index.js +320 -0
  248. package/dist/sleep/index.js.map +1 -0
  249. package/dist/sleep/report.d.ts +14 -0
  250. package/dist/sleep/report.d.ts.map +1 -0
  251. package/dist/sleep/report.js +139 -0
  252. package/dist/sleep/report.js.map +1 -0
  253. package/dist/sleep/tension-scan-state.d.ts +19 -0
  254. package/dist/sleep/tension-scan-state.d.ts.map +1 -0
  255. package/dist/sleep/tension-scan-state.js +0 -0
  256. package/dist/sleep/tension-scan-state.js.map +1 -0
  257. package/dist/sleep/tension-scan.d.ts +44 -0
  258. package/dist/sleep/tension-scan.d.ts.map +1 -0
  259. package/dist/sleep/tension-scan.js +0 -0
  260. package/dist/sleep/tension-scan.js.map +1 -0
  261. package/dist/storage/backend.d.ts +12 -0
  262. package/dist/storage/backend.d.ts.map +1 -0
  263. package/dist/storage/backend.js +231 -0
  264. package/dist/storage/backend.js.map +1 -0
  265. package/dist/storage/backends/fs.d.ts +4 -0
  266. package/dist/storage/backends/fs.d.ts.map +1 -0
  267. package/dist/storage/backends/fs.js +99 -0
  268. package/dist/storage/backends/fs.js.map +1 -0
  269. package/dist/storage/fs-walk.d.ts +5 -0
  270. package/dist/storage/fs-walk.d.ts.map +1 -0
  271. package/dist/storage/fs-walk.js +28 -0
  272. package/dist/storage/fs-walk.js.map +1 -0
  273. package/dist/storage/index-db.d.ts +18 -0
  274. package/dist/storage/index-db.d.ts.map +1 -1
  275. package/dist/storage/index-db.js +159 -22
  276. package/dist/storage/index-db.js.map +1 -1
  277. package/dist/storage/local.d.ts +7 -1
  278. package/dist/storage/local.d.ts.map +1 -1
  279. package/dist/storage/local.js +42 -4
  280. package/dist/storage/local.js.map +1 -1
  281. package/dist/storage/sync.d.ts +25 -0
  282. package/dist/storage/sync.d.ts.map +1 -0
  283. package/dist/storage/sync.js +308 -0
  284. package/dist/storage/sync.js.map +1 -0
  285. package/dist/sync/index.d.ts +2 -0
  286. package/dist/sync/index.d.ts.map +1 -0
  287. package/dist/sync/index.js +136 -0
  288. package/dist/sync/index.js.map +1 -0
  289. package/dist/themes/clustering.d.ts +6 -11
  290. package/dist/themes/clustering.d.ts.map +1 -1
  291. package/dist/themes/clustering.js +76 -84
  292. package/dist/themes/clustering.js.map +1 -1
  293. package/dist/tools/consumes.d.ts +14 -0
  294. package/dist/tools/consumes.d.ts.map +1 -0
  295. package/dist/tools/consumes.js +99 -0
  296. package/dist/tools/consumes.js.map +1 -0
  297. package/dist/tools/curation.d.ts +2 -0
  298. package/dist/tools/curation.d.ts.map +1 -1
  299. package/dist/tools/curation.js +102 -7
  300. package/dist/tools/curation.js.map +1 -1
  301. package/dist/tools/edge-staleness.d.ts +28 -0
  302. package/dist/tools/edge-staleness.d.ts.map +1 -0
  303. package/dist/tools/edge-staleness.js +205 -0
  304. package/dist/tools/edge-staleness.js.map +1 -0
  305. package/dist/tools/edges.d.ts.map +1 -1
  306. package/dist/tools/edges.js +28 -22
  307. package/dist/tools/edges.js.map +1 -1
  308. package/dist/tools/read.d.ts +15 -1
  309. package/dist/tools/read.d.ts.map +1 -1
  310. package/dist/tools/read.js +144 -31
  311. package/dist/tools/read.js.map +1 -1
  312. package/dist/tools/receipt.d.ts +65 -0
  313. package/dist/tools/receipt.d.ts.map +1 -0
  314. package/dist/tools/receipt.js +257 -0
  315. package/dist/tools/receipt.js.map +1 -0
  316. package/dist/tools/search.d.ts +1 -0
  317. package/dist/tools/search.d.ts.map +1 -1
  318. package/dist/tools/search.js +186 -12
  319. package/dist/tools/search.js.map +1 -1
  320. package/dist/tools/staged-actions.d.ts +3 -0
  321. package/dist/tools/staged-actions.d.ts.map +1 -1
  322. package/dist/tools/staged-actions.js +228 -23
  323. package/dist/tools/staged-actions.js.map +1 -1
  324. package/dist/tools/themes.d.ts +9 -0
  325. package/dist/tools/themes.d.ts.map +1 -1
  326. package/dist/tools/themes.js +333 -133
  327. package/dist/tools/themes.js.map +1 -1
  328. package/dist/tools/tier1.d.ts +14 -0
  329. package/dist/tools/tier1.d.ts.map +1 -0
  330. package/dist/tools/tier1.js +169 -0
  331. package/dist/tools/tier1.js.map +1 -0
  332. package/dist/tools/tier2.d.ts +27 -0
  333. package/dist/tools/tier2.d.ts.map +1 -0
  334. package/dist/tools/tier2.js +344 -0
  335. package/dist/tools/tier2.js.map +1 -0
  336. package/dist/tools/witness.d.ts +9 -0
  337. package/dist/tools/witness.d.ts.map +1 -0
  338. package/dist/tools/witness.js +66 -0
  339. package/dist/tools/witness.js.map +1 -0
  340. package/dist/tools/write.d.ts +9 -1
  341. package/dist/tools/write.d.ts.map +1 -1
  342. package/dist/tools/write.js +593 -59
  343. package/dist/tools/write.js.map +1 -1
  344. package/dist/utils/config.d.ts +54 -0
  345. package/dist/utils/config.d.ts.map +1 -1
  346. package/dist/utils/config.js +420 -4
  347. package/dist/utils/config.js.map +1 -1
  348. package/dist/utils/paths.d.ts +2 -0
  349. package/dist/utils/paths.d.ts.map +1 -0
  350. package/dist/utils/paths.js +15 -0
  351. package/dist/utils/paths.js.map +1 -0
  352. package/dist/utils/redos.d.ts +2 -0
  353. package/dist/utils/redos.d.ts.map +1 -0
  354. package/dist/utils/redos.js +101 -0
  355. package/dist/utils/redos.js.map +1 -0
  356. package/dist/utils/run-id.d.ts +3 -0
  357. package/dist/utils/run-id.d.ts.map +1 -0
  358. package/dist/utils/run-id.js +19 -0
  359. package/dist/utils/run-id.js.map +1 -0
  360. package/dist/utils/vault-gitignore.d.ts +1 -1
  361. package/dist/utils/vault-gitignore.d.ts.map +1 -1
  362. package/dist/utils/vault-gitignore.js +6 -1
  363. package/dist/utils/vault-gitignore.js.map +1 -1
  364. package/dist/witness/track-record.d.ts +42 -0
  365. package/dist/witness/track-record.d.ts.map +1 -0
  366. package/dist/witness/track-record.js +189 -0
  367. package/dist/witness/track-record.js.map +1 -0
  368. package/package.json +27 -2
  369. package/templates/config.yaml +8 -0
@@ -1,33 +1,42 @@
1
- // vault_themes — thematic clustering over document-pooled embeddings.
1
+ // vault_themes — thematic clustering over chunk embeddings with per-document
2
+ // theme distributions (#58).
2
3
  //
3
- // Cluster DOCUMENTS, not chunks: for each document we mean-pool its chunk
4
- // embeddings into one vector, L2-normalise it, and cluster the resulting
5
- // ~N-doc set. ~3.5k document vectors instead of ~44k chunk vectors makes
6
- // every algorithm cheap (silhouette becomes tractable on the full set; no
7
- // sampling needed at this scale).
4
+ // Cluster CHUNKS, not pooled documents: v1 mean-pooled each document's chunk
5
+ // embeddings into one vector, which collapses a genuinely two-region document
6
+ // (half pricing, half moonshot) into a single point that lands in neither
7
+ // region. v2 clusters the chunk vectors directly and aggregates chunk
8
+ // assignments into per-document DISTRIBUTIONS over themes, so that document
9
+ // carries weight in both themes instead of a wrong single assignment.
8
10
  //
9
- // v1 constraints (locked):
10
- // - One-doc-one-theme. Each document lives in exactly one cluster.
11
+ // v2 constraints (locked):
12
+ // - Documents have distributions, not single assignments. `documentCount`
13
+ // is COVERAGE (a doc in two themes counts in both); the partition lives
14
+ // in `primaryDocumentCount` (each doc counted once, at its argmax theme).
15
+ // - A theme membership below MEMBERSHIP_MIN_FRACTION of the doc's chunks
16
+ // is dropped (except the argmax) — a one-chunk aside must not create a
17
+ // phantom membership (#58's granularity concern).
11
18
  // - Heuristic labels from TF-IDF over titles + tags. No LLM call.
12
19
  // - No new storage — the tool is read-only against the existing
13
20
  // embeddings / chunks / documents tables.
14
- // - Default k-sweep over {10, 15, 20, 25}; an explicit `k` argument
15
- // skips the sweep.
16
- // - Deterministic via a fixed seed for the k-means RNG.
21
+ // - Default k-sweep over {10, 15, 20, 25}; an explicit `k` argument skips
22
+ // the sweep. Silhouette for the sweep is computed on a deterministic
23
+ // stride sample (chunk-scale inputs make the full O(n²) score ~2B pairs).
24
+ // - Deterministic via a fixed seed for the k-means RNG and stride (never
25
+ // random) sampling.
17
26
  import { canRead } from "../access/rbac.js";
18
27
  import { err, ok } from "../frontmatter/types.js";
19
28
  import { getProvider } from "../search/vector.js";
20
29
  import { blobToEmbedding } from "../storage/index-db.js";
21
- import { clusterCoherence, kmeans, meanPoolL2, pickK, seededRng, selectSecondaryMemberships, } from "../themes/clustering.js";
30
+ import { clusterCoherence, kmeans, membershipDistributions, pickK, seededRng, strideSample, } from "../themes/clustering.js";
22
31
  import { ensureIndexReady, openIndexForActiveProvider } from "./search.js";
23
32
  // Fixed seed for the k-means RNG. The whole point of vault_themes is that
24
33
  // the same vault produces the same themes — a random seed would give the
25
34
  // user different themes every run and erode trust in the output.
26
35
  const THEMES_RNG_SEED = 0x7da17f1; // arbitrary; constant is what matters
27
36
  // Default k-sweep range (from the issue dialogue). Each is clamped to the
28
- // number of clusterable documents inside pickK.
37
+ // number of clusterable chunks inside pickK.
29
38
  const DEFAULT_K_CANDIDATES = [10, 15, 20, 25];
30
- // k-means iteration cap. Documents on the unit sphere converge fast in
39
+ // k-means iteration cap. Vectors on the unit sphere converge fast in
31
40
  // practice; this is a safety bound for pathological inputs.
32
41
  const KMEANS_MAX_ITER = 50;
33
42
  // Representative-doc count returned per theme. 5 is enough to be useful in
@@ -36,15 +45,19 @@ const REPRESENTATIVE_DOCS_PER_THEME = 5;
36
45
  // Tag-frequency cutoff per theme. Five most-common tags is enough signal
37
46
  // without burying the user in noise.
38
47
  const RELATED_TAGS_PER_THEME = 5;
39
- // Secondary-membership tuning. A doc qualifies as a secondary member of a
40
- // non-primary cluster when its centroid similarity is BOTH within DELTA of
41
- // the primary AND above MIN_ABS_SIMILARITY. The cap prevents a centroid-
42
- // of-mass doc from showing up in every cluster. Defaults are deliberately
43
- // conservative secondaries should surface cross-cutting docs, not bury
44
- // the primary signal.
45
- const SECONDARY_DELTA = 0.1;
46
- const SECONDARY_MIN_SIMILARITY = 0.5;
47
- const SECONDARY_MAX_PER_DOC = 2;
48
+ // A doc is a member of a theme when at least this fraction of its chunks
49
+ // landed there (the argmax theme always qualifies). 0.25 keeps the 60/40
50
+ // two-region synthesis doc in both themes while dropping a one-chunk aside
51
+ // in a ten-chunk doc (0.1) #58's phantom-theme guard.
52
+ const MEMBERSHIP_MIN_FRACTION = 0.25;
53
+ // Silhouette sample cap for the k-sweep. The sweep clusters every chunk but
54
+ // scores k on a deterministic stride sample: full silhouette is O(n²) and
55
+ // chunk-scale inputs (~44k on the motivating vault) would make it ~2B pairs
56
+ // per candidate k.
57
+ const SILHOUETTE_SAMPLE_CAP = 2000;
58
+ // Per-theme coherence sample cap, same O(n²) rationale as the silhouette
59
+ // cap but per cluster.
60
+ const COHERENCE_SAMPLE_CAP = 200;
48
61
  // Hard cap on secondaries reported per theme. Same UX rationale as
49
62
  // REPRESENTATIVE_DOCS_PER_THEME — enough to be useful, bounded enough to
50
63
  // not bury the user.
@@ -95,14 +108,28 @@ function labelTokens(text) {
95
108
  }
96
109
  // Loads every (path, content_hash, embedding) row for the active model, in
97
110
  // path-then-chunk-index order, and groups embeddings by document path.
98
- function loadEmbeddingsByPath(db, model) {
99
- const rows = db
100
- .prepare(`SELECT c.path AS path, e.embedding AS embedding, e.dim AS dim
111
+ //
112
+ // When `collection` is given the chunk scan is joined to `documents` and
113
+ // filtered to that collection IN SQL, so out-of-scope embeddings are never
114
+ // read out of the database or copied into memory (E3). The collection column
115
+ // is authoritative for scope — the same predicate `buildScopedDocs` applies —
116
+ // so pushing it down here changes nothing about the clustered result, only
117
+ // which rows are loaded.
118
+ function loadEmbeddingsByPath(db, model, collection) {
119
+ const sql = collection
120
+ ? `SELECT c.path AS path, e.embedding AS embedding, e.dim AS dim
121
+ FROM chunks c
122
+ JOIN documents d ON d.path = c.path
123
+ LEFT JOIN embeddings e
124
+ ON e.content_hash = c.content_hash AND e.model = ?
125
+ WHERE d.collection = ?
126
+ ORDER BY c.path, c.chunk_index`
127
+ : `SELECT c.path AS path, e.embedding AS embedding, e.dim AS dim
101
128
  FROM chunks c
102
129
  LEFT JOIN embeddings e
103
130
  ON e.content_hash = c.content_hash AND e.model = ?
104
- ORDER BY c.path, c.chunk_index`)
105
- .all(model);
131
+ ORDER BY c.path, c.chunk_index`;
132
+ const rows = (collection ? db.prepare(sql).all(model, collection) : db.prepare(sql).all(model));
106
133
  const provider = getProvider();
107
134
  const expectedDim = provider.dim;
108
135
  const out = new Map();
@@ -124,49 +151,150 @@ function loadEmbeddingsByPath(db, model) {
124
151
  }
125
152
  return out;
126
153
  }
127
- // Applies the scope filter (collection, tags), RBAC, and the doc-has-an-
128
- // embedded-chunk requirement; returns the clusterable doc set and the count
129
- // of docs the embedding requirement skipped.
130
- function buildScopedDocs(documents, embeddingsByPath, filters, access) {
131
- const scoped = [];
132
- let skipped = 0;
133
- for (const doc of documents) {
134
- if (filters.collection && doc.collection !== filters.collection)
154
+ // Process-level memo of the chunk vector set. Keyed by
155
+ // `${vaultRoot} ${model} ${collection ?? "*"}`; the entry's
156
+ // `signature` is checked against the live index signature on every call and a
157
+ // mismatch (any reindex/content change) discards the stale entry. Bounded to
158
+ // one entry per scope key — the working set for a single vault is the same
159
+ // vectors clustering must hold in memory anyway, and the cache is dropped
160
+ // wholesale on signature change.
161
+ const chunkSetCache = new Map();
162
+ // Test-only hook: reset the process-level cache between cases.
163
+ export function __resetThemesCache() {
164
+ chunkSetCache.clear();
165
+ }
166
+ // Cheap content signature for the (collection-scoped) chunk+embedding set of
167
+ // the active model. It changes whenever a chunk is added/removed/re-hashed or
168
+ // an embedding is (re)written, which is exactly when the chunk vectors would
169
+ // differ — so a matching signature guarantees the cached set is still valid.
170
+ // `documents.rowid` participation via count keeps a bare collection rename
171
+ // honest. This scans indexes, not the embedding blobs.
172
+ function indexSignature(db, model, collection) {
173
+ const sql = collection
174
+ ? `SELECT COUNT(*) AS n,
175
+ group_concat(c.content_hash, '') AS hashes,
176
+ SUM(CASE WHEN e.content_hash IS NULL THEN 0 ELSE 1 END) AS embn
177
+ FROM chunks c
178
+ JOIN documents d ON d.path = c.path
179
+ LEFT JOIN embeddings e
180
+ ON e.content_hash = c.content_hash AND e.model = ?
181
+ WHERE d.collection = ?`
182
+ : `SELECT COUNT(*) AS n,
183
+ group_concat(c.content_hash, '') AS hashes,
184
+ SUM(CASE WHEN e.content_hash IS NULL THEN 0 ELSE 1 END) AS embn
185
+ FROM chunks c
186
+ LEFT JOIN embeddings e
187
+ ON e.content_hash = c.content_hash AND e.model = ?`;
188
+ const row = (collection ? db.prepare(sql).get(model, collection) : db.prepare(sql).get(model));
189
+ // A tiny FNV-1a over the concatenated hashes keeps the key bounded regardless
190
+ // of vault size while still varying on any content change.
191
+ const hashes = row.hashes ?? "";
192
+ let h = 0x811c9dc5;
193
+ for (let i = 0; i < hashes.length; i++) {
194
+ h ^= hashes.charCodeAt(i);
195
+ h = Math.imul(h, 0x01000193);
196
+ }
197
+ const hashDigest = (h >>> 0).toString(16);
198
+ return `${model}:${collection ?? "*"}:${row.n ?? 0}:${row.embn ?? 0}:${hashDigest}`;
199
+ }
200
+ // Returns the per-doc chunk vector set for the given scope, using the cache
201
+ // when the live signature matches and rebuilding (load + normalise)
202
+ // otherwise. This is the load-avoidance core of the E3 fix: a second call
203
+ // against an unchanged index re-uses the vectors instead of re-reading every
204
+ // embedding blob.
205
+ function getChunkSet(db, vaultRoot, model, documentsByPath, collection) {
206
+ const cacheKey = `${vaultRoot} ${model} ${collection ?? "*"}`;
207
+ const signature = indexSignature(db, model, collection);
208
+ const cached = chunkSetCache.get(cacheKey);
209
+ if (cached && cached.signature === signature)
210
+ return cached;
211
+ const embeddingsByPath = loadEmbeddingsByPath(db, model, collection);
212
+ const docs = [];
213
+ const unembeddedPaths = new Set();
214
+ for (const [path, doc] of documentsByPath) {
215
+ if (collection && doc.collection !== collection)
135
216
  continue;
136
- if (filters.tags && filters.tags.length > 0) {
137
- const hasAll = filters.tags.every((t) => doc.tags.includes(t));
138
- if (!hasAll)
217
+ const raw = embeddingsByPath.get(path) ?? [];
218
+ // Normalise each chunk onto the unit sphere (cosine semantics for the
219
+ // clustering); drop zero vectors — they carry no direction to cluster.
220
+ const chunks = [];
221
+ for (const vec of raw) {
222
+ // Single pass per chunk: this runs at chunk scale (~44k on the
223
+ // motivating vault), so the norm is computed once and reused for the
224
+ // scaling rather than recomputed inside a normalize helper.
225
+ let norm = 0;
226
+ for (let i = 0; i < vec.length; i++) {
227
+ const x = vec[i];
228
+ norm += x * x;
229
+ }
230
+ if (norm === 0)
139
231
  continue;
232
+ const inv = 1 / Math.sqrt(norm);
233
+ const out = new Float32Array(vec.length);
234
+ for (let i = 0; i < vec.length; i++)
235
+ out[i] = vec[i] * inv;
236
+ chunks.push(out);
140
237
  }
141
- if (access && !canRead(access.role, doc.collection))
142
- continue;
143
- const chunkVecs = embeddingsByPath.get(doc.path) ?? [];
144
- const pooled = meanPoolL2(chunkVecs);
145
- if (!pooled) {
146
- // No embedded chunks (or an all-zero pool): count as skipped per
147
- // the v1 contract.
148
- skipped += 1;
238
+ if (chunks.length === 0) {
239
+ unembeddedPaths.add(path);
149
240
  continue;
150
241
  }
242
+ docs.push({
243
+ path: doc.path,
244
+ title: doc.title,
245
+ collection: doc.collection,
246
+ tags: doc.tags,
247
+ chunks,
248
+ });
249
+ }
250
+ const set = { signature, docs, unembeddedPaths };
251
+ chunkSetCache.set(cacheKey, set);
252
+ return set;
253
+ }
254
+ // Applies the per-caller scope (tags, RBAC) and the doc-has-an-embedded-chunk
255
+ // requirement over a pre-loaded set. The collection filter is already applied
256
+ // upstream in `getChunkSet` (and pushed into SQL), so it is not re-checked
257
+ // here beyond honouring it for the skipped count. Returns the clusterable doc
258
+ // set and the count of docs the embedding requirement skipped.
259
+ //
260
+ // `skipped` must match the pre-cache contract exactly: a doc counts as skipped
261
+ // only if it passes collection + tags + RBAC and yet has no chunk vector.
262
+ function finalizeScope(chunkSet, documentsByPath, filters, access) {
263
+ const passesTagsAndRbac = (collection, tags) => {
264
+ if (filters.tags && filters.tags.length > 0) {
265
+ const hasAll = filters.tags.every((t) => tags.includes(t));
266
+ if (!hasAll)
267
+ return false;
268
+ }
269
+ if (access && !canRead(access.role, collection))
270
+ return false;
271
+ return true;
272
+ };
273
+ const scoped = [];
274
+ for (const doc of chunkSet.docs) {
275
+ if (!passesTagsAndRbac(doc.collection, doc.tags))
276
+ continue;
151
277
  scoped.push({
152
278
  path: doc.path,
153
279
  title: doc.title,
154
280
  collection: doc.collection,
155
281
  tags: doc.tags,
156
- vector: pooled,
282
+ chunks: doc.chunks,
157
283
  });
158
284
  }
285
+ let skipped = 0;
286
+ for (const path of chunkSet.unembeddedPaths) {
287
+ const doc = documentsByPath.get(path);
288
+ if (!doc)
289
+ continue;
290
+ if (filters.collection && doc.collection !== filters.collection)
291
+ continue;
292
+ if (!passesTagsAndRbac(doc.collection, doc.tags))
293
+ continue;
294
+ skipped += 1;
295
+ }
159
296
  return { scoped, skipped };
160
297
  }
161
- // Cosine similarity for L2-normalised vectors == dot product. Used for
162
- // representative-doc ranking against a cluster centroid.
163
- function dotNormalized(a, b) {
164
- let s = 0;
165
- const n = Math.min(a.length, b.length);
166
- for (let i = 0; i < n; i++)
167
- s += a[i] * b[i];
168
- return s;
169
- }
170
298
  // TF-IDF over the cluster: term frequency among the cluster's titles +
171
299
  // tags, divided by the document-frequency across ALL clusters (so a term
172
300
  // that names every theme — e.g. "doc" — doesn't dominate any one of them).
@@ -194,7 +322,7 @@ function buildLabel(clusterDocs, globalDocCount, globalDf, themeIndex) {
194
322
  continue;
195
323
  scored.push({ term, score: freq * idf });
196
324
  }
197
- scored.sort((a, b) => b.score - a.score);
325
+ scored.sort((a, b) => b.score - a.score || a.term.localeCompare(b.term));
198
326
  const top = scored.slice(0, 3).map((s) => s.term);
199
327
  if (top.length > 0)
200
328
  return top.join(" / ");
@@ -241,13 +369,6 @@ function topTagsForCluster(clusterDocs) {
241
369
  .slice(0, RELATED_TAGS_PER_THEME)
242
370
  .map(([t]) => t);
243
371
  }
244
- function representativesForCluster(clusterDocs, centroid) {
245
- return [...clusterDocs]
246
- .map((d) => ({ path: d.path, sim: dotNormalized(d.vector, centroid) }))
247
- .sort((a, b) => b.sim - a.sim || a.path.localeCompare(b.path))
248
- .slice(0, REPRESENTATIVE_DOCS_PER_THEME)
249
- .map((d) => d.path);
250
- }
251
372
  function parseArgs(args) {
252
373
  let k;
253
374
  if (args.k !== undefined) {
@@ -278,104 +399,180 @@ export async function vaultThemes(vaultRoot, args, access) {
278
399
  const db = dbResult.value;
279
400
  try {
280
401
  const provider = getProvider();
281
- const docRows = db
282
- .prepare("SELECT path, title, collection, tags FROM documents ORDER BY path")
283
- .all();
284
- const documents = docRows.map((r) => ({
285
- path: r.path,
286
- title: r.title,
287
- collection: r.collection,
288
- domain: "",
289
- status: "",
290
- confidence: "",
291
- updated: "",
292
- tags: JSON.parse(r.tags),
293
- content: "",
294
- tokens: [],
295
- ttlDays: null,
296
- created: "",
297
- supersededBy: null,
298
- }));
299
- const embeddingsByPath = loadEmbeddingsByPath(db, provider.id);
300
- const { scoped, skipped } = buildScopedDocs(documents, embeddingsByPath, parsed.value, access);
402
+ // Push the collection filter into the documents scan too: a scoped call
403
+ // never materialises out-of-scope document rows, and `JSON.parse(tags)`
404
+ // runs only for in-scope docs (part of the E3 load-avoidance).
405
+ const collection = parsed.value.collection;
406
+ const docRows = (collection
407
+ ? db
408
+ .prepare("SELECT path, title, collection, tags FROM documents WHERE collection = ? ORDER BY path")
409
+ .all(collection)
410
+ : db.prepare("SELECT path, title, collection, tags FROM documents ORDER BY path").all());
411
+ const documentsByPath = new Map();
412
+ for (const r of docRows) {
413
+ documentsByPath.set(r.path, {
414
+ path: r.path,
415
+ title: r.title,
416
+ collection: r.collection,
417
+ domain: "",
418
+ status: "",
419
+ confidence: "",
420
+ updated: "",
421
+ tags: JSON.parse(r.tags),
422
+ content: "",
423
+ tokens: [],
424
+ ttlDays: null,
425
+ created: "",
426
+ supersededBy: null,
427
+ });
428
+ }
429
+ const chunkSet = getChunkSet(db, vaultRoot, provider.id, documentsByPath, collection);
430
+ const { scoped, skipped } = finalizeScope(chunkSet, documentsByPath, parsed.value, access);
301
431
  if (scoped.length === 0) {
302
432
  return ok({
303
433
  themes: [],
434
+ docMemberships: {},
304
435
  totalDocuments: 0,
436
+ totalChunks: 0,
305
437
  skippedDocuments: skipped,
306
438
  selectedK: 0,
439
+ droppedClusters: 0,
307
440
  clusteredAt: new Date().toISOString(),
308
441
  });
309
442
  }
310
- const vectors = scoped.map((s) => s.vector);
443
+ // Flatten to chunk-level arrays: the clustering input plus the chunk→doc
444
+ // map the distribution aggregation needs.
445
+ const chunkVectors = [];
446
+ const chunkDoc = [];
447
+ for (let d = 0; d < scoped.length; d++) {
448
+ for (const vec of scoped[d].chunks) {
449
+ chunkVectors.push(vec);
450
+ chunkDoc.push(d);
451
+ }
452
+ }
311
453
  const rng = seededRng(THEMES_RNG_SEED);
312
454
  let selectedK;
313
455
  let assignments;
314
- let centroids;
315
456
  if (parsed.value.k !== undefined) {
316
- const result = kmeans(vectors, parsed.value.k, rng, KMEANS_MAX_ITER);
457
+ const result = kmeans(chunkVectors, parsed.value.k, rng, KMEANS_MAX_ITER);
317
458
  selectedK = result.centroids.length;
318
459
  assignments = result.assignments;
319
- centroids = result.centroids;
320
460
  }
321
461
  else {
322
- const result = pickK(vectors, DEFAULT_K_CANDIDATES, rng, KMEANS_MAX_ITER);
462
+ const result = pickK(chunkVectors, DEFAULT_K_CANDIDATES, rng, KMEANS_MAX_ITER, SILHOUETTE_SAMPLE_CAP);
323
463
  selectedK = result.centroids.length;
324
464
  assignments = result.assignments;
325
- centroids = result.centroids;
326
465
  }
327
- // Group docs by cluster.
328
- const byCluster = new Map();
329
- for (let i = 0; i < scoped.length; i++) {
466
+ // Aggregate chunk assignments into per-doc theme distributions (#58).
467
+ const distributions = membershipDistributions(assignments, chunkDoc, scoped.length, MEMBERSHIP_MIN_FRACTION);
468
+ // Group member/primary docs by cluster from the distributions.
469
+ const membersByCluster = new Map();
470
+ const primaryByCluster = new Map();
471
+ for (let d = 0; d < distributions.length; d++) {
472
+ const memberships = distributions[d] ?? [];
473
+ for (let m = 0; m < memberships.length; m++) {
474
+ const { cluster, weight } = memberships[m];
475
+ const list = membersByCluster.get(cluster);
476
+ if (list)
477
+ list.push({ docIndex: d, weight });
478
+ else
479
+ membersByCluster.set(cluster, [{ docIndex: d, weight }]);
480
+ // memberships are weight-desc with deterministic ties: entry 0 IS
481
+ // the argmax/primary.
482
+ if (m === 0) {
483
+ const plist = primaryByCluster.get(cluster);
484
+ if (plist)
485
+ plist.push(d);
486
+ else
487
+ primaryByCluster.set(cluster, [d]);
488
+ }
489
+ }
490
+ }
491
+ // Chunk vectors per cluster for the coherence score.
492
+ const chunksByCluster = new Map();
493
+ for (let i = 0; i < chunkVectors.length; i++) {
330
494
  const c = assignments[i];
331
- const doc = scoped[i];
332
- const list = byCluster.get(c);
495
+ const list = chunksByCluster.get(c);
333
496
  if (list)
334
- list.push(doc);
497
+ list.push(chunkVectors[i]);
335
498
  else
336
- byCluster.set(c, [doc]);
499
+ chunksByCluster.set(c, [chunkVectors[i]]);
500
+ }
501
+ // A chunk-bearing cluster no doc's retained membership points at is a
502
+ // grab-bag of sub-threshold asides — dropped from `themes` by the
503
+ // phantom-theme guard, but COUNTED so the omission is visible (empty
504
+ // k-means clusters from the k-clamp are not "dropped": they never had
505
+ // chunks).
506
+ let droppedClusters = 0;
507
+ for (const clusterId of chunksByCluster.keys()) {
508
+ if (!membersByCluster.has(clusterId))
509
+ droppedClusters += 1;
337
510
  }
338
511
  const globalDf = buildGlobalDf(scoped);
339
- // Secondary memberships: for each doc, find non-primary clusters its
340
- // pooled vector also aligns with. This is the soft-reporting layer on
341
- // top of the hard partition — it does NOT change documentCount, which
342
- // still reflects primary membership only.
343
- const secondaryByCluster = selectSecondaryMemberships(vectors, assignments, centroids, {
344
- delta: SECONDARY_DELTA,
345
- minSimilarity: SECONDARY_MIN_SIMILARITY,
346
- maxPerDoc: SECONDARY_MAX_PER_DOC,
347
- });
348
512
  const themes = [];
349
513
  let themeIndex = 0;
350
- for (const [clusterIdx, clusterDocs] of byCluster) {
351
- if (clusterDocs.length === 0)
514
+ // Iterate clusters in index order so labels/fallbacks are deterministic.
515
+ const clusterIds = [...membersByCluster.keys()].sort((a, b) => a - b);
516
+ for (const clusterId of clusterIds) {
517
+ const members = membersByCluster.get(clusterId) ?? [];
518
+ if (members.length === 0)
352
519
  continue;
353
- const centroid = centroids[clusterIdx];
354
- if (!centroid)
355
- continue;
356
- // Singleton clusters have no pairs to average return null rather
357
- // than the mathematically-trivial 1.0, which would falsely imply
358
- // tightness.
359
- const coherence = clusterDocs.length < 2 ? null : clusterCoherence(clusterDocs.map((d) => d.vector));
360
- const secondaries = (secondaryByCluster.get(clusterIdx) ?? [])
361
- .slice(0, SECONDARY_DOCS_PER_THEME)
362
- .map((s) => scoped[s.docIndex].path);
520
+ members.sort((a, b) => b.weight - a.weight ||
521
+ scoped[a.docIndex].path.localeCompare(scoped[b.docIndex].path));
522
+ const primaries = new Set(primaryByCluster.get(clusterId) ?? []);
523
+ // Label/tag signal comes from the docs that primarily live here when
524
+ // any do a theme should be named by its residents, not its visitors.
525
+ const primaryDocs = [...primaries].map((d) => scoped[d]);
526
+ const labelDocs = primaryDocs.length > 0 ? primaryDocs : members.map((m) => scoped[m.docIndex]);
527
+ const clusterChunks = chunksByCluster.get(clusterId) ?? [];
528
+ const coherenceChunks = clusterChunks.length > COHERENCE_SAMPLE_CAP
529
+ ? strideSample(clusterChunks.length, COHERENCE_SAMPLE_CAP).map((i) => clusterChunks[i])
530
+ : clusterChunks;
531
+ // A single-chunk theme has no pairs to average — null rather than the
532
+ // mathematically-trivial 1.0, which would falsely imply tightness.
533
+ const coherence = coherenceChunks.length < 2 ? null : clusterCoherence(coherenceChunks);
363
534
  themes.push({
364
- label: buildLabel(clusterDocs, scoped.length, globalDf, themeIndex),
365
- documentCount: clusterDocs.length,
535
+ id: clusterId,
536
+ label: buildLabel(labelDocs, scoped.length, globalDf, themeIndex),
537
+ documentCount: members.length,
538
+ primaryDocumentCount: primaries.size,
366
539
  coherence,
367
- representativeDocs: representativesForCluster(clusterDocs, centroid),
368
- secondaryDocs: secondaries,
369
- relatedTags: topTagsForCluster(clusterDocs),
540
+ // Residents only: a high-weight visitor ranks in `members` but must
541
+ // not appear as a representative AND a secondary of the same theme —
542
+ // the disjointness invariant v1 held and tests pin.
543
+ representativeDocs: members
544
+ .filter((m) => primaries.has(m.docIndex))
545
+ .slice(0, REPRESENTATIVE_DOCS_PER_THEME)
546
+ .map((m) => scoped[m.docIndex].path),
547
+ secondaryDocs: members
548
+ .filter((m) => !primaries.has(m.docIndex))
549
+ .slice(0, SECONDARY_DOCS_PER_THEME)
550
+ .map((m) => scoped[m.docIndex].path),
551
+ relatedTags: topTagsForCluster(labelDocs),
370
552
  });
371
553
  themeIndex += 1;
372
554
  }
373
555
  themes.sort((a, b) => b.documentCount - a.documentCount || a.label.localeCompare(b.label));
556
+ // Cross-cutting docs only: a single-theme doc's distribution is trivial,
557
+ // and listing every path would bloat the payload at vault scale.
558
+ const docMemberships = {};
559
+ for (let d = 0; d < distributions.length; d++) {
560
+ const memberships = distributions[d] ?? [];
561
+ if (memberships.length < 2)
562
+ continue;
563
+ docMemberships[scoped[d].path] = memberships.map((m) => ({
564
+ theme: m.cluster,
565
+ weight: m.weight,
566
+ }));
567
+ }
374
568
  return ok({
375
569
  themes,
570
+ docMemberships,
376
571
  totalDocuments: scoped.length,
572
+ totalChunks: chunkVectors.length,
377
573
  skippedDocuments: skipped,
378
574
  selectedK,
575
+ droppedClusters,
379
576
  clusteredAt: new Date().toISOString(),
380
577
  });
381
578
  }
@@ -391,15 +588,18 @@ export const themesTools = [
391
588
  name: "vault_themes",
392
589
  title: "Cluster vault themes",
393
590
  annotations: { readOnlyHint: true },
394
- description: "Surface thematic clusters across the vault using k-means over " +
395
- "document-pooled embeddings. Each document's chunk vectors are " +
396
- "mean-pooled into one vector, L2-normalised, and clustered. By " +
397
- "default the tool sweeps k {10, 15, 20, 25} and picks the k with " +
398
- "the best mean silhouette; pass `k` to skip the sweep. Each theme " +
399
- "reports a heuristic label (TF-IDF over titles + tags), a coherence " +
400
- "score (mean pairwise cosine inside the cluster), representative " +
401
- "documents nearest the centroid, and the most frequent tags. " +
402
- "Output is deterministic for the same vault.",
591
+ description: "Surface thematic clusters across the vault using k-means over CHUNK " +
592
+ "embeddings, with per-document theme distributions. A document whose " +
593
+ "chunks split across two clusters is a member of both themes " +
594
+ "(docMemberships reports its weights); documentCount is coverage (a " +
595
+ "two-theme doc counts in both) while primaryDocumentCount partitions " +
596
+ "docs by their dominant theme. By default the tool sweeps " +
597
+ "k {10, 15, 20, 25} and picks the k with the best (sampled) mean " +
598
+ "silhouette; pass `k` to skip the sweep. Each theme reports a " +
599
+ "heuristic label (TF-IDF over titles + tags), a coherence score (mean " +
600
+ "pairwise cosine among its chunks), representative documents ranked " +
601
+ "by membership weight, and the most frequent tags. Output is " +
602
+ "deterministic for the same vault.",
403
603
  inputSchema: {
404
604
  type: "object",
405
605
  properties: {