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.
- package/CHANGELOG.md +281 -0
- package/README.md +263 -9
- package/dist/access/rbac.d.ts +1 -0
- package/dist/access/rbac.d.ts.map +1 -1
- package/dist/access/rbac.js +7 -0
- package/dist/access/rbac.js.map +1 -1
- package/dist/asof/git-read.d.ts +17 -0
- package/dist/asof/git-read.d.ts.map +1 -0
- package/dist/asof/git-read.js +163 -0
- package/dist/asof/git-read.js.map +1 -0
- package/dist/asof/index.d.ts +2 -0
- package/dist/asof/index.d.ts.map +1 -0
- package/dist/asof/index.js +151 -0
- package/dist/asof/index.js.map +1 -0
- package/dist/asof/report.d.ts +11 -0
- package/dist/asof/report.d.ts.map +1 -0
- package/dist/asof/report.js +119 -0
- package/dist/asof/report.js.map +1 -0
- package/dist/asof/snapshot.d.ts +72 -0
- package/dist/asof/snapshot.d.ts.map +1 -0
- package/dist/asof/snapshot.js +207 -0
- package/dist/asof/snapshot.js.map +1 -0
- package/dist/audit/checks/broken_refs.d.ts +1 -1
- package/dist/audit/checks/broken_refs.d.ts.map +1 -1
- package/dist/audit/checks/broken_refs.js +77 -22
- package/dist/audit/checks/broken_refs.js.map +1 -1
- package/dist/audit/collect.d.ts +1 -0
- package/dist/audit/collect.d.ts.map +1 -1
- package/dist/audit/collect.js +22 -2
- package/dist/audit/collect.js.map +1 -1
- package/dist/audit/index.d.ts.map +1 -1
- package/dist/audit/index.js +9 -3
- package/dist/audit/index.js.map +1 -1
- package/dist/audit/links.d.ts.map +1 -1
- package/dist/audit/links.js +9 -3
- package/dist/audit/links.js.map +1 -1
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/report.js +2 -0
- package/dist/audit/report.js.map +1 -1
- package/dist/audit/types.d.ts +4 -1
- package/dist/audit/types.d.ts.map +1 -1
- package/dist/audit/types.js.map +1 -1
- package/dist/backfill/apply.js +2 -2
- package/dist/backfill/apply.js.map +1 -1
- package/dist/backfill/derive.d.ts.map +1 -1
- package/dist/backfill/derive.js +1 -0
- package/dist/backfill/derive.js.map +1 -1
- package/dist/backfill/plan.js +2 -2
- package/dist/backfill/plan.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +36 -0
- package/dist/cli.js.map +1 -1
- package/dist/consolidate/call-budget.d.ts +3 -0
- package/dist/consolidate/call-budget.d.ts.map +1 -0
- package/dist/consolidate/call-budget.js +37 -0
- package/dist/consolidate/call-budget.js.map +1 -0
- package/dist/consolidate/constants.d.ts +1 -0
- package/dist/consolidate/constants.d.ts.map +1 -1
- package/dist/consolidate/constants.js +5 -0
- package/dist/consolidate/constants.js.map +1 -1
- package/dist/consolidate/index.d.ts.map +1 -1
- package/dist/consolidate/index.js +96 -27
- package/dist/consolidate/index.js.map +1 -1
- package/dist/court/docket.d.ts +39 -0
- package/dist/court/docket.d.ts.map +1 -0
- package/dist/court/docket.js +103 -0
- package/dist/court/docket.js.map +1 -0
- package/dist/court/index.d.ts +2 -0
- package/dist/court/index.d.ts.map +1 -0
- package/dist/court/index.js +169 -0
- package/dist/court/index.js.map +1 -0
- package/dist/court/precedent.d.ts +18 -0
- package/dist/court/precedent.d.ts.map +1 -0
- package/dist/court/precedent.js +89 -0
- package/dist/court/precedent.js.map +1 -0
- package/dist/court/report.d.ts +10 -0
- package/dist/court/report.d.ts.map +1 -0
- package/dist/court/report.js +77 -0
- package/dist/court/report.js.map +1 -0
- package/dist/curation/consumes.d.ts +24 -0
- package/dist/curation/consumes.d.ts.map +1 -0
- package/dist/curation/consumes.js +122 -0
- package/dist/curation/consumes.js.map +1 -0
- package/dist/curation/edge-staleness.d.ts +47 -0
- package/dist/curation/edge-staleness.d.ts.map +1 -0
- package/dist/curation/edge-staleness.js +258 -0
- package/dist/curation/edge-staleness.js.map +1 -0
- package/dist/curation/edges.d.ts.map +1 -1
- package/dist/curation/edges.js +164 -10
- package/dist/curation/edges.js.map +1 -1
- package/dist/curation/lint.d.ts +4 -1
- package/dist/curation/lint.d.ts.map +1 -1
- package/dist/curation/lint.js +61 -6
- package/dist/curation/lint.js.map +1 -1
- package/dist/curation/provenance.d.ts +2 -0
- package/dist/curation/provenance.d.ts.map +1 -1
- package/dist/curation/provenance.js +2 -0
- package/dist/curation/provenance.js.map +1 -1
- package/dist/curation/read-log.d.ts +19 -0
- package/dist/curation/read-log.d.ts.map +1 -0
- package/dist/curation/read-log.js +120 -0
- package/dist/curation/read-log.js.map +1 -0
- package/dist/curation/review-throughput.d.ts +24 -0
- package/dist/curation/review-throughput.d.ts.map +1 -0
- package/dist/curation/review-throughput.js +102 -0
- package/dist/curation/review-throughput.js.map +1 -0
- package/dist/curation/staged-actions.d.ts +11 -1
- package/dist/curation/staged-actions.d.ts.map +1 -1
- package/dist/curation/staged-actions.js +117 -30
- package/dist/curation/staged-actions.js.map +1 -1
- package/dist/curation/staleness.js +1 -1
- package/dist/curation/staleness.js.map +1 -1
- package/dist/curation/structural.d.ts +16 -0
- package/dist/curation/structural.d.ts.map +1 -0
- package/dist/curation/structural.js +64 -0
- package/dist/curation/structural.js.map +1 -0
- package/dist/curation/tension-access.d.ts +7 -0
- package/dist/curation/tension-access.d.ts.map +1 -0
- package/dist/curation/tension-access.js +44 -0
- package/dist/curation/tension-access.js.map +1 -0
- package/dist/curation/tension-blast.d.ts +6 -1
- package/dist/curation/tension-blast.d.ts.map +1 -1
- package/dist/curation/tension-blast.js +11 -2
- package/dist/curation/tension-blast.js.map +1 -1
- package/dist/curation/tension-clusters.d.ts +1 -1
- package/dist/curation/tension-clusters.d.ts.map +1 -1
- package/dist/curation/tension-clusters.js +5 -2
- package/dist/curation/tension-clusters.js.map +1 -1
- package/dist/curation/tension.d.ts +6 -2
- package/dist/curation/tension.d.ts.map +1 -1
- package/dist/curation/tension.js +86 -35
- package/dist/curation/tension.js.map +1 -1
- package/dist/curation/tier0.d.ts +27 -0
- package/dist/curation/tier0.d.ts.map +1 -0
- package/dist/curation/tier0.js +196 -0
- package/dist/curation/tier0.js.map +1 -0
- package/dist/curation/tier1.d.ts +30 -0
- package/dist/curation/tier1.d.ts.map +1 -0
- package/dist/curation/tier1.js +142 -0
- package/dist/curation/tier1.js.map +1 -0
- package/dist/curation/tier2.d.ts +43 -0
- package/dist/curation/tier2.d.ts.map +1 -0
- package/dist/curation/tier2.js +182 -0
- package/dist/curation/tier2.js.map +1 -0
- package/dist/curation/vault-docs.d.ts +5 -1
- package/dist/curation/vault-docs.d.ts.map +1 -1
- package/dist/curation/vault-docs.js +15 -1
- package/dist/curation/vault-docs.js.map +1 -1
- package/dist/eval/generate.js +22 -5
- package/dist/eval/generate.js.map +1 -1
- package/dist/eval/index.d.ts.map +1 -1
- package/dist/eval/index.js +173 -25
- package/dist/eval/index.js.map +1 -1
- package/dist/eval/llm-openrouter.d.ts +9 -0
- package/dist/eval/llm-openrouter.d.ts.map +1 -0
- package/dist/eval/llm-openrouter.js +171 -0
- package/dist/eval/llm-openrouter.js.map +1 -0
- package/dist/eval/llm.js +1 -1
- package/dist/eval/llm.js.map +1 -1
- package/dist/eval/prune.d.ts +24 -0
- package/dist/eval/prune.d.ts.map +1 -0
- package/dist/eval/prune.js +83 -0
- package/dist/eval/prune.js.map +1 -0
- package/dist/eval/storage.d.ts +3 -0
- package/dist/eval/storage.d.ts.map +1 -1
- package/dist/eval/storage.js +14 -0
- package/dist/eval/storage.js.map +1 -1
- package/dist/frontmatter/parser.d.ts +1 -0
- package/dist/frontmatter/parser.d.ts.map +1 -1
- package/dist/frontmatter/parser.js +12 -0
- package/dist/frontmatter/parser.js.map +1 -1
- package/dist/frontmatter/schema.d.ts.map +1 -1
- package/dist/frontmatter/schema.js +38 -3
- package/dist/frontmatter/schema.js.map +1 -1
- package/dist/frontmatter/types.d.ts +4 -1
- package/dist/frontmatter/types.d.ts.map +1 -1
- package/dist/frontmatter/types.js +8 -0
- package/dist/frontmatter/types.js.map +1 -1
- package/dist/import/index.d.ts.map +1 -1
- package/dist/import/index.js +44 -9
- package/dist/import/index.js.map +1 -1
- package/dist/import/langgraph-store.d.ts +53 -0
- package/dist/import/langgraph-store.d.ts.map +1 -0
- package/dist/import/langgraph-store.js +292 -0
- package/dist/import/langgraph-store.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +56 -18
- package/dist/index.js.map +1 -1
- package/dist/lifecycle/lock.d.ts +9 -1
- package/dist/lifecycle/lock.d.ts.map +1 -1
- package/dist/lifecycle/lock.js +34 -9
- package/dist/lifecycle/lock.js.map +1 -1
- package/dist/okf/export.d.ts +12 -0
- package/dist/okf/export.d.ts.map +1 -0
- package/dist/okf/export.js +115 -0
- package/dist/okf/export.js.map +1 -0
- package/dist/okf/import.d.ts +24 -0
- package/dist/okf/import.d.ts.map +1 -0
- package/dist/okf/import.js +107 -0
- package/dist/okf/import.js.map +1 -0
- package/dist/okf/index.d.ts +2 -0
- package/dist/okf/index.d.ts.map +1 -0
- package/dist/okf/index.js +147 -0
- package/dist/okf/index.js.map +1 -0
- package/dist/okf/map.d.ts +16 -0
- package/dist/okf/map.d.ts.map +1 -0
- package/dist/okf/map.js +149 -0
- package/dist/okf/map.js.map +1 -0
- package/dist/okf/types.d.ts +9 -0
- package/dist/okf/types.d.ts.map +1 -0
- package/dist/okf/types.js +48 -0
- package/dist/okf/types.js.map +1 -0
- package/dist/search/contested.d.ts +18 -0
- package/dist/search/contested.d.ts.map +1 -0
- package/dist/search/contested.js +119 -0
- package/dist/search/contested.js.map +1 -0
- package/dist/search/coverage.d.ts.map +1 -1
- package/dist/search/coverage.js +1 -1
- package/dist/search/coverage.js.map +1 -1
- package/dist/search/hybrid.d.ts +23 -0
- package/dist/search/hybrid.d.ts.map +1 -1
- package/dist/search/hybrid.js +75 -15
- package/dist/search/hybrid.js.map +1 -1
- package/dist/search/reindex.d.ts +1 -0
- package/dist/search/reindex.d.ts.map +1 -1
- package/dist/search/reindex.js +57 -24
- package/dist/search/reindex.js.map +1 -1
- package/dist/search/watcher.d.ts +2 -0
- package/dist/search/watcher.d.ts.map +1 -1
- package/dist/search/watcher.js +23 -12
- package/dist/search/watcher.js.map +1 -1
- package/dist/serve/index.d.ts +36 -0
- package/dist/serve/index.d.ts.map +1 -0
- package/dist/serve/index.js +507 -0
- package/dist/serve/index.js.map +1 -0
- package/dist/server.d.ts +10 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +91 -12
- package/dist/server.js.map +1 -1
- package/dist/sleep/cycle.d.ts +59 -0
- package/dist/sleep/cycle.d.ts.map +1 -0
- package/dist/sleep/cycle.js +127 -0
- package/dist/sleep/cycle.js.map +1 -0
- package/dist/sleep/index.d.ts +5 -0
- package/dist/sleep/index.d.ts.map +1 -0
- package/dist/sleep/index.js +320 -0
- package/dist/sleep/index.js.map +1 -0
- package/dist/sleep/report.d.ts +14 -0
- package/dist/sleep/report.d.ts.map +1 -0
- package/dist/sleep/report.js +139 -0
- package/dist/sleep/report.js.map +1 -0
- package/dist/sleep/tension-scan-state.d.ts +19 -0
- package/dist/sleep/tension-scan-state.d.ts.map +1 -0
- package/dist/sleep/tension-scan-state.js +0 -0
- package/dist/sleep/tension-scan-state.js.map +1 -0
- package/dist/sleep/tension-scan.d.ts +44 -0
- package/dist/sleep/tension-scan.d.ts.map +1 -0
- package/dist/sleep/tension-scan.js +0 -0
- package/dist/sleep/tension-scan.js.map +1 -0
- package/dist/storage/backend.d.ts +12 -0
- package/dist/storage/backend.d.ts.map +1 -0
- package/dist/storage/backend.js +231 -0
- package/dist/storage/backend.js.map +1 -0
- package/dist/storage/backends/fs.d.ts +4 -0
- package/dist/storage/backends/fs.d.ts.map +1 -0
- package/dist/storage/backends/fs.js +99 -0
- package/dist/storage/backends/fs.js.map +1 -0
- package/dist/storage/fs-walk.d.ts +5 -0
- package/dist/storage/fs-walk.d.ts.map +1 -0
- package/dist/storage/fs-walk.js +28 -0
- package/dist/storage/fs-walk.js.map +1 -0
- package/dist/storage/index-db.d.ts +18 -0
- package/dist/storage/index-db.d.ts.map +1 -1
- package/dist/storage/index-db.js +159 -22
- package/dist/storage/index-db.js.map +1 -1
- package/dist/storage/local.d.ts +7 -1
- package/dist/storage/local.d.ts.map +1 -1
- package/dist/storage/local.js +42 -4
- package/dist/storage/local.js.map +1 -1
- package/dist/storage/sync.d.ts +25 -0
- package/dist/storage/sync.d.ts.map +1 -0
- package/dist/storage/sync.js +308 -0
- package/dist/storage/sync.js.map +1 -0
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +136 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/themes/clustering.d.ts +6 -11
- package/dist/themes/clustering.d.ts.map +1 -1
- package/dist/themes/clustering.js +76 -84
- package/dist/themes/clustering.js.map +1 -1
- package/dist/tools/consumes.d.ts +14 -0
- package/dist/tools/consumes.d.ts.map +1 -0
- package/dist/tools/consumes.js +99 -0
- package/dist/tools/consumes.js.map +1 -0
- package/dist/tools/curation.d.ts +2 -0
- package/dist/tools/curation.d.ts.map +1 -1
- package/dist/tools/curation.js +102 -7
- package/dist/tools/curation.js.map +1 -1
- package/dist/tools/edge-staleness.d.ts +28 -0
- package/dist/tools/edge-staleness.d.ts.map +1 -0
- package/dist/tools/edge-staleness.js +205 -0
- package/dist/tools/edge-staleness.js.map +1 -0
- package/dist/tools/edges.d.ts.map +1 -1
- package/dist/tools/edges.js +28 -22
- package/dist/tools/edges.js.map +1 -1
- package/dist/tools/read.d.ts +15 -1
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js +144 -31
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/receipt.d.ts +65 -0
- package/dist/tools/receipt.d.ts.map +1 -0
- package/dist/tools/receipt.js +257 -0
- package/dist/tools/receipt.js.map +1 -0
- package/dist/tools/search.d.ts +1 -0
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +186 -12
- package/dist/tools/search.js.map +1 -1
- package/dist/tools/staged-actions.d.ts +3 -0
- package/dist/tools/staged-actions.d.ts.map +1 -1
- package/dist/tools/staged-actions.js +228 -23
- package/dist/tools/staged-actions.js.map +1 -1
- package/dist/tools/themes.d.ts +9 -0
- package/dist/tools/themes.d.ts.map +1 -1
- package/dist/tools/themes.js +333 -133
- package/dist/tools/themes.js.map +1 -1
- package/dist/tools/tier1.d.ts +14 -0
- package/dist/tools/tier1.d.ts.map +1 -0
- package/dist/tools/tier1.js +169 -0
- package/dist/tools/tier1.js.map +1 -0
- package/dist/tools/tier2.d.ts +27 -0
- package/dist/tools/tier2.d.ts.map +1 -0
- package/dist/tools/tier2.js +344 -0
- package/dist/tools/tier2.js.map +1 -0
- package/dist/tools/witness.d.ts +9 -0
- package/dist/tools/witness.d.ts.map +1 -0
- package/dist/tools/witness.js +66 -0
- package/dist/tools/witness.js.map +1 -0
- package/dist/tools/write.d.ts +9 -1
- package/dist/tools/write.d.ts.map +1 -1
- package/dist/tools/write.js +593 -59
- package/dist/tools/write.js.map +1 -1
- package/dist/utils/config.d.ts +54 -0
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +420 -4
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/paths.d.ts +2 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +15 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/redos.d.ts +2 -0
- package/dist/utils/redos.d.ts.map +1 -0
- package/dist/utils/redos.js +101 -0
- package/dist/utils/redos.js.map +1 -0
- package/dist/utils/run-id.d.ts +3 -0
- package/dist/utils/run-id.d.ts.map +1 -0
- package/dist/utils/run-id.js +19 -0
- package/dist/utils/run-id.js.map +1 -0
- package/dist/utils/vault-gitignore.d.ts +1 -1
- package/dist/utils/vault-gitignore.d.ts.map +1 -1
- package/dist/utils/vault-gitignore.js +6 -1
- package/dist/utils/vault-gitignore.js.map +1 -1
- package/dist/witness/track-record.d.ts +42 -0
- package/dist/witness/track-record.d.ts.map +1 -0
- package/dist/witness/track-record.js +189 -0
- package/dist/witness/track-record.js.map +1 -0
- package/package.json +27 -2
- package/templates/config.yaml +8 -0
package/dist/tools/themes.js
CHANGED
|
@@ -1,33 +1,42 @@
|
|
|
1
|
-
// vault_themes — thematic clustering over
|
|
1
|
+
// vault_themes — thematic clustering over chunk embeddings with per-document
|
|
2
|
+
// theme distributions (#58).
|
|
2
3
|
//
|
|
3
|
-
// Cluster
|
|
4
|
-
// embeddings into one vector,
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
// -
|
|
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
|
-
//
|
|
16
|
-
//
|
|
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,
|
|
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
|
|
37
|
+
// number of clusterable chunks inside pickK.
|
|
29
38
|
const DEFAULT_K_CANDIDATES = [10, 15, 20, 25];
|
|
30
|
-
// k-means iteration cap.
|
|
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
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
// the
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 (
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
328
|
-
const
|
|
329
|
-
|
|
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
|
|
332
|
-
const list = byCluster.get(c);
|
|
495
|
+
const list = chunksByCluster.get(c);
|
|
333
496
|
if (list)
|
|
334
|
-
list.push(
|
|
497
|
+
list.push(chunkVectors[i]);
|
|
335
498
|
else
|
|
336
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
.map((
|
|
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
|
-
|
|
365
|
-
|
|
535
|
+
id: clusterId,
|
|
536
|
+
label: buildLabel(labelDocs, scoped.length, globalDf, themeIndex),
|
|
537
|
+
documentCount: members.length,
|
|
538
|
+
primaryDocumentCount: primaries.size,
|
|
366
539
|
coherence,
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
396
|
-
"
|
|
397
|
-
"
|
|
398
|
-
"
|
|
399
|
-
"
|
|
400
|
-
"
|
|
401
|
-
"
|
|
402
|
-
"
|
|
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: {
|