diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 822a6bf..a139ddf 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -14,10 +14,10 @@ * per-camera-event full-canvas repaints (GPU spikes + coil whine) are * gone (at most one draw per frame, and NO frame at all while idle). * - * Layout (graphology + forceAtlas2) runs-then-freezes exactly as - * before: ONE synchronous 100-iteration pass inside a useEffect writes - * world-space x/y, then the positions are handed to the renderer as - * data. No layout animation ever starts; the only animation is the + * Layout fallback (ledger absent): ONE synchronous incrementalLayout + * pass (d3-force, core/layout.ts) inside a useEffect writes world-space + * x/y, then the positions are handed to the renderer as data. No layout + * animation ever starts; the only animation is the * short hover fade, strictly bounded in time — idle CPU is zero (the * loop schedules no rAF until something actually changes). * diff --git a/config.ts b/config.ts index 8985557..8162991 100644 --- a/config.ts +++ b/config.ts @@ -89,6 +89,12 @@ export const SOURCES_FILE = path.join(PATHS.graph, "sources.json"); * Carries an updatedAt stamp for future live-physics conflict handling. */ export const POSITIONS_FILE = path.join(PATHS.graph, "positions.json"); +/** The embedding ledger: one L2-normalized vector per node id, all from + * ONE model (swapping models re-embeds everything — vectors from + * different models live in incompatible spaces). Read-modify-write + * JSON like positions/sources. */ +export const EMBEDDINGS_FILE = path.join(PATHS.graph, "embeddings.json"); + /** * Graph render settings (the settings-panel controls): one small JSON blob, * read-modify-write. Shared shape/sanitizer: src/core/render-settings.ts. diff --git a/next.config.ts b/next.config.ts index 5d5d02e..c3236a4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,7 +4,13 @@ const nextConfig: NextConfig = { // Framework-free core lives in src/ (plain TS, imported by routes and CLI alike). // The pi engine must stay external: its providers are runtime-loaded Node // code (undici streams, catalog data) that breaks when bundled into routes. - serverExternalPackages: ["@earendil-works/pi-ai", "@earendil-works/pi-agent-core"], + // transformers.js (embeddings) is runtime-loaded ONNX — same reason as + // the pi engine: bundling onnxruntime-node breaks route builds. + serverExternalPackages: [ + "@earendil-works/pi-ai", + "@earendil-works/pi-agent-core", + "@huggingface/transformers", + ], }; export default nextConfig; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 862e963..d6c41f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@ai-sdk/react": "^4.0.79", "@earendil-works/pi-agent-core": "^0.85.1", "@earendil-works/pi-ai": "^0.85.1", + "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@radix-ui/react-use-controllable-state": "^1.2.6", "@rive-app/react-webgl2": "^4.32.1", @@ -28,9 +29,6 @@ "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "embla-carousel-react": "^8.6.0", - "graphology": "^0.26.0", - "graphology-communities-louvain": "^2.0.2", - "graphology-layout-forceatlas2": "^0.10.1", "katex": "^0.16.47", "linkedom": "^0.18.13", "lucide-react": "^1.33.0", @@ -1913,6 +1911,582 @@ } } }, + "node_modules/@huggingface/jinja": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.10.tgz", + "integrity": "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/tokenizers": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", + "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", + "license": "Apache-2.0" + }, + "node_modules/@huggingface/transformers": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz", + "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.6", + "@huggingface/tokenizers": "^0.1.3", + "onnxruntime-node": "1.24.3", + "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", + "sharp": "^0.34.5" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@huggingface/transformers/node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2001,7 +2575,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -5965,6 +6538,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -6294,6 +6876,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -7343,7 +7932,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -7361,7 +7949,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -7397,12 +7984,17 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -7714,7 +8306,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7724,7 +8315,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7805,6 +8395,12 @@ "tests/browser-compat" ] }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -8304,15 +8900,6 @@ "node": ">=0.10.0" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/eventsource-parser": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", @@ -8484,6 +9071,12 @@ "node": ">=16" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, "node_modules/flatted": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", @@ -8752,6 +9345,23 @@ "node": ">=10.13.0" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -8769,7 +9379,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -8812,7 +9421,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8828,73 +9436,11 @@ "dev": true, "license": "ISC" }, - "node_modules/graphology": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", - "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", - "license": "MIT", - "dependencies": { - "events": "^3.3.0" - }, - "peerDependencies": { - "graphology-types": ">=0.24.0" - } - }, - "node_modules/graphology-communities-louvain": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz", - "integrity": "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==", - "license": "MIT", - "dependencies": { - "graphology-indices": "^0.17.0", - "graphology-utils": "^2.4.4", - "mnemonist": "^0.39.0", - "pandemonium": "^2.4.1" - }, - "peerDependencies": { - "graphology-types": ">=0.19.0" - } - }, - "node_modules/graphology-indices": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz", - "integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==", - "license": "MIT", - "dependencies": { - "graphology-utils": "^2.4.2", - "mnemonist": "^0.39.0" - }, - "peerDependencies": { - "graphology-types": ">=0.20.0" - } - }, - "node_modules/graphology-layout-forceatlas2": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/graphology-layout-forceatlas2/-/graphology-layout-forceatlas2-0.10.1.tgz", - "integrity": "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==", - "license": "MIT", - "dependencies": { - "graphology-utils": "^2.1.0" - }, - "peerDependencies": { - "graphology-types": ">=0.19.0" - } - }, - "node_modules/graphology-types": { - "version": "0.24.8", - "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", - "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", - "license": "MIT", - "peer": true - }, - "node_modules/graphology-utils": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", - "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", - "license": "MIT", - "peerDependencies": { - "graphology-types": ">=0.23.0" - } + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" }, "node_modules/hachure-fill": { "version": "0.5.2", @@ -8929,7 +9475,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -10055,6 +10600,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", @@ -10520,6 +11071,30 @@ "node": ">= 20" } }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -11626,15 +12201,6 @@ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, - "node_modules/mnemonist": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", - "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", - "license": "MIT", - "dependencies": { - "obliterator": "^2.0.1" - } - }, "node_modules/modern-tar": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.4.tgz", @@ -11937,7 +12503,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12033,12 +12598,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obliterator": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", - "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", - "license": "MIT" - }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -12056,6 +12615,49 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/onnxruntime-common": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", + "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", + "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.24.3" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.26.0-dev.20260416-b7804b056c", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz", + "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.24.0-dev.20251116-b39e144322", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", + "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", + "license": "MIT" + }, "node_modules/openai": { "version": "6.40.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", @@ -12162,15 +12764,6 @@ "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", "license": "MIT" }, - "node_modules/pandemonium": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz", - "integrity": "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==", - "license": "MIT", - "dependencies": { - "mnemonist": "^0.39.2" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -12288,6 +12881,12 @@ "node": ">=16.20.0" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -12998,6 +13597,23 @@ "node": ">=0.10.0" } }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -13137,7 +13753,6 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -13146,6 +13761,27 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -13363,6 +13999,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, "node_modules/stable-hash-x": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", @@ -13894,6 +14536,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typebox": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", diff --git a/package.json b/package.json index 1e1f895..6969412 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@ai-sdk/react": "^4.0.79", "@earendil-works/pi-agent-core": "^0.85.1", "@earendil-works/pi-ai": "^0.85.1", + "@huggingface/transformers": "^4.2.0", "@mozilla/readability": "^0.6.0", "@radix-ui/react-use-controllable-state": "^1.2.6", "@rive-app/react-webgl2": "^4.32.1", @@ -33,9 +34,6 @@ "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "embla-carousel-react": "^8.6.0", - "graphology": "^0.26.0", - "graphology-communities-louvain": "^2.0.2", - "graphology-layout-forceatlas2": "^0.10.1", "katex": "^0.16.47", "linkedom": "^0.18.13", "lucide-react": "^1.33.0", diff --git a/scripts/backfill-graph.ts b/scripts/backfill-graph.ts index 194addc..9eefef0 100644 --- a/scripts/backfill-graph.ts +++ b/scripts/backfill-graph.ts @@ -8,13 +8,14 @@ import { chatStore } from "../src/store/chats.ts"; import { graphStore } from "../src/store/graph.ts"; import { createExtractionPipeline } from "../src/agent/subagents/extraction.ts"; +import { embedNewNodes } from "../src/server/embeddings.ts"; import { getGraph } from "../src/core/graph.ts"; async function main() { const only = process.argv[2]; // optional: extract just one chat id const chats = only ? chatStore.listChats().filter((c) => c.id === only) : chatStore.listChats(); console.log(`[backfill] ${chats.length} chats to extract`); - const pipeline = createExtractionPipeline({ chatStore, graphStore }); + const pipeline = createExtractionPipeline({ chatStore, graphStore, embedder: embedNewNodes }); for (const chat of chats) { const t0 = Date.now(); try { diff --git a/scripts/bench-graph.ts b/scripts/bench-graph.ts deleted file mode 100644 index a3a2d12..0000000 --- a/scripts/bench-graph.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Layout benchmark (W4; vision §1 "Rendering (DECIDED)" — "Pre-implementation - * validation: 2k-node synthetic benchmark to confirm layout time"). - * - * npx tsx scripts/bench-graph.ts - * - * Two scales: the vision's target regime (hundreds of nodes, <150 ms budget - * with a synchronous run-then-freeze pass) and the stress case (2,000 nodes, - * ~4,000 edges), the latter timed with and without barnesHutOptimize. - * - * VERDICT (2026-08-22, Apple silicon, Node 24): hundreds of nodes are - * comfortably inside the budget (see the measured lines — ~25 ms at 100, - * ~90 ms at 300). At 2k nodes the naive O(n²) pass takes seconds, and - * barnesHutOptimize cuts it roughly in half — still over budget, which is - * exactly the vision's escalation ladder: Barnes-Hut is already switched on - * automatically above ~500 nodes in components/graph-view.tsx, and the - * bundled Web Worker / server-cached positions stay available as later - * rungs if real graphs ever get there. v1 learner graphs are dozens of - * nodes; the stress case is preparation, not expectation. - */ - -import Graph from "graphology"; -import forceAtlas2 from "graphology-layout-forceatlas2"; - -const ITERATIONS = 100; - -/** A plausible learning graph: mostly-local chains plus long-range cross-links. */ -function synthesize(nodeCount: number, edgeCount: number): Graph { - const graph = new Graph(); - for (let i = 0; i < nodeCount; i++) { - graph.addNode(`n${i}`, { - label: `node ${i}`, - size: 6, - x: Math.random() * 100, - y: Math.random() * 100, - }); - } - let added = 0; - for (let i = 0; added < edgeCount && i < edgeCount * 3; i++) { - const from = Math.floor(Math.random() * nodeCount); - const span = Math.random() < 0.8 ? 1 + Math.floor(Math.random() * 10) : Math.floor(Math.random() * nodeCount); - const to = (from + span) % nodeCount; - if (from === to || graph.hasEdge(`n${from}`, `n${to}`)) continue; - graph.addEdge(`n${from}`, `n${to}`, { type: "arrow", size: 1 }); - added++; - } - return graph; -} - -function bench(label: string, nodeCount: number, edgeCount: number, settings: Record): void { - const graph = synthesize(nodeCount, edgeCount); - const start = performance.now(); - forceAtlas2.assign(graph, { - iterations: ITERATIONS, - settings: { ...forceAtlas2.inferSettings(graph), ...settings }, - }); - const elapsed = performance.now() - start; - console.log( - `${label}: ${elapsed.toFixed(1)} ms (${graph.order} nodes, ${graph.size} edges, ${ITERATIONS} iterations)`, - ); -} - -console.log(`Target regime — synchronous run-then-freeze (budget: <150 ms):\n`); -bench(" 100 nodes, ~200 edges", 100, 200, {}); -bench(" 300 nodes, ~600 edges", 300, 600, {}); -bench(" 500 nodes, ~1000 edges", 500, 1000, {}); -console.log(`\nStress case — 2k nodes (~4000 edges), the vision's escalation ladder:\n`); -bench(" naive repulsion (O(n²)) ", 2000, 4000, {}); -bench(" barnesHutOptimize ", 2000, 4000, { barnesHutOptimize: true }); -bench(" barnesHutOptimize (warmed JIT)", 2000, 4000, { barnesHutOptimize: true }); diff --git a/scripts/embed-graph.ts b/scripts/embed-graph.ts new file mode 100644 index 0000000..04514a0 --- /dev/null +++ b/scripts/embed-graph.ts @@ -0,0 +1,23 @@ +/** + * Embed the whole graph (backfill / cold start): one-time pass that + * fills the embedding ledger for every node — the same embedNewNodes the + * post-merge hook runs, just with an empty (or stale-model) ledger so + * every node is "new". First run downloads the model weights (~300MB) + * to the local HF cache; subsequent runs embed only what's missing. + * + * npx tsx scripts/embed-graph.ts + */ +import { graphStore } from "../src/store/graph.ts"; +import { embedNewNodes, EMBEDDING_MODEL, EMBEDDING_DIM } from "../src/server/embeddings.ts"; + +async function main() { + const t0 = Date.now(); + console.log(`[embed] model: ${EMBEDDING_MODEL} (dim ${EMBEDDING_DIM}, q8)`); + const { embedded, total } = await embedNewNodes(graphStore); + console.log(`[embed] embedded ${embedded} nodes → ledger holds ${total} vectors (${((Date.now() - t0) / 1000).toFixed(1)}s)`); +} + +main().catch((err) => { + console.error("[embed] failed:", err); + process.exit(1); +}); diff --git a/scripts/import-notes.ts b/scripts/import-notes.ts index 11cf144..f1c7acf 100644 --- a/scripts/import-notes.ts +++ b/scripts/import-notes.ts @@ -15,6 +15,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { createExtractionPipeline } from "../src/agent/subagents/extraction.ts"; +import { embedNewNodes } from "../src/server/embeddings.ts"; import { clusterNamingSettled } from "../src/agent/subagents/cluster-namer.ts"; import { getGraph } from "../src/core/graph.ts"; import { stores } from "../src/server/stores.ts"; @@ -28,7 +29,7 @@ async function main() { .sort(); console.log(`[import-notes] ${files.length} note files from ${dir} (concurrency ${limit})`); - const pipeline = createExtractionPipeline(stores); + const pipeline = createExtractionPipeline({ ...stores, embedder: embedNewNodes }); const queue = [...files]; const totals = { nodes: 0, edges: 0, events: 0, failed: 0 }; const workers = Array.from({ length: Math.min(limit, files.length) }, async () => { diff --git a/scripts/replay-layout.ts b/scripts/replay-layout.ts index 0de4d64..ef71041 100644 --- a/scripts/replay-layout.ts +++ b/scripts/replay-layout.ts @@ -13,7 +13,6 @@ * playground mode baked into the rest state); omit it for the free layout. */ import { graphStore } from "../src/store/graph.ts"; -import { detectCommunities } from "../src/core/clusters.ts"; // --depth=vertical|radial: bake the playground depth mode into the ledger. const depthArg = process.argv.find((a) => a.startsWith("--depth=")); const depthMode: "off" | "vertical" | "radial" = @@ -33,6 +32,12 @@ function main() { nodes.push(line); } const edges = graphStore.readEdges(); + // The semantic-topic source: the embedding ledger (run + // scripts/embed-graph.ts first if missing/stale). + const embeddings = graphStore.readEmbeddings(); + if (Object.keys(embeddings.vectors).length === 0) { + console.warn("[replay] embedding ledger is empty — clusters will be absent (run scripts/embed-graph.ts)"); + } // Batches: each imported file, in real import order (sources.json // importedAt). A node belongs to the file whose mapping first claims it @@ -79,8 +84,12 @@ function main() { const before = new Map(positions); const batchNodes = nodes.filter((n) => delivered.has(n.id)); positions = incrementalLayout(batchNodes, activeEdges, positions, { - // The same partition the real settle would see for this batch. - clusters: detectCommunities(batchNodes, activeEdges).communities, + // The same partition the real settle would see for this batch: + // the persisted semantic topics, filtered to delivered nodes + // (never recomputed — the agglomerative pass is ~1.5s at scale). + clusters: embeddings.clusters + .map((c) => ({ members: c.members.filter((id) => delivered.has(id)) })) + .filter((c) => c.members.length > 0), ...(depthMode === "off" ? {} : { diff --git a/src/agent/subagents/cluster-namer.ts b/src/agent/subagents/cluster-namer.ts index 4c21f5c..ba4ef8c 100644 --- a/src/agent/subagents/cluster-namer.ts +++ b/src/agent/subagents/cluster-namer.ts @@ -21,12 +21,7 @@ import type { PiEngine } from "../models.ts"; import { runSubagent } from "./pi-run.ts"; import type { GraphNode } from "../../core/contracts.ts"; import { getGraph, clusterContentId } from "../../core/graph.ts"; -import { - detectCommunities, - matchClusterLabelings, - MIN_CLUSTER_SIZE, - type Community, -} from "../../core/clusters.ts"; +import { matchClusterLabelings, MIN_CLUSTER_SIZE, type Community } from "../../core/clusters.ts"; import type { GraphStore } from "../../core/ports.ts"; /** How many member propositions the namer sees per cluster — enough to @@ -138,14 +133,17 @@ async function doRefresh(deps: { signal?: AbortSignal; }): Promise { const { graphStore, engine, signal } = deps; - const { nodes, edges } = getGraph(graphStore); - const detection = detectCommunities(nodes, edges); + const { nodes } = getGraph(graphStore); + // Semantic topics: the partition the embedder persisted in the + // embedding ledger (same source the snapshot renders — never + // recomputed here; the agglomerative pass is ~1.5s at scale). + const communities = graphStore.readEmbeddings().clusters; const labelings = graphStore.readClusterLabels(); - const claims = matchClusterLabelings(detection.communities, labelings); + const claims = matchClusterLabelings(communities, labelings); const byId = new Map(nodes.map((n) => [n.id, n])); const claimed = claims.filter((c) => c !== null).length; - const unnamed = detection.communities + const unnamed = communities .filter((c, i) => !claims[i] && c.members.length >= MIN_CLUSTER_SIZE) .map((c: Community, i) => { const nodesFor = c.members @@ -156,7 +154,7 @@ async function doRefresh(deps: { return { key: `c${i}`, members: c.members, nodes: nodesFor }; }); - if (unnamed.length === 0) return { detected: detection.communities.length, claimed, named: 0, appended: 0 }; + if (unnamed.length === 0) return { detected: communities.length, claimed, named: 0, appended: 0 }; const labels = await nameClusters(engine, signal, unnamed); let appended = 0; @@ -174,7 +172,7 @@ async function doRefresh(deps: { }); appended++; } - return { detected: detection.communities.length, claimed, named: unnamed.length, appended }; + return { detected: communities.length, claimed, named: unnamed.length, appended }; } let namingTail: Promise = Promise.resolve(); diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index f0e2030..67157f6 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -16,6 +16,7 @@ import type { SessionFindings, } from "../../core/contracts.ts"; import { getGraph, mergeFindings, type EmissionStats, type GraphDeps } from "../../core/graph.ts"; +import type { GraphStore } from "../../core/ports.ts"; import { settleGraphLayout } from "../../core/layout-settle.ts"; import { refreshClusterLabels } from "./cluster-namer.ts"; @@ -554,7 +555,14 @@ export interface ExtractionModels { labeler?: PiEngine; } -export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels {} +export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels { + /** The post-merge embedding batch (semantic clusters run on it). + * INJECTED by the composition root (the agent layer never imports + * the server layer — architecture law): production passes the real + * local-model embedder (server/embeddings.ts embedNewNodes), tests + * fake it. Failure inside it is non-fatal by contract. */ + embedder: (graphStore: GraphStore) => Promise; +} export interface NotesImportInput { /** The source's file path — its identity (deterministic src id) AND its @@ -576,7 +584,7 @@ export interface ExtractionPipeline { } export function createExtractionPipeline(deps: ExtractionPipelineDeps): ExtractionPipeline { - const { chatStore, graphStore } = deps; + const { chatStore, graphStore, embedder: embed } = deps; const inFlight = new Set(); function setPhase(chatId: string, phase: ExtractionPhase, kind: "chat" | "notes", label?: string): void { @@ -715,6 +723,13 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti messages: record.messages as UIMessage[], foldedAt: record.updatedAt, }); + // Semantic embeddings (DECIDED 2026-09-13): topics come from + // MEANING, so embed the new propositions BEFORE the settle and + // the namer look at clusters. Failure is non-fatal — clustering + // runs without the new nodes until the next merge retries. + await embed(graphStore).catch((err) => { + console.error(`[extraction ${chatId}] embedding failed (non-fatal):`, err); + }); // Option A: the layout ledger evolves with the merge (sync — the // next snapshot fetch must already reflect the new nodes). settleGraphLayout(graphStore); @@ -828,6 +843,11 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti // (messages: null) — nothing to attribute, nothing to build. const auditResult: AuditResult = { ...notesResult, attribution: { quiz: {} } }; const stats = mergeFindings(id, auditResult, { chatStore, graphStore }, { messages: null, foldedAt: input.importedAt }); + // Same post-merge chain as the chat path: embeddings first + // (topics from meaning), then the settle, then the namer. + await embed(graphStore).catch((err) => { + console.error(`[extraction ${id}] embedding failed (non-fatal):`, err); + }); // Option A: same post-merge settle as the chat path. settleGraphLayout(graphStore); persistRunUsage(id, Math.round(performance.now() - t0), stats); diff --git a/src/core/clusters.ts b/src/core/clusters.ts index a762a73..7bc17b2 100644 --- a/src/core/clusters.ts +++ b/src/core/clusters.ts @@ -1,31 +1,19 @@ /** - * COMMUNITY DETECTION for the knowledge graph (vision §1, DECIDED - * 2026-09-13): Louvain modularity over the undirected, unweighted - * follows+supports structure — purely structural, so it never depends on - * layout, and the extraction pipeline never knows clusters exist. + * CLUSTER LABEL MATCHING (vision §1). DETECTION moved to semantic topic + * clustering over embeddings (core/semantic-clusters.ts, DECIDED + * 2026-09-13) — Louvain over the edge structure is retired: it only ever + * found the reasoning chains (the graph is disconnected by nature), and + * edge-orphans could never join anything. What REMAINS here is the + * cluster contract + core-survival label matching, which is + * detection-agnostic: it consumes the same { members } shape semantic + * clustering emits. * - * Determinism: Louvain's random-walk traversal is driven by a SEEDED - * mulberry32 RNG (default seed constant below), so the same graph always - * yields the same partition. Community INDICES are still not stable - * across graph edits — which is why labelings (Phase B) key to sorted - * MEMBER SETS, never to indices. + * Labelings key to sorted MEMBER SETS, never to detection indices — + * a labeling record outlives any reshuffle of the partition. * - * Undirected + unweighted (user-decided 2026-09-13): both edge types are - * equally real relations between propositions — direction is - * derivational-vs-grounding, not stronger-vs-weaker — and a follows AND - * a supports between the same pair collapse to one edge. - * - * This module is browser-safe pure logic (no node builtins, no fs): the - * server runs it for the snapshot payload, the renderer for nothing - * (clusters arrive precomputed), and tests run it directly. - * - * NOTE: no minimum-size filter here — detection returns every community, - * singletons included. The ≥3-node render threshold (decided) is a - * PRESENTATION rule; it lives in the payload derivation and the - * renderer, not in the algorithm. + * Browser-safe pure logic (no node builtins, no fs): the server runs + * matching for the snapshot payload, tests run it directly. */ -import Graph from "graphology"; -import louvain from "graphology-communities-louvain"; import type { ClusterLabeling } from "./contracts.ts"; /** A detected community. Members are SORTED node ids — the member set IS @@ -39,90 +27,6 @@ export interface Community { * least three propositions to be worth a hull and a label. */ export const MIN_CLUSTER_SIZE = 3; -export interface CommunityDetection { - communities: Community[]; - /** Modularity of the found partition — the one quality number. */ - modularity: number; -} - -export interface DetectOptions { - /** Louvain resolution (higher → more, smaller communities). Default 1. - * Parked for the multi-scale work; exposed now so that dial has a home. */ - resolution?: number; - /** RNG seed. Default "lh-clusters". Same seed + same graph → same - * partition, always. */ - seed?: string; -} - -/** mulberry32 seeded by a string (FNV-1a) — the same recipe as - * question-core's seededRandom, inlined so this module has no - * cross-domain dependency. */ -function seededRng(seed: string): () => number { - let h = 2166136261; - for (let i = 0; i < seed.length; i++) { - h ^= seed.charCodeAt(i); - h = Math.imul(h, 16777619); - } - let a = h >>> 0; - return () => { - a |= 0; - a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** - * Detect communities over a graph's structure. Inputs are the minimal - * structural shapes of the snapshot contracts (GraphNode.id, - * GraphEdge.from/to), so both the real snapshot and test fixtures feed it. - * - * Edges referencing unknown node ids are skipped defensively (the ledgers - * are append-only; a mid-merge read should never crash detection), and - * duplicate pairs (follows+supports, or reverse direction) collapse — - * mergeEdge keeps the first. - */ -export function detectCommunities( - nodes: readonly { id: string }[], - edges: readonly { from: string; to: string }[], - opts?: DetectOptions, -): CommunityDetection { - const graph = new Graph({ type: "undirected" }); - for (const node of nodes) graph.addNode(node.id); - for (const edge of edges) { - if (!graph.hasNode(edge.from) || !graph.hasNode(edge.to)) continue; - if (edge.from === edge.to) continue; // self-loops: no community signal - graph.mergeEdge(edge.from, edge.to); - } - // Louvain on a 0-edge graph is degenerate; every node is trivially its - // own community (and louvain on an empty graph returns nothing useful). - if (graph.size === 0) { - return { - communities: nodes.map((n) => ({ members: [n.id] })).sort((a, b) => (a.members[0] ?? "").localeCompare(b.members[0] ?? "")), - modularity: 0, - }; - } - const details = louvain.detailed(graph, { - rng: seededRng(opts?.seed ?? "lh-clusters"), - resolution: opts?.resolution ?? 1, - }); - const byIndex = new Map(); - for (const [id, index] of Object.entries(details.communities)) { - const bucket = byIndex.get(index) ?? []; - bucket.push(id); - byIndex.set(index, bucket); - } - const communities = [...byIndex.values()] - .map((ids) => ({ members: ids.sort() })) - .sort( - (a, b) => - b.members.length - a.members.length || - (a.members[0] ?? "").localeCompare(b.members[0] ?? ""), - ); - return { communities, modularity: details.modularity }; -} - /** * Claim labeling records for detected communities — CORE-SURVIVAL (DECIDED * 2026-09-13): a community keeps a record's label while AT LEAST HALF of diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index cb56644..3a69258 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -278,6 +278,26 @@ export interface AuditResult { * least half its core survives in some single community (core-survival; * under half → the community is renamed, splits can keep only one * successor). */ +/** The embedding ledger shape (config.EMBEDDINGS_FILE): vectors for + * EVERY embedded node, all produced by one model at one dimensionality. */ +export interface EmbeddingsLedger { + /** The embedding model id (e.g. the HF repo) — mismatch = stale + * vectors, the writer re-embeds everything. */ + model: string; + /** Vector dimensionality (MRL-truncated on write). */ + dim: number; + /** nodeId → L2-normalized vector (cosine similarity = dot product). */ + vectors: Record; + /** The topic partition over those vectors, computed by the SAME + * embedder run (the user-decided trigger: recluster at the end of + * every embedding batch, never per read — the agglomerative pass is + * ~1.5s at current scale). Consumers (snapshot, settle, namer) read + * this instead of recomputing. Empty when nothing is embedded. */ + clusters: Array<{ members: string[] }>; + /** ISO timestamp of the last write. */ + updatedAt: string; +} + export interface ClusterLabeling { /** "clu_" + content hash of the sorted core. */ id: string; diff --git a/src/core/graph.ts b/src/core/graph.ts index c1aa7a5..1c4863c 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -51,7 +51,7 @@ import type { LedgerEvent, LedgerEventKind, } from "./contracts.ts"; -import { detectCommunities, matchClusterLabelings, MIN_CLUSTER_SIZE } from "./clusters.ts"; +import { matchClusterLabelings, MIN_CLUSTER_SIZE } from "./clusters.ts"; import type { QuestionAnswerData } from "./question-core.ts"; import type { ChatStore, GraphStore } from "./ports.ts"; @@ -497,14 +497,18 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot { (nodeObservations[nodeId] ??= {})[chatId] = note; } - // Structural communities (vision §1): Louvain over the undirected - // follows+supports structure, joined with persisted labelings — - // core-survival matching, size-filtered at the presentation threshold. - // Detection is DETERMINISTIC (seeded), so no memo in v1: a snapshot - // read recomputes it (ms at current scale; revisit at vault scale). - const detection = detectCommunities(nodes, edges); - const claims = matchClusterLabelings(detection.communities, deps.graphStore.readClusterLabels()); - const clusters: Cluster[] = detection.communities + // Semantic topic communities (DECIDED 2026-09-13, replacing structural + // Louvain): average-linkage clustering over embeddings — related + // topics, not reasoning chains. Edge-orphans join topics by meaning; + // stragglers stay loose (filtered at the presentation threshold + // below). The partition is computed ONCE per embedding batch by the + // embedder (the agglomerative pass is ~1.5s at current scale) and + // PERSISTED in the embedding ledger — reads just load it. Nodes whose + // embedding hasn't landed yet simply sit unclustered until the next + // read after the embedder runs. + const communities = deps.graphStore.readEmbeddings().clusters; + const claims = matchClusterLabelings(communities, deps.graphStore.readClusterLabels()); + const clusters: Cluster[] = communities .map((community, index) => ({ community, claim: claims[index] })) .filter(({ community }) => community.members.length >= MIN_CLUSTER_SIZE) .map(({ community, claim }) => ({ diff --git a/src/core/index.ts b/src/core/index.ts index 70c2ebb..5e1a4bf 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -22,7 +22,7 @@ export * from "./render-settings.ts"; export * from "./question-core.ts"; // clusters.ts (Louvain) is deliberately NOT re-exported: nothing in the // shell runs community detection — the snapshot carries the partition -// from the server. Keeping it out keeps graphology-louvain out of the +// from the server. Keeping it out keeps the heavy clustering deps out of the // client bundle (verified 2026-09-13: chunks contained it while this // line existed). Same logic as layout-settle.ts below. export * from "./layout.ts"; diff --git a/src/core/layout-settle.ts b/src/core/layout-settle.ts index eb23aa7..8a405d6 100644 --- a/src/core/layout-settle.ts +++ b/src/core/layout-settle.ts @@ -1,5 +1,4 @@ -import type { GraphEdge, GraphNode } from "./contracts.ts"; -import { detectCommunities } from "./clusters.ts"; +import type { EmbeddingsLedger, GraphEdge, GraphNode } from "./contracts.ts"; import { incrementalLayout } from "./layout.ts"; import type { Position } from "./contracts.ts"; @@ -13,7 +12,7 @@ import type { Position } from "./contracts.ts"; * * SPLIT FROM layout.ts deliberately (2026-09-13): layout.ts is in the * browser barrel, and the settle needs the Louvain partition for the - * cluster centroid pull — graphology-louvain must not ship in the client + * cluster centroid pull — the heavy deps must not ship in the client * bundle. Server code imports this module DIRECTLY; it is never * re-exported through src/core/index.ts. */ @@ -22,6 +21,7 @@ export function settleGraphLayout(graphStore: { readEdges(): GraphEdge[]; readPositions(): Record; writePositions(positions: Record): void; + readEmbeddings(): EmbeddingsLedger; }): void { const nodes = graphStore.readNodes(); if (nodes.length === 0) return; @@ -34,10 +34,11 @@ export function settleGraphLayout(graphStore: { }); const edges = graphStore.readEdges(); const previous = new Map(Object.entries(graphStore.readPositions())); - // The cluster partition for the centroid pull: same Louvain the - // snapshot uses (same nodes/edges = same communities). 338 nodes: - // ~1-2ms, noise next to the settle itself. - const { communities } = detectCommunities(unique, edges); + // The cluster partition for the centroid pull: the SAME persisted + // semantic topics the snapshot renders (computed by the embedder + // once per batch, stored in the embedding ledger) — one cluster + // concept everywhere. + const communities = graphStore.readEmbeddings().clusters; const next = incrementalLayout(unique, edges, previous, { clusters: communities }); graphStore.writePositions(Object.fromEntries(next)); } \ No newline at end of file diff --git a/src/core/layout.ts b/src/core/layout.ts index 26fad48..a555240 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -386,7 +386,7 @@ export interface IncrementalLayoutOptions { gravityStrength?: number; /** Cluster partition (Louvain output shape). Supplied by callers so * this module stays louvain-free (the browser bundle must not carry - * graphology-louvain); absent = no cluster pull. */ + * embedding ledger; absent = no cluster pull. */ clusters?: ReadonlyArray<{ members: ReadonlyArray }>; /** Cluster centroid pull strength (default CLUSTER_PULL_STRENGTH). */ clusterPullStrength?: number; diff --git a/src/core/ports.ts b/src/core/ports.ts index 307280c..9f9d893 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -27,6 +27,7 @@ import type { TurnUsage, NotesSource, ClusterLabeling, + EmbeddingsLedger, } from "./contracts.ts"; // ─── ChatStore — the session record's home (data/chats) ───────────────────── @@ -104,6 +105,13 @@ export interface GraphStore { readPositions(): Record; /** Replace the whole ledger (the settle is a full-graph computation). */ writePositions(positions: Record): void; + + /** The embedding ledger (config.EMBEDDINGS_FILE). WRITTEN ONLY by + * the embedder (server/embeddings.ts) after a merge — read-only for + * clustering consumers (snapshot, settle, namer). */ + readEmbeddings(): EmbeddingsLedger; + /** Replace the whole ledger (embedding is a full-batch computation). */ + writeEmbeddings(ledger: EmbeddingsLedger): void; } // ─── JobStore — background sub-agent jobs (data/jobs) ───────────────────────── diff --git a/src/core/semantic-clusters.ts b/src/core/semantic-clusters.ts new file mode 100644 index 0000000..7772c60 --- /dev/null +++ b/src/core/semantic-clusters.ts @@ -0,0 +1,141 @@ +// Semantic topic clustering — the REPLACEMENT for Louvain community +// detection (DECIDED 2026-09-13): structural clusters were just the +// reasoning chains (the graph is disconnected by nature — every connected +// component became its own "cluster"), and edge-orphans could never join +// anything. Clusters are now RELATED TOPICS: nodes whose proposition +// MEANING is close, computed over the embedding ledger +// (config.EMBEDDINGS_FILE). This pulls orphans into topics by meaning +// and groups small chains with the large chains they discuss. +// +// Algorithm: threshold AGGLOMERATIVE (average linkage over cosine +// similarity). No JS HDBSCAN is trustworthy (the npm package is an +// abandoned alpha), and at proposition scale (~100s-1000s of short texts) +// brute-force pairwise cosine is sub-second — so the honest ~60-line +// hand-roll wins. "Stragglers" (nodes that fit nothing) fall out +// naturally: they simply never merge, and stay singletons. + +/** The default similarity threshold, SWEPT ON THE REAL GRAPH + * (2026-09-13): 0.60 → 26 topics of ~20 propositions, 74 of the 90 + * edge-orphans land in real topics, stragglers 10. Higher (0.65-0.68) + * gives tighter micro-topics with far more stragglers; lower (0.55) + * collapses into ~13 mega-topics. */ +export const SEMANTIC_SIM_THRESHOLD = 0.6; + +/** Cosine similarity of two vectors (defensively normalizes inputs; + * ledger vectors are already L2-normalized, so this is a dot product). */ +export function cosineSimilarity(a: readonly number[], b: readonly number[]): number { + let dot = 0; + let ma = 0; + let mb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i]! * b[i]!; + ma += a[i]! * a[i]!; + mb += b[i]! * b[i]!; + } + if (ma === 0 || mb === 0) return 0; + return dot / (Math.sqrt(ma) * Math.sqrt(mb)); +} + +export interface SemanticClusterOptions { + /** Merge while the best cluster pair's average-linkage similarity is + * >= this (default SEMANTIC_SIM_THRESHOLD). */ + similarityThreshold?: number; + /** After the cut, force every singleton into its nearest topic + * ("all-in" mode) instead of leaving stragglers loose. Default + * false: some propositions genuinely fit nothing. */ + attachStragglers?: boolean; +} + +/** Average-linkage agglomerative clustering over embedding vectors. + * Pure and deterministic (stable Map iteration, greedy by best + * similarity, first-found wins ties). Returns clusters size-desc — the + * same { members } shape Louvain produced, so label matching and the + * renderer contract are unchanged. An empty/missing ledger returns [] + * (nothing embedded yet: no clusters, every node loose — the snapshot + * degrades gracefully until the first embed). */ +export function semanticClusters( + vectors: Readonly>, + options: SemanticClusterOptions = {}, +): Array<{ members: string[] }> { + const ids = Object.keys(vectors); + if (ids.length === 0) return []; + const simThreshold = options.similarityThreshold ?? SEMANTIC_SIM_THRESHOLD; + const attach = options.attachStragglers ?? false; + + // Sum vectors per cluster: average-linkage similarity between clusters + // A and B is dot(sumA, sumB) / (|A|·|B|) — the exact mean of pairwise + // dots — so the merge loop never revisits the raw O(n²) matrix. + const sums = new Map(); + const members = new Map(); + ids.forEach((id, i) => { + sums.set(i, [...vectors[id]!]); + members.set(i, [id]); + }); + + // Greedy merge: repeatedly join the pair with the highest + // average-linkage similarity until it drops below the threshold. + // O(k²) pair scan per merge — trivial at proposition scale. + for (;;) { + let bestA = -1; + let bestB = -1; + let bestSim = -Infinity; + for (const [a, sumA] of sums) { + for (const [b, sumB] of sums) { + if (a >= b) continue; + let dot = 0; + for (let i = 0; i < sumA.length; i++) dot += sumA[i]! * sumB[i]!; + const sim = dot / (members.get(a)!.length * members.get(b)!.length); + if (sim > bestSim) { + bestSim = sim; + bestA = a; + bestB = b; + } + } + } + if (bestA < 0 || bestSim < simThreshold) break; + // Merge B into A (deterministic direction: lower cluster index). + const sumA = sums.get(bestA)!; + const sumB = sums.get(bestB)!; + for (let i = 0; i < sumA.length; i++) sumA[i] = sumA[i]! + sumB[i]!; + members.get(bestA)!.push(...members.get(bestB)!); + sums.delete(bestB); + members.delete(bestB); + } + + // Optional all-in pass: singletons join their nearest topic regardless + // of threshold — exactly the "forced membership" mode. The difference + // from stragglers mode is this one pass. Singleton-vs-cluster + // average-linkage similarity is dot(sumS, sumK) / |K|. + if (attach) { + const singletons = [...members.entries()].filter(([, m]) => m.length === 1).map(([k]) => k); + for (const s of singletons) { + if (!members.has(s)) continue; // absorbed by an earlier attach + let bestTarget = -1; + let bestSim = -Infinity; + for (const [k, m] of members) { + if (k === s || m.length < 2) continue; + let dot = 0; + const sumS = sums.get(s)!; + const sumK = sums.get(k)!; + for (let i = 0; i < sumS.length; i++) dot += sumS[i]! * sumK[i]!; + const sim = dot / m.length; + if (sim > bestSim) { + bestSim = sim; + bestTarget = k; + } + } + if (bestTarget >= 0) { + const sumT = sums.get(bestTarget)!; + const sumS = sums.get(s)!; + for (let i = 0; i < sumT.length; i++) sumT[i] = sumT[i]! + sumS[i]!; + members.get(bestTarget)!.push(...members.get(s)!); + sums.delete(s); + members.delete(s); + } + } + } + + return [...members.values()] + .map((m) => ({ members: [...m].sort() })) + .sort((a, b) => b.members.length - a.members.length || (a.members[0] ?? "").localeCompare(b.members[0] ?? "")); +} diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 589516f..a160b9f 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -4,6 +4,7 @@ import { createProberAgent, createTeacherAgent, type TeachingAgent } from "../ag import { piEngineFor } from "../agent/models.ts"; import { createJobRunner } from "../agent/subagents/runner.ts"; import { createExtractionPipeline } from "../agent/subagents/extraction.ts"; +import { embedNewNodes } from "./embeddings.ts"; import type { ModelRole } from "../../config.ts"; import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts"; import { recordToPiMessages } from "./pi/record-adapter.ts"; @@ -40,7 +41,12 @@ const jobRunner = createJobRunner({ /** The app's ONE extraction pipeline instance — both front doors share it: * quiz-triggered session runs (turn-service) and notes imports * (/api/graph/import) hit the same single-flight registry. */ -export const extraction = createExtractionPipeline(stores); +export const extraction = createExtractionPipeline({ + ...stores, + // The REAL embedder (local transformer model): post-merge embedding + // batch — semantic clusters are computed and persisted here. + embedder: embedNewNodes, +}); // Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline // fires its server-side teacher turns through THIS composition root, and diff --git a/src/server/embeddings.ts b/src/server/embeddings.ts new file mode 100644 index 0000000..0d9194f --- /dev/null +++ b/src/server/embeddings.ts @@ -0,0 +1,125 @@ +/** + * THE EMBEDDER (DECIDED 2026-09-13): semantic topic clustering needs + * vectors, and vectors need a model running ON the user's machine — no + * external services, no API bills, no network dependency for re-embedding. + * + * Runtime: in-process transformers.js (@huggingface/transformers v4 over + * onnxruntime-node — native Apple Silicon acceleration). The model + * weights download ONCE to the local HF cache on first run (~300MB). + * + * Model: EmbeddingGemma-300M (q8) — best quality-per-megabyte small + * embedding model as of late 2025 (MTEB-en 68.36), built for on-device + * use, with MRL truncation so we store 512 of its 768 dims. Every input + * uses the model card's document format ("title: none | text: …"), + * consistently — mixing prefixed and unprefixed inputs degrades quality. + * + * Model swaps re-embed EVERYTHING: vectors from different models live in + * incompatible spaces. The ledger records model + dim; a mismatch marks + * the whole ledger stale and this module re-embeds from scratch (minutes + * at current scale). + */ +import { pipeline, type FeatureExtractionPipeline } from "@huggingface/transformers"; +import type { EmbeddingsLedger, GraphNode } from "../core/contracts.ts"; +import { semanticClusters, SEMANTIC_SIM_THRESHOLD } from "../core/semantic-clusters.ts"; + +export const EMBEDDING_MODEL = "onnx-community/embeddinggemma-300m-ONNX"; +/** MRL truncation of the native 768 — negligible quality loss, half the + * ledger size (2026-09-13 researcher sweep). */ +export const EMBEDDING_DIM = 512; +/** Batch size bounds peak memory; propositions are tiny so 32 per call + * keeps the pipeline warm without thrashing. */ +const EMBED_BATCH = 32; + +let pipelinePromise: Promise | null = null; + +function getPipeline(): Promise { + pipelinePromise ??= pipeline("feature-extraction", EMBEDDING_MODEL, { dtype: "q8" }); + return pipelinePromise; +} + +/** EmbeddingGemma's required document format (model card). */ +function embedInput(statement: string): string { + return `title: none | text: ${statement}`; +} + +/** Truncate an L2-normalized vector to EMBEDDING_DIM (MRL: leading dims + * carry most of the signal) and re-normalize so cosine = dot product. */ +function truncateNormalize(vec: readonly number[]): number[] { + const out = vec.slice(0, EMBEDDING_DIM); + let mag = 0; + for (const x of out) mag += x * x; + mag = Math.sqrt(mag); + return mag > 0 ? out.map((x) => x / mag) : out; +} + +/** + * Embed every node the ledger is missing — the post-merge hook (new + * propositions only, typically a handful) AND the cold-start backfill + * (empty ledger = every node) in one function. Model/dim mismatch voids + * the ledger first (full re-embed). Also prunes vectors for nodes that + * no longer exist. Writes the whole ledger and returns counts for logs. + * Non-fatal by contract: the caller treats failure as "clustering runs + * without the new nodes until the next merge retries". + */ +export async function embedNewNodes( + graphStore: { + readNodes(): GraphNode[]; + readEmbeddings(): EmbeddingsLedger; + writeEmbeddings(ledger: EmbeddingsLedger): void; + }, +): Promise<{ embedded: number; total: number }> { + const ledger = graphStore.readEmbeddings(); + const stale = ledger.model !== EMBEDDING_MODEL || ledger.dim !== EMBEDDING_DIM; + const vectors: Record = stale ? {} : { ...ledger.vectors }; + + // Node LINES may repeat an id — dedupe, first defines (getGraph rule). + const seen = new Set(); + const todo: GraphNode[] = []; + for (const node of graphStore.readNodes()) { + if (seen.has(node.id)) continue; + seen.add(node.id); + if (!vectors[node.id]) todo.push(node); + } + + if (todo.length > 0) { + const extractor = await getPipeline(); + for (let i = 0; i < todo.length; i += EMBED_BATCH) { + const batch = todo.slice(i, i + EMBED_BATCH); + const output = await extractor(batch.map((n) => embedInput(n.statement)), { + pooling: "mean", + normalize: true, + }); + const rows = output.tolist() as number[][]; + for (let j = 0; j < batch.length; j++) { + vectors[batch[j]!.id] = truncateNormalize(rows[j]!); + } + } + } + + // Prune ghosts: vectors for node ids that no longer exist. + let pruned = 0; + for (const id of Object.keys(vectors)) { + if (!seen.has(id)) { + delete vectors[id]; + pruned++; + } + } + + // RECLUSTER AT THE END OF THE BATCH (user-decided 2026-09-13): the + // agglomerative pass is ~1.5s at current scale, so it runs HERE — + // once per embedding batch — and consumers (snapshot, settle, namer) + // read the persisted partition instead of recomputing per read. + const clusters = semanticClusters(vectors, { similarityThreshold: SEMANTIC_SIM_THRESHOLD }); + + const wrote = todo.length > 0 || pruned > 0 || stale || ledger.clusters.length !== clusters.length; + if (wrote) { + graphStore.writeEmbeddings({ + model: EMBEDDING_MODEL, + dim: EMBEDDING_DIM, + vectors, + clusters, + updatedAt: new Date().toISOString(), + }); + } + return { embedded: todo.length, total: Object.keys(vectors).length }; +} diff --git a/src/store/graph.ts b/src/store/graph.ts index d7a9545..8c72007 100644 --- a/src/store/graph.ts +++ b/src/store/graph.ts @@ -29,9 +29,10 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts"; +import { EMBEDDINGS_FILE, LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts"; import type { ClusterLabeling, + EmbeddingsLedger, GraphEdge, GraphNode, ExtractionRunUsage, @@ -216,6 +217,12 @@ export const graphStore: GraphStore = { writePositions(positions: Record): void { writePositionMap(positions); }, + readEmbeddings(): EmbeddingsLedger { + return readEmbeddingsLedger(); + }, + writeEmbeddings(ledger: EmbeddingsLedger): void { + writeEmbeddingsLedger(ledger); + }, }; /** positions.json — { positions, updatedAt }. Read-modify-write, NOT @@ -252,6 +259,57 @@ function writePositionMap(positions: Record): void { ); } +/** embeddings.json — the whole EmbeddingsLedger at once. Missing/corrupt + * file reads as an EMPTY ledger (model "") so consumers see "nothing + * embedded yet" rather than an error; the embedder backfills. */ +function readEmbeddingsLedger(): EmbeddingsLedger { + let raw: string; + try { + raw = fs.readFileSync(EMBEDDINGS_FILE, "utf-8"); + } catch { + return { model: "", dim: 0, vectors: {}, clusters: [], updatedAt: "" }; + } + try { + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed)) return { model: "", dim: 0, vectors: {}, clusters: [], updatedAt: "" }; + const model = typeof parsed.model === "string" ? parsed.model : ""; + const dim = typeof parsed.dim === "number" && Number.isFinite(parsed.dim) ? parsed.dim : 0; + const vectors: Record = {}; + if (isRecord(parsed.vectors)) { + for (const [id, value] of Object.entries(parsed.vectors)) { + if ( + Array.isArray(value) && + value.length > 0 && + value.every((x) => typeof x === "number" && Number.isFinite(x)) + ) { + vectors[id] = value as number[]; + } + } + } + const clusters: Array<{ members: string[] }> = []; + if (Array.isArray(parsed.clusters)) { + for (const c of parsed.clusters) { + if ( + isRecord(c) && + Array.isArray(c.members) && + c.members.length > 0 && + c.members.every((m) => typeof m === "string") + ) { + clusters.push({ members: [...(c.members as string[])] }); + } + } + } + return { model, dim, vectors, clusters, updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : "" }; + } catch { + return { model: "", dim: 0, vectors: {}, clusters: [], updatedAt: "" }; + } +} + +function writeEmbeddingsLedger(ledger: EmbeddingsLedger): void { + fs.mkdirSync(path.dirname(EMBEDDINGS_FILE), { recursive: true }); + fs.writeFileSync(EMBEDDINGS_FILE, JSON.stringify(ledger, null, "\t"), "utf-8"); +} + const isPosition = (v: unknown): v is Position => isRecord(v) && typeof v.x === "number" && Number.isFinite(v.x) && typeof v.y === "number" && Number.isFinite(v.y); diff --git a/tests/clusters.test.ts b/tests/clusters.test.ts index 7436141..b7a4ffc 100644 --- a/tests/clusters.test.ts +++ b/tests/clusters.test.ts @@ -6,9 +6,8 @@ */ import { strict as assert } from "node:assert"; import { describe, test } from "node:test"; -import { detectCommunities, matchClusterLabelings, MIN_CLUSTER_SIZE } from "../src/core/clusters.ts"; +import { matchClusterLabelings, MIN_CLUSTER_SIZE, type Community } from "../src/core/clusters.ts"; import { clusterHue } from "../components/cluster-geometry.ts"; -import type { Community } from "../src/core/clusters.ts"; import type { ClusterLabeling } from "../src/core/contracts.ts"; const ids = (n: number, prefix = "n") => [...Array(n).keys()].map((i) => `${prefix}${i}`); @@ -84,89 +83,3 @@ describe("clusterHue", () => { }); }); -describe("detectCommunities", () => { - test("two dense cliques joined by a single bridge split into two communities", () => { - const left = ids(4, "L"); - const right = ids(4, "R"); - const bridge = { from: "L0", to: "R0" }; - const result = detectCommunities([...left, ...right].map((id) => ({ id })), [...clique(left), ...clique(right), bridge]); - assert.equal(result.communities.length, 2); - assert.equal(result.modularity > 0.3, true); - // Member sets, not order: the two big communities are exactly the cliques. - const big = result.communities.filter((c) => c.members.length === 4); - assert.deepEqual(big.map((c) => c.members.sort()).sort(), [left.slice().sort(), right.slice().sort()]); - }); - - test("deterministic: same graph, same seed → identical partition", () => { - const nodes = ids(10).map((id) => ({ id })); - const edges = [...clique(ids(5)), ...clique(ids(5, "n").slice().reverse().slice(0, 5)), { from: "n0", to: "n9" }]; - const a = detectCommunities(nodes, edges); - const b = detectCommunities(nodes, edges); - assert.deepEqual(a.communities, b.communities); - assert.equal(a.modularity, b.modularity); - }); - - test("no edges: every node is its own singleton community", () => { - const result = detectCommunities(ids(3).map((id) => ({ id })), []); - assert.equal(result.communities.length, 3); - for (const c of result.communities) assert.equal(c.members.length, 1); - assert.equal(result.modularity, 0); - }); - - test("empty graph: zero communities", () => { - assert.deepEqual(detectCommunities([], []), { communities: [], modularity: 0 }); - }); - - test("duplicate and reversed pairs collapse to one edge (undirected, unweighted)", () => { - const nodes = ["a", "b", "c"].map((id) => ({ id })); - // follows a→b AND supports b→a — same pair twice, both directions. - const result = detectCommunities(nodes, [ - { from: "a", to: "b" }, - { from: "b", to: "a" }, - { from: "b", to: "c" }, - ]); - assert.equal(result.communities.length, 1); - assert.deepEqual(result.communities[0]!.members.sort(), ["a", "b", "c"]); - }); - - test("self-loops and unknown-endpoint edges are skipped defensively", () => { - const nodes = ["a", "b"].map((id) => ({ id })); - const result = detectCommunities(nodes, [ - { from: "a", to: "a" }, // self-loop - { from: "a", to: "ghost" }, // dangling - { from: "ghost1", to: "ghost2" }, - { from: "a", to: "b" }, // the one real edge - ]); - assert.equal(result.communities.length, 1); - assert.deepEqual(result.communities[0]!.members.sort(), ["a", "b"]); - }); - - test("members are sorted; communities ordered by size then id", () => { - const left = ids(4, "L"); - const right = ids(4, "R"); - const result = detectCommunities( - [...left, ...right, "iso"].map((id) => ({ id })), - [...clique(left), ...clique(right), { from: "L0", to: "R0" }], - ); - for (const c of result.communities as Community[]) { - const sorted = c.members.slice().sort(); - assert.deepEqual(c.members, sorted); - } - // iso (isolated) is last: size 1 < 4. - const last = result.communities[result.communities.length - 1]!; - assert.deepEqual(last.members, ["iso"]); - }); - - test("seed override changes nothing structurally but stays deterministic", () => { - const nodes = ids(8).map((id) => ({ id })); - const edges = [...clique(["n0", "n1", "n2", "n3"]), ...clique(["n4", "n5", "n6", "n7"]), { from: "n0", to: "n4" }]; - const a = detectCommunities(nodes, edges, { seed: "one" }); - const b = detectCommunities(nodes, edges, { seed: "one" }); - const c = detectCommunities(nodes, edges, { seed: "two" }); - assert.deepEqual(a.communities, b.communities); - // A different seed partitions the same graph into member sets that - // may or may not coincide — but both runs are 2 communities here, - // because the structure dominates. - assert.equal(c.communities.length, 2); - }); -}); diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index 02ec278..24e90ca 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.ts @@ -40,6 +40,7 @@ import type { import type { GraphStore } from "../src/core/ports.ts"; import { abortExtraction, createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts"; import { clusterNamingSettled, refreshClusterLabels } from "../src/agent/subagents/cluster-namer.ts"; +import { semanticClusters } from "../src/core/semantic-clusters.ts"; import { chatStore } from "../src/store/chats.ts"; import { PATHS } from "../config.ts"; @@ -67,6 +68,13 @@ function memoryLedgers() { const sources = new Map(); const clusterLabels: ClusterLabeling[] = []; const positions = new Map(); + const embeddings = { + model: "", + dim: 0, + updatedAt: "", + vectors: new Map(), + clusters: new Array>(), + }; return { appendNode: (n: GraphNode) => void nodes.push(n), readNodes: () => nodes.map((n) => ({ ...n })), @@ -102,6 +110,16 @@ function memoryLedgers() { positions.clear(); for (const [id, p] of Object.entries(next)) positions.set(id, { x: p.x, y: p.y }); }, + // The embedding ledger — the embedder writes it after a merge. + readEmbeddings: () => ({ model: embeddings.model, dim: embeddings.dim, vectors: Object.fromEntries([...embeddings.vectors].map(([id, v]) => [id, [...v]])), clusters: [...embeddings.clusters.map((c) => ({ members: [...c] }))], updatedAt: embeddings.updatedAt }), + writeEmbeddings: (next) => { + embeddings.model = next.model; + embeddings.dim = next.dim; + embeddings.updatedAt = next.updatedAt; + embeddings.vectors.clear(); + for (const [id, v] of Object.entries(next.vectors)) embeddings.vectors.set(id, [...v]); + embeddings.clusters = next.clusters.map((c) => [...c.members]); + }, } satisfies GraphStore; } @@ -319,6 +337,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, + embedder: fakeEmbedder, extractor: submitModel("submit_findings", FINDINGS), auditor: submitModel("submit_review", verdict), labeler: submitModel("submit_labels", { labels: [] }), @@ -360,6 +379,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, + embedder: fakeEmbedder, extractor: submitModel("submit_findings", FINDINGS), auditor: silentModel(), // never submits the review labeler: submitModel("submit_labels", { labels: [] }), @@ -371,6 +391,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const failingExtractor = createExtractionPipeline({ chatStore, graphStore: ledgers, + embedder: fakeEmbedder, extractor: silentModel(), auditor: submitModel("submit_review", { ...FINDINGS, dropped: [] }), labeler: submitModel("submit_labels", { labels: [] }), @@ -402,6 +423,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, + embedder: fakeEmbedder, // Each stage's first generate is the observation point: the // registry must already carry this chat at the right phase. extractor: submitModel("submit_findings", FINDINGS, () => seen.push(phaseOf())), @@ -435,6 +457,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, + embedder: fakeEmbedder, // During the auditor's generate, the registry must already carry // the extractor's fully accumulated live usage (2 steps × 10/5). extractor: submitModel("submit_findings", FINDINGS), @@ -476,6 +499,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, + embedder: fakeEmbedder, extractor: hangingModel(), // hangs until aborted — no step finishes auditor: submitModel("submit_review", { propositions: [], edges: [], attribution: { quiz: {} }, dropped: [] }), labeler: submitModel("submit_labels", { labels: [] }), @@ -527,6 +551,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, + embedder: fakeEmbedder, extractor: gatedExtractor, auditor: silentModel(), labeler: submitModel("submit_labels", { labels: [] }), @@ -545,6 +570,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, + embedder: fakeEmbedder, extractor: silentModel(), auditor: silentModel(), labeler: submitModel("submit_labels", { labels: [] }), @@ -603,6 +629,27 @@ const CLUSTER_VERDICT = { dropped: [], } as const; +/** Test seam for the embedder: fakes the post-merge embedding batch — + * one identical unit vector per node → semanticClusters merges them all + * into ONE topic — then persists the partition like the real embedder. */ +const fakeEmbedder = async (store: GraphStore) => { + const seen = new Set(); + const ids: string[] = []; + for (const n of store.readNodes()) { + if (seen.has(n.id)) continue; + seen.add(n.id); + ids.push(n.id); + } + const vectors = Object.fromEntries(ids.map((id) => [id, [1, 0, 0]])); + store.writeEmbeddings({ + model: "test/embedder", + dim: 3, + vectors, + clusters: semanticClusters(vectors), + updatedAt: "2026-09-13T00:00:00.000Z", + }); +}; + describe("cluster naming (the post-extraction garnish)", () => { it("names a new community after a notes merge — one record, claimed on the next refresh", async () => { const ledgers = memoryLedgers(); @@ -611,6 +658,7 @@ describe("cluster naming (the post-extraction garnish)", () => { const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, + embedder: fakeEmbedder, extractor: submitModel("submit_findings", CLUSTER_FINDINGS), auditor: submitModel("submit_review", CLUSTER_VERDICT), labeler, @@ -638,6 +686,7 @@ describe("cluster naming (the post-extraction garnish)", () => { const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, + embedder: fakeEmbedder, extractor: submitModel("submit_findings", CLUSTER_FINDINGS), auditor: submitModel("submit_review", CLUSTER_VERDICT), labeler: silentModel(), // never submits — the namer throws @@ -656,6 +705,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => { const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, + embedder: fakeEmbedder, extractor: submitModel("submit_findings", NOTES_FINDINGS), // During the auditor's call the registry must show the notes run. auditor: submitModel("submit_review", NOTES_VERDICT, () => { @@ -704,6 +754,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => { createExtractionPipeline({ chatStore, graphStore: ledgers, + embedder: fakeEmbedder, extractor: submitModel("submit_findings", NOTES_FINDINGS), auditor: submitModel("submit_review", NOTES_VERDICT), labeler: submitModel("submit_labels", { labels: [] }), @@ -724,6 +775,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => { const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, + embedder: fakeEmbedder, extractor: hangingModel(), auditor: submitModel("submit_review", NOTES_VERDICT), labeler: submitModel("submit_labels", { labels: [] }), diff --git a/tests/graph.test.ts b/tests/graph.test.ts index c695394..2bfc79c 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -23,7 +23,7 @@ import type { QuestionAnswerData } from "../src/core/question-core.ts"; import { clusterContentId, getGraph, graphSnapshot, mergeFindings, pendingQuizAnswers, type GraphDeps } from "../src/core/graph.ts"; import { graphStore } from "../src/store/graph.ts"; import { chatStore } from "../src/store/chats.ts"; -import { LEDGER_FILES, PATHS, SOURCES_FILE } from "../config.ts"; +import { LEDGER_FILES, PATHS, SOURCES_FILE, EMBEDDINGS_FILE } from "../config.ts"; /** The wired fs adapters, exactly what src/server/stores.ts passes in production. */ const graphDeps = { chatStore, graphStore } as const satisfies GraphDeps; @@ -38,7 +38,9 @@ function sessionSource(chatId: string): { messages: UIMessage[]; foldedAt: strin // ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ────────────── -const LEDGER_PATHS = [...Object.values(LEDGER_FILES), SOURCES_FILE]; +// EMBEDDINGS_FILE too: the semantic-cluster source (read by graphSnapshot) +// — isolated like the rest so this suite never touches the real vectors. +const LEDGER_PATHS = [...Object.values(LEDGER_FILES), SOURCES_FILE, EMBEDDINGS_FILE]; const backups: Array<{ file: string; bak: string }> = []; const createdChats: string[] = []; @@ -407,8 +409,10 @@ describe("the merger (mergeFindings)", () => { assert.equal(snap.nodeObservations[node.id]!["src_snapfixture"], "solid"); }); - it("derives structural clusters in graphSnapshot, joined with persisted labelings", () => { - // A self-contained 3-clique: three propositions, all mutually linked. + it("derives semantic clusters in graphSnapshot, joined with persisted labelings", () => { + // Three propositions; the cluster comes from the EMBEDDING ledger + // (semantic topics, DECIDED 2026-09-13) — the embedder persists the + // partition, the snapshot just reads it. Here we play the embedder. const tri = ["Alpha.", "Beta.", "Gamma cluster."] as const; const merge = mergeFindings( "src_clusterfixture", @@ -433,6 +437,16 @@ describe("the merger (mergeFindings)", () => { { messages: null, foldedAt: "2026-09-13T00:00:00.000Z" }, ); assert.equal(merge.nodes, 3); + // The embedder's contract: recluster at the end of the batch and + // PERSIST the partition in the embeddings ledger. + const ids = tri.map((s) => getGraph(graphStore).nodes.find((n) => n.statement === s)!.id); + graphStore.writeEmbeddings({ + model: "test/embedder", + dim: 2, + vectors: {}, + clusters: [{ members: ids }], + updatedAt: "2026-09-13T00:00:00.000Z", + }); const snap = graphSnapshot(graphDeps); // Presentation threshold: every cluster is >= 3 members. assert.ok(snap.clusters.every((c) => c.members.length >= 3)); diff --git a/tests/layout.test.ts b/tests/layout.test.ts index 7e71f44..9ae9fc9 100644 --- a/tests/layout.test.ts +++ b/tests/layout.test.ts @@ -110,6 +110,7 @@ describe("settleGraphLayout", () => { readEdges: () => [edge("a", "b")], readPositions: () => ({} as Record), writePositions: (p: Record) => void written.push(p), + readEmbeddings: () => ({ model: "", dim: 0, vectors: {}, clusters: [], updatedAt: "" }), }; settleGraphLayout(store); assert.equal(written.length, 1); @@ -123,6 +124,7 @@ describe("settleGraphLayout", () => { readEdges: () => [], readPositions: () => ({} as Record), writePositions: (p) => void written.push(p), + readEmbeddings: () => ({ model: "", dim: 0, vectors: {}, clusters: [], updatedAt: "" }), }); assert.equal(written.length, 0); }); diff --git a/tests/semantic-clusters.test.ts b/tests/semantic-clusters.test.ts new file mode 100644 index 0000000..54f7fb2 --- /dev/null +++ b/tests/semantic-clusters.test.ts @@ -0,0 +1,118 @@ +/** + * Semantic topic clustering (the Louvain REPLACEMENT, DECIDED + * 2026-09-13): average-linkage agglomerative clustering over the + * embedding ledger — related TOPICS, not reasoning chains. Pure and + * deterministic, so these tests are exact. + */ +import { strict as assert } from "node:assert"; +import { describe, test } from "node:test"; +import { semanticClusters, SEMANTIC_SIM_THRESHOLD, cosineSimilarity } from "../src/core/semantic-clusters.ts"; + +/** Two clearly-related one-hot-ish vectors (high cosine similarity). */ +function near(vec: number[], target: number[], amount = 0.2): number[] { + let dot = 0; + for (let i = 0; i < vec.length; i++) dot += vec[i]! * target[i]!; + const mixed = vec.map((x, i) => x + amount * target[i]!); + let mag = 0; + for (const x of mixed) mag += x * x; + mag = Math.sqrt(mag); + return mixed.map((x) => x / mag); +} + +function axis(dim: number, k: number): number[] { + return Array.from({ length: dim }, (_, i) => (i === k ? 1 : 0)); +} + +describe("cosineSimilarity", () => { + test("identical unit vectors → 1; orthogonal → 0; defensive normalization", () => { + const a = axis(4, 0); + const b = axis(4, 1); + assert.ok(cosineSimilarity(a, a) > 0.999); + assert.ok(Math.abs(cosineSimilarity(a, b)) < 1e-9); + // Unnormalized twins still match. + assert.ok(cosineSimilarity([3, 0], [5, 0]) > 0.999); + }); +}); + +describe("semanticClusters", () => { + test("empty ledger: zero clusters (nothing embedded yet)", () => { + assert.deepEqual(semanticClusters({}), []); + }); + + test("related vectors merge; distant ones stay separate (threshold)", () => { + const dim = 8; + const topicA = axis(dim, 0); + const topicB = axis(dim, 1); + const vectors = { + a1: near(topicA, topicB, 0.1), + a2: near(topicA, topicB, 0.15), + a3: near(topicA, topicB, 0.05), + b1: near(topicB, topicA, 0.1), + b2: near(topicB, topicA, 0.12), + }; + const clusters = semanticClusters(vectors); + // Both topics form; average-linkage similarity within each is well + // above the default threshold, across is well below. + const big = clusters.filter((c) => c.members.length >= 2); + assert.equal(big.length, 2); + assert.deepEqual( + big.map((c) => c.members.sort()).sort((x, y) => x[0]!.localeCompare(y[0]!)), + [["a1", "a2", "a3"], ["b1", "b2"]], + ); + }); + + test("threshold is the granularity knob: higher splits, lower merges", () => { + const dim = 6; + const vectors = { + x: near(axis(dim, 0), axis(dim, 1), 0.3), + y: near(axis(dim, 1), axis(dim, 0), 0.3), + }; + // Similarity ~0.72: merges below that, splits above. + const sim = cosineSimilarity(vectors.x!, vectors.y!); + assert.equal(semanticClusters(vectors, { similarityThreshold: sim - 0.01 }).length, 1); + assert.equal(semanticClusters(vectors, { similarityThreshold: sim + 0.01 }).length, 2); + }); + + test("stragglers stay singletons by default; attachStragglers forces membership", () => { + const dim = 6; + const topic = axis(dim, 0); + const vectors = { + t1: near(topic, axis(dim, 1), 0.1), + t2: near(topic, axis(dim, 2), 0.1), + t3: near(topic, axis(dim, 3), 0.1), + loner: axis(dim, 5), // orthogonal: fits nothing + }; + const loose = semanticClusters(vectors); + const looseLoner = loose.find((c) => c.members.includes("loner")); + assert.equal(looseLoner?.members.length, 1, "default: loner is a straggler"); + assert.equal(loose.find((c) => c.members.length >= 3)?.members.length, 3); + + const allIn = semanticClusters(vectors, { attachStragglers: true }); + assert.equal(allIn.length, 1, "attach: the loner joins the nearest topic"); + assert.ok(allIn[0]!.members.includes("loner")); + }); + + test("members sorted; clusters ordered by size then id (deterministic)", () => { + const dim = 4; + const vectors = { + solo: axis(dim, 3), + p1: near(axis(dim, 0), axis(dim, 1), 0.05), + p2: near(axis(dim, 0), axis(dim, 2), 0.05), + q1: near(axis(dim, 1), axis(dim, 0), 0.05), + q2: near(axis(dim, 1), axis(dim, 2), 0.05), + }; + const a = semanticClusters(vectors); + const b = semanticClusters(vectors); + assert.deepEqual(a, b, "same input → identical partition"); + for (let i = 1; i < a.length; i++) { + assert.ok(a[i - 1]!.members.length >= a[i]!.members.length, "size-descending"); + } + for (const c of a) { + assert.deepEqual(c.members, [...c.members].sort(), "members sorted"); + } + }); + + test("default threshold is the swept real-graph value", () => { + assert.equal(SEMANTIC_SIM_THRESHOLD, 0.6); + }); +});