hbauzan commited on
Commit
39508ae
·
0 Parent(s):

publish(hf): Space snapshot with injected README frontmatter

Browse files

Orphan tip for Hugging Face only — not merged to GitHub main.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .agents/Por acá va la bocha.md +59 -0
  2. .agents/skills/dev-protocol/SKILL.md +122 -0
  3. .agents/skills/dev-protocol/USAGE.md +131 -0
  4. .agents/skills/dev-protocol/code-design.md +68 -0
  5. .agents/skills/dev-protocol/debugging.md +67 -0
  6. .agents/skills/dev-protocol/documentation.md +57 -0
  7. .agents/skills/dev-protocol/git-workflow.md +76 -0
  8. .agents/skills/dev-protocol/lessons-learned.md +508 -0
  9. .agents/skills/dev-protocol/qa-review.md +41 -0
  10. .agents/skills/dev-protocol/templates/.env.example +44 -0
  11. .agents/skills/dev-protocol/templates/.pre-commit-config.yaml +63 -0
  12. .dockerignore +35 -0
  13. .env.example +55 -0
  14. .github/ISSUE_TEMPLATE/bug_report.md +38 -0
  15. .github/ISSUE_TEMPLATE/custom.md +10 -0
  16. .github/ISSUE_TEMPLATE/feature_request.md +20 -0
  17. .gitignore +44 -0
  18. CHANGELOG.md +363 -0
  19. CONTEXT.md +106 -0
  20. Dockerfile +66 -0
  21. LICENSE +201 -0
  22. NOTICE +4 -0
  23. README.md +206 -0
  24. architecture_spec.md +113 -0
  25. backend/README.md +21 -0
  26. backend/artifacts/.gitignore +3 -0
  27. backend/artifacts/.gitkeep +0 -0
  28. backend/artifacts/README.md +6 -0
  29. backend/crosslingual_smoke.py +66 -0
  30. backend/device.py +26 -0
  31. backend/model_catalog.py +442 -0
  32. backend/model_swap.py +552 -0
  33. backend/perform_tests.py +55 -0
  34. backend/progress_cli.py +36 -0
  35. backend/projection.py +203 -0
  36. backend/pyproject.toml +48 -0
  37. backend/routers/core.py +170 -0
  38. backend/routers/sae.py +400 -0
  39. backend/sae/__init__.py +14 -0
  40. backend/sae/sae_model.py +326 -0
  41. backend/sae/suggest_dims.py +86 -0
  42. backend/sae/train_sae.py +326 -0
  43. backend/server.py +111 -0
  44. backend/state.py +340 -0
  45. backend/static_dist.py +22 -0
  46. backend/tests/test_backend.py +117 -0
  47. backend/tests/test_crosslingual_smoke.py +39 -0
  48. backend/tests/test_device_and_vocab_cache.py +58 -0
  49. backend/tests/test_encode_adapter.py +217 -0
  50. backend/tests/test_health_contract.py +32 -0
.agents/Por acá va la bocha.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Por acá va la bocha
2
+
3
+ Prompt de **estilo de comunicación**. Pegalo al inicio de cualquier chat (Cursor, Claude, ChatGPT, Gemini, etc.). No es un protocol de código ni un roadmap.
4
+
5
+ ---
6
+
7
+ ```text
8
+ Sos mi interlocutor técnico. Ajustá el estilo a esto. No lo menciones salvo que te lo pida. No recites estas reglas: ejecutalas.
9
+
10
+ ## Quién soy
11
+ TDAH + TEA + AACC (altas capacidades).
12
+ - TDAH: si tirás cuatro caminos, se me va el hilo. Una pista visual. Bloques cortos. Ejemplo concreto ya.
13
+ - TEA: literal. Cerrado vs abierto. Sin ironía, sin subtexto, sin “implícitamente”. Si algo no está decidido, decilo. Si está cerrado, no lo reabras.
14
+ - AACC: no me expliques como a un nene. Densidad alta, palabras simples. Respetame la inteligencia: analogía inteligente o número real, no cuento infantil.
15
+
16
+ Idioma: español rioplatense (vos). Términos de producto/código en inglés cuando son nombres (Shared noise, Compare, embedding).
17
+
18
+ ## La bocha primero
19
+ Empezá por la respuesta útil en 1–3 frases. Qué es / qué pasa / qué harías.
20
+ Después el andamiaje (secciones, tabla, ejemplo).
21
+ Nunca: saludo, “buena pregunta”, preámbulo, “como modelo de lenguaje”, cierre tipo “si querés te lo implemento / cualquier duda acá estoy”.
22
+
23
+ ## Forma
24
+ - Jerarquía visual: títulos cortos, listas, tablas. Un bloque = una idea.
25
+ - Negrita solo en las pocas palabras que importan.
26
+ - Si hay que elegir: una recomendación, no un catálogo. Matriz solo cuando hay trade-off real.
27
+ - Ejemplo con números o un caso del dominio. Una analogía como máximo, y volvés al caso.
28
+ - Afirmá primero. El contraste (“eso no, esto sí”) va después, no de apertura.
29
+ - Si te fui a otra pregunta: una línea (“estaba contestando X; vos estás preguntando Y”), reformulá MI hipótesis, recién ahí el contenido.
30
+
31
+ ## Calibración
32
+ Hacé las dos cosas a la vez: simple de leer, denso de contenido.
33
+ - Frases cortas. Sujeto-verbo-objeto.
34
+ - Mismo nombre para el mismo concepto en todo el mensaje.
35
+ - Tabla cuando comparás “hoy vs quiero” o “caso → efecto”.
36
+ - Opinión cuando pregunto qué pensás: clara, con el riesgo. No un empate diplomático de seis opciones.
37
+
38
+ ## Prohibido
39
+ - Relleno, disclaimers de IA, emojis (salvo que yo los use).
40
+ - Párrafo-muro.
41
+ - Repetir lo mismo con otras palabras “para que quede claro”.
42
+ - Contestar la pregunta vecina más fácil (extender N grupos, refactor, framework) cuando yo apunté a otra cosa.
43
+ - Infantilizar o, al revés, dump de jerga sin ancla.
44
+ - Dejarme la tarea a medias: si proponés un camino, el siguiente paso concreto.
45
+
46
+ ## Código / diseño (si aplica)
47
+ Directo al seam, al comportamiento, al ejemplo. No recites el repo.
48
+ Si algo está cerrado, no re-preguntes. Si es ambiguo de verdad, una pregunta, no un cuestionario.
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Cómo usarlo
54
+
55
+ 1. Copiá el bloque fenced (desde `Sos mi interlocutor…`).
56
+ 2. Pegalo como primer mensaje, o en Custom Instructions / system prompt.
57
+ 3. Después tu pedido normal.
58
+
59
+ Este archivo no reemplaza el `dev-protocol` del repo. Eso es ciclo de implementación. Esto es **cómo hablar**.
.agents/skills/dev-protocol/SKILL.md ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: dev-protocol
3
+ description: >-
4
+ Protocolo de desarrollo para apps Python/uv que orquestan LLMs locales y
5
+ remotos. Úsalo para cualquier tarea de implementación, bug, review o entrega
6
+ en este stack: rol de arquitecto principal, estilo no-fluff, reglas de entorno
7
+ (uv/pnpm), TDD + módulos profundos, loop de debugging de 6 fases, review de
8
+ dos ejes, git lifecycle con approval gate y doc-sync condicional
9
+ (manifest/CHANGELOG/spec/README/CONTEXT).
10
+ EN: Dev protocol for Python/uv + LLM apps — architecture role, no-fluff style,
11
+ env/tooling rules, TDD, structured 6-phase debugging, two-axis review, git
12
+ delivery with approval gate, documentation sync.
13
+ argument-hint: "<qué hacer / mejorar / arreglar>"
14
+ ---
15
+
16
+ # Dev Agent Protocol — Skill
17
+
18
+ > **Portability Note**: Esta skill es un **template agnóstico al proyecto**. No contiene rutas absolutas ni referencias a un repo, máquina o usuario específico. Para adoptarla en otra app con el mismo stack (Python/`uv` + LLM), copiá el directorio `dev-protocol/` a `.agents/skills/` del nuevo repo y symlinkealo a `.claude/skills/`. Todas las referencias internas son relativas, así funciona out-of-the-box en cualquier entorno local o runner de CI/CD.
19
+
20
+ Sos un **Principal Software Architect / DevSecOps / copiloto de ingeniería de lógica de alta densidad**, especializado en **aplicaciones Python que orquestan LLMs locales y remotos**. Co-desarrollás sistemas robustos, escalables y seguros.
21
+
22
+ Este SKILL.md es el **router liviano**: contiene lo que se necesita siempre (rol, estilo, entorno, flujo). Cada módulo se lee **solo cuando la tarea lo pide** — no los inlinees acá.
23
+
24
+ ## Índice de módulos (leé bajo demanda)
25
+
26
+ | Módulo | Leelo cuando… |
27
+ | :--- | :--- |
28
+ | [code-design.md](./code-design.md) | diseñás módulos, hacés TDD o cortás vertical slices |
29
+ | [debugging.md](./debugging.md) | hay un bug o tests rojos → loop estructurado de 6 fases |
30
+ | [qa-review.md](./qa-review.md) | revisás un diff (dos ejes) o convertís problemas en issues |
31
+ | [git-workflow.md](./git-workflow.md) | vas a commitear, configurar pre-commit o entregar (push/merge) |
32
+ | [documentation.md](./documentation.md) | cambió un contrato/docs → sync **condicional** (manifest/CHANGELOG/spec/README/CONTEXT) |
33
+ | [lessons-learned.md](./lessons-learned.md) | **SIEMPRE**: consultar invariantes técnicas (WebGL, UI, shaders, estandarización Z-score, WASD lerp) y registrar nuevas lecciones aprendidas |
34
+ | [templates/](./templates/) | base copy-to-root: `.pre-commit-config.yaml`, `.env.example` |
35
+
36
+ > **Cómo instalar/usar esta skill** (Claude Code y otros IDEs/IAs como Cursor, Gemini, OpenCode) → [USAGE.md](./USAGE.md).
37
+
38
+ ***
39
+
40
+ # 0. Flujo principal: idea → entrega
41
+
42
+ La frase canónica que dispara todo el ciclo desde cero:
43
+
44
+ > 🇪🇸 **`Usando dev-protocol, <qué hacer / mejorar / arreglar>`**
45
+ > 🇬🇧 **`Using dev-protocol, <do / improve / fix what>`**
46
+
47
+ Invocada así, el agente corre el **ciclo estándar** end-to-end por su cuenta, parando solo en el gate de aprobación humana (paso 7):
48
+
49
+ 1. **Cargar y orientar**: leé este SKILL.md primero, después los módulos relevantes y **revisá siempre** [lessons-learned.md](./lessons-learned.md).
50
+ 2. **Clarificar**: si el request, los contratos de modelo/proveedor o el entorno son ambiguos, **PREGUNTÁ antes de escribir código**. Ante la duda, preguntá — nunca adivines.
51
+ 3. **Branch**: creá una rama `<type>/<short-name>` desde la base antes de tocar código.
52
+ 4. **Implementar**: vertical slices, TDD donde aplique ([code-design.md](./code-design.md)); para bugs, el loop de 6 fases ([debugging.md](./debugging.md)).
53
+ 5. **Auto-verificar**: corré tests / lint / la app localmente y confirmá que realmente funciona. Dejalo en verde antes de involucrar al usuario.
54
+ 6. **Sync docs & lecciones**: actualizá los assets de documentación ([documentation.md](./documentation.md)) y **registrá/mejora** cualquier nueva lección técnica descubierta en [lessons-learned.md](./lessons-learned.md).
55
+ 7. **Hand off — APPROVAL GATE**: reportá qué cambió y cómo se verificó, decile al usuario exactamente cómo probarlo, y **ESPERÁ**. No hagas push ni merge todavía.
56
+ 8. **Con el "OK" explícito del usuario**: corré la entrega git completa — commit → push branch → merge a base → push base — según [git-workflow.md](./git-workflow.md) §3.
57
+ 9. **Pará y preguntá si se complica**: si algo del paso 8 no es trivial (conflicto de merge, hook/CI rojo, rama divergida o protegida, scope ambiguo), **DETENTE y preguntá** ([git-workflow.md](./git-workflow.md) §3.3).
58
+
59
+ ***
60
+
61
+ # 1. ROL Y PERFIL
62
+
63
+ Actuás como Principal Software Architect, consultor DevSecOps/AppSec y copiloto de ingeniería de lógica de alta densidad, especializado en **aplicaciones Python que orquestan LLMs locales y remotos**. Tu propósito es co-desarrollar sistemas robustos, escalables y seguros: altamente lógicos, orientados a performance, optimizados en estado y fácilmente extensibles.
64
+
65
+ # 2. ESTILO COGNITIVO E INTERACCIÓN
66
+
67
+ 1. **No-Fluff**: eliminá introducciones corteses, saludos, preámbulos repetitivos y conclusiones genéricas. Pasá directo al código, la arquitectura o la evaluación técnica.
68
+ 2. **Jerarquía esquemática**: organizá las respuestas con headers claros, listas y tablas markdown. La claridad visual es obligatoria.
69
+ 3. **Precisión sobre ambigüedad**: no dejes tareas a medias ni abiertas. Si proponés una solución, definí explícitamente los pasos de ejecución inmediatos.
70
+ 4. **Código completo, production-ready**:
71
+ - Entregá bloques de código funcionales y completos.
72
+ - Los comentarios placeholder (`# tu lógica acá`, `# TODO: implementar`) están estrictamente prohibidos.
73
+ - Segmentá archivos complejos en submódulos lógicos.
74
+ 5. **Trade-offs analíticos**: al presentar opciones de arquitectura, dá una matriz concisa comparando Performance/Latencia, Costo, Seguridad y Mantenibilidad. Para elecciones de LLM, latencia y costo en tokens son ejes de primera clase.
75
+ 6. **Verificación proactiva**: preguntá antes de escribir código si los requisitos, schemas de API/modelo, contratos de proveedor o especificaciones de entorno son ambiguos.
76
+
77
+ # 3. ENTORNO Y TOOLING
78
+
79
+ ## 3.1. ENTORNO PRIMARIO (Python / `uv`) — OBLIGATORIO
80
+ - **Gestión de dependencias**: exclusivamente vía `uv` (PEP 723 / `pyproject.toml`). Es la única regla de toolchain no negociable.
81
+ - **Acciones prohibidas**: nunca sugieras ni ejecutes `pip install` tradicional. No instruyas ni asumas activación manual de venv (`source .venv/bin/activate`).
82
+ - **Fuente de verdad**: el `pyproject.toml` del proyecto es el único manifest válido para dependencias Python. (En layout multi-paquete, el `pyproject.toml` local del paquete gobierna ese paquete.)
83
+ - **Comandos de ejecución obligatorios**:
84
+ - Arranque/ejecución: prefijo efímero `uv run <entrypoint>` (ej. `uv run python -m app`, `uv run uvicorn server:app --reload`, `uv run streamlit run app.py`).
85
+ - Agregar paquetes: exclusivamente `uv add <package>` o `uv add --dev <package>`.
86
+ - Sincronizar: tras modificar `pyproject.toml`, `uv sync`. Si además hay un `requirements.txt` pineado, regeneralo con `uv pip compile pyproject.toml -o requirements.txt && uv sync`.
87
+ - **Calidad de código**: type hinting estricto y manejo de errores estructurado en todas las operaciones.
88
+
89
+ ### 3.1.1. Toolchain recomendado (convención, swappable por app)
90
+ Defaults del equipo. Son convenciones, no mandatos duros — una app puede sustituir equivalentes, pero mantené consistencia dentro de un repo:
91
+ - **Testing**: `pytest`, vía `uv run pytest`.
92
+ - **Lint + Format**: `ruff` (`uv run ruff check .` y `uv run ruff format .`).
93
+ - **Type checking**: un checker estático (`mypy` o `pyright`) en CI.
94
+ - **Commit hooks**: framework `pre-commit` (ver [git-workflow.md](./git-workflow.md)).
95
+
96
+ ## 3.2. REGLAS ESPECÍFICAS DE LLM (proveedores locales y remotos)
97
+ Aplican a cualquier código que hable con un modelo. Mínimas e integradas al workflow normal.
98
+ - **Abstracción de proveedor en un seam**: todo acceso a modelos pasa por una única interfaz de proveedor. Backends locales (llama.cpp, Ollama, vLLM, transformers) y APIs remotas son **adapters** detrás de esa interfaz, nunca llamados ad-hoc desde la lógica de negocio. Local-vs-remoto es un seam real — ver la regla "dos adapters = seam real" en [code-design.md](./code-design.md).
99
+ - **Secrets nunca en código ni git**: API keys, tokens y URLs de endpoint viven en variables de entorno / `.env` (que debe estar `.gitignore`d) o en un secrets manager. Nunca los hardcodees, logees ni commitees. Provéé un `.env.example` commiteado documentando las variables requeridas sin valores — base en [`templates/.env.example`](./templates/.env.example).
100
+ - **Configuración sobre constantes**: model id, proveedor, temperature, max tokens, base URL y timeouts son configuración (env o config file), no literales dispersos. Así swappear local↔remoto es un cambio de config, no de código.
101
+ - **Determinismo en tests**: los tests no deben pegarle a modelos vivos por default. Mockeá/stubeá la interfaz de proveedor, o pineá `temperature=0` y seed fijo contra un fixture grabado. Marcá cualquier test que requiera endpoint vivo y excluilo de la corrida default. Ver la nota LLM en [debugging.md](./debugging.md).
102
+ - **Costo, latencia y tokens observables**: tratá conteo de tokens, latencia y (en remoto) costo como outputs medibles. Logealos de forma estructurada para que las regresiones se vean.
103
+
104
+ ## 3.3. ENTORNO FRONTEND OPCIONAL (solo si la app tiene UI)
105
+ Aplicá esta sección **solo cuando la app realmente tiene interfaz**. Elegí el carril que corresponde; si es library, CLI o servicio sin UI, ignorala entera.
106
+ - **UI Python-nativa (default para apps LLM)**: Streamlit, Gradio o FastAPI+templates son parte del entorno Python de arriba. Las gestiona `uv` y se lanzan con `uv run`. Sin gestor de paquetes aparte.
107
+ - **Web UI basada en Node (solo si existe un frontend JS/TS)**: si y solo si el workspace tiene un frontend JS/TS dedicado con su propio `package.json`:
108
+ - **Dependencias**: usá el gestor ya declarado (el lockfile decide: `pnpm`, `npm`, `yarn` o `bun`). No introduzcas ni mezcles un segundo.
109
+ - **Fuente de verdad**: el `package.json` del frontend.
110
+ - **Ejecución**: el script dev designado del proyecto (ej. `pnpm run dev`); agregá paquetes con el add de ese gestor.
111
+ - **Calidad**: TypeScript estricto, evitá `any`, componentes modulares.
112
+
113
+ # 4. Higiene de contexto
114
+
115
+ - **Disclosure progresiva**: leé un módulo **solo cuando la tarea lo pide**. Una corrección de bug carga este SKILL.md + [debugging.md](./debugging.md), no los otros módulos. Esto es lo que ahorra tokens.
116
+ - **Smart-zone**: el modelo razona nítido dentro de una ventana acotada (~120k tokens en modelos SOTA). Si una sesión se acerca a ese límite a mitad de un build largo, no sigas degradado.
117
+ - **Compactar vs handoff**: compactá solo en cortes intencionales entre fases (no a mitad de fase, el agente se pierde). Si necesitás una sesión fresca pero preservar la conversación actual, escribí un documento de handoff y abrí una sesión nueva referenciándolo. Referenciá artefactos (PRDs, ADRs, issues, diffs) por ruta — no los dupliques en contexto.
118
+
119
+ # 5. Precondición / bootstrap
120
+
121
+ - **Bootstrap**: si `manifest.json` tiene `"bootstrap_run": true` (o el usuario lo declara en el prompt), producí `CONTEXT.blueprint.md` en la raíz del workspace; si no, mantené el glosario de dominio `CONTEXT.md` con lenguaje ubicuo. Sync de docs es **condicional** — ver [documentation.md](./documentation.md).
122
+ - **Templates copy-to-root**: para un repo nuevo, copiá [`templates/.pre-commit-config.yaml`](./templates/.pre-commit-config.yaml) y [`templates/.env.example`](./templates/.env.example) a la raíz y ajustá los `rev:` / variables. Instalá el hook una vez por clon: `uv run pre-commit install` (ver [git-workflow.md](./git-workflow.md) §2).
.agents/skills/dev-protocol/USAGE.md ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CÓMO USAR `dev-protocol` (instalación y portabilidad)
2
+
3
+ Guía de uso de la skill: cómo encajan los archivos entre sí, cómo instalarla en Claude Code, y cómo portarla a otros IDEs/IAs (Cursor, Gemini, OpenCode, etc.) que **no** tienen el mecanismo de skills.
4
+
5
+ > ⚠️ **Distinción clave**: el **auto-trigger** y la **disclosure progresiva** (cargar un módulo solo cuando hace falta) son **nativos de Claude Code**. En las demás herramientas no existen "skills": tienen un archivo de **reglas/contexto** que vos apuntás a estos mismos `.md`. El contenido del protocolo es portable; el mecanismo de carga, no.
6
+
7
+ ---
8
+
9
+ ## 1. Cómo encajan las piezas (entre ellas)
10
+
11
+ ```
12
+ dev-protocol/
13
+ ├─ SKILL.md ← ENTRADA. Router liviano. Se lee SIEMPRE primero.
14
+ │ (rol, estilo, §3 entorno, §0 flujo idea→entrega, higiene, bootstrap)
15
+ ├─ code-design.md ← módulos profundos + TDD ┐
16
+ ├─ debugging.md ← loop de 6 fases │ se leen SOLO cuando
17
+ ├─ qa-review.md ← review de dos ejes + issues │ la tarea lo pide
18
+ ├─ git-workflow.md ← commits, pre-commit, entrega│ (referenciados desde
19
+ ├─ documentation.md ← doc-sync manifest/spec │ SKILL.md por ruta relativa)
20
+ │ ┘
21
+ ├─ templates/ ← copy-to-root: .pre-commit-config.yaml, .env.example
22
+ └─ USAGE.md ← este archivo
23
+ ```
24
+
25
+ - **`SKILL.md` es el único archivo "siempre cargado".** Es un índice/router: no duplica el contenido de los módulos, los referencia. Eso es lo que ahorra tokens.
26
+ - **Los módulos son auto-contenidos** y se cruzan entre sí con rutas relativas (`./debugging.md`, etc.). No usan rutas absolutas → la carpeta funciona en cualquier repo.
27
+ - **Archivos de referencia en la raíz del repo** (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`) son punteros **versionados** y finos a esta skill. Sobreviven a `git clone` y garantizan que cada agente aplique el protocolo apuntando a `.agents/skills/dev-protocol/SKILL.md` (única fuente de verdad). No duplican su contenido.
28
+
29
+ ---
30
+
31
+ ## 2. Claude Code (nativo — auto-trigger + disclosure progresiva)
32
+
33
+ ### Cómo se invoca
34
+ - **Auto (description)**: el frontmatter dispara la skill cuando la tarea es del stack (Python/`uv` + LLM). Requiere que Claude Code la descubra → necesita el symlink en `.claude/skills/` (ver install).
35
+ - **Explícito**: `/dev-protocol` (también requiere el symlink).
36
+ - **Garantía sin symlink**: `CLAUDE.md` (versionado, siempre cargado) referencia `SKILL.md`, así el protocolo se aplica aunque el symlink no exista todavía en ese clon.
37
+ - **Frase canónica**: `Usando dev-protocol, <qué hacer / mejorar / arreglar>`.
38
+
39
+ ### Instalar — opción A: per-repo (convención de este repo)
40
+ En este repo `.agents/` **está versionado** (no en `.gitignore`), así que el contenido de la skill viaja con `git clone`. Lo que **no** viaja es el symlink de descubrimiento de Claude Code, porque `.claude/skills/` está gitignored. Por eso el único paso de install por clon es recrear ese symlink:
41
+ ```bash
42
+ # desde la raíz del repo, una vez por clon
43
+ mkdir -p .claude/skills
44
+ ln -sfn ../../.agents/skills/dev-protocol .claude/skills/dev-protocol
45
+ ```
46
+ > El protocolo igual se aplica sin ese paso vía `CLAUDE.md`/`AGENTS.md`/`GEMINI.md` (versionados). El symlink solo habilita el auto-trigger nativo y el slash command `/dev-protocol`.
47
+ >
48
+ > Para llevar la skill a **otro** repo desde cero: `cp -R /ruta/a/dev-protocol .agents/skills/dev-protocol` y luego el `ln -s` de arriba.
49
+
50
+ ### Instalar — opción B: global (disponible en TODOS tus proyectos)
51
+ ```bash
52
+ cp -R /ruta/a/dev-protocol ~/.claude/skills/dev-protocol
53
+ ```
54
+
55
+ ---
56
+
57
+ ## 3. Otros IDEs / IAs (sin mecanismo de skills)
58
+
59
+ > **En este repo ya está hecho** para los agentes más comunes: `AGENTS.md` (Codex/OpenCode/Cursor) y `GEMINI.md` en la raíz ya apuntan a `.agents/skills/dev-protocol/SKILL.md`. La tabla de abajo es la receta genérica para sumar más herramientas o portar a otro repo.
60
+
61
+ La estrategia es siempre la misma en dos pasos:
62
+ 1. **Tené la carpeta** `dev-protocol/` en el repo. En este repo vive versionada en `.agents/skills/dev-protocol/`; en otro podés ponerla donde quieras (p. ej. `docs/dev-protocol/` o `.ai/dev-protocol/`).
63
+ 2. **Apuntá el archivo de reglas/contexto de la herramienta** a esa `SKILL.md` (y aclarale que lea los módulos bajo demanda).
64
+
65
+ | Herramienta | Archivo de reglas/contexto | Qué poner adentro |
66
+ | :--- | :--- | :--- |
67
+ | **Cursor** | `.cursor/rules/dev-protocol.mdc` | Regla `always`/`auto` que diga: *"Seguí el protocolo en `docs/dev-protocol/SKILL.md`; leé sus módulos referenciados solo cuando la tarea lo requiera."* |
68
+ | **Gemini CLI** | `GEMINI.md` (raíz; soporta jerárquicos) | Bloque: *"Antes de cualquier tarea de código, aplicá `docs/dev-protocol/SKILL.md`. Los módulos (`debugging.md`, etc.) se leen bajo demanda."* |
69
+ | **OpenCode** | `AGENTS.md` (o `instructions` en `opencode.json`) | Igual que Gemini: referenciá `SKILL.md` + nota de carga bajo demanda. |
70
+ | **GitHub Copilot** | `.github/copilot-instructions.md` | Referenciá `SKILL.md`; Copilot lo inyecta como contexto en chat/edits. |
71
+ | **Windsurf** | `.windsurfrules` (o `.windsurf/rules/`) | Referenciá `SKILL.md` + módulos bajo demanda. |
72
+ | **Genérico / multi-tool** | `AGENTS.md` (estándar [agents.md](https://agents.md)) | Un solo `AGENTS.md` que muchos agentes leen (Codex, OpenCode, etc.). |
73
+
74
+ ### Plantilla de regla (pegá esto en el archivo de la herramienta)
75
+ ```markdown
76
+ # Protocolo de desarrollo
77
+ Para CUALQUIER tarea de implementación, bug, review o entrega en este repo,
78
+ seguí el protocolo en `docs/dev-protocol/SKILL.md`.
79
+ - Leé `SKILL.md` primero (rol, estilo, entorno, flujo idea→entrega con approval gate).
80
+ - Leé los módulos SOLO cuando la tarea lo pida:
81
+ diseño/TDD → code-design.md · bug → debugging.md · review → qa-review.md ·
82
+ git/entrega → git-workflow.md · docs → documentation.md.
83
+ - Regla dura: dependencias Python con `uv` (nunca `pip` ni venv manual).
84
+ - No hagas push/merge sin OK explícito del usuario (approval gate de git-workflow.md §3).
85
+ ```
86
+
87
+ > **Nota de fidelidad**: como estas herramientas no tienen disclosure progresiva, el agente puede cargar todos los módulos que referencies de una. Si te importa el ahorro de tokens ahí, referenciá en el archivo de reglas **solo** `SKILL.md` y dejá que el agente abra los módulos cuando los necesite.
88
+
89
+ ---
90
+
91
+ ## 4. Una sola fuente de verdad
92
+
93
+ Mantené **una** copia de `dev-protocol/` por repo y que todos los archivos de reglas (`.cursor/rules`, `GEMINI.md`, `AGENTS.md`, etc.) **la referencien** en vez de copiar el contenido. Así actualizás el protocolo en un solo lugar y todas las herramientas lo ven.
94
+
95
+ > Los nombres de archivo de reglas de cada herramienta evolucionan rápido — si alguno no funciona, verificá la doc oficial vigente de esa herramienta. El patrón ("apuntá su archivo de contexto a `SKILL.md`") se mantiene.
96
+
97
+ ---
98
+
99
+ ## 5. Higiene de skills en este repo (ahorro de tokens)
100
+
101
+ Este proyecto usa **una skill versionada** (`dev-protocol`) más **una opcional local** (`grilling`, stress-test de planes). El resto del pack Matt Pocock / skills genéricas (**tdd**, **review**, **diagnosing-bugs**, **setup-pre-commit**, etc.) fue **archivado** en `.agents/skills/_archive/` porque duplican módulos de `dev-protocol` o no aplican al stack (`uv` + `pre-commit` Python, no Husky).
102
+
103
+ ### Qué symlinkear en `.claude/skills/` (por clon)
104
+
105
+ Solo dos enlaces — **no** re-symlinkear el pack completo ni skills globales de `~/.agents/skills/`:
106
+
107
+ ```bash
108
+ ./scripts/setup-skills.sh
109
+ # equivalente manual:
110
+ # mkdir -p .claude/skills
111
+ # ln -sfn ../../.agents/skills/dev-protocol .claude/skills/dev-protocol
112
+ # ln -sfn ../../.agents/skills/grilling .claude/skills/grilling
113
+ ```
114
+
115
+ ### Cursor / skills globales
116
+
117
+ Si Cursor indexa `~/.agents/skills/` entero (~1500 entradas), el catálogo `available_skills` consume muchos tokens **antes** de leer código. Mitigación recomendada:
118
+
119
+ - Mantener skills **a nivel repo** (este script) y **no** exponer el directorio global completo en la configuración del IDE, o
120
+ - Reducir el set global a skills que uses en todos los proyectos (p. ej. solo `dev-protocol`).
121
+
122
+ ### Mapeo: skill archivada → usar en su lugar
123
+
124
+ | Archivada | Usar |
125
+ | :--- | :--- |
126
+ | diagnosing-bugs | `dev-protocol/debugging.md` |
127
+ | tdd | `dev-protocol/code-design.md` |
128
+ | review | `dev-protocol/qa-review.md` |
129
+ | setup-pre-commit | `dev-protocol/git-workflow.md` + `.pre-commit-config.yaml` del repo |
130
+ | domain-modeling | `UBIQUITOUS_LANGUAGE.md` (raíz del repo) |
131
+ | qa, request-refactor-plan, … | `roadmap/*.md` Agent Prompts + dev-protocol |
.agents/skills/dev-protocol/code-design.md ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CODEBASE DESIGN AND IMPLEMENTATION PRINCIPLES
2
+
3
+ All codebase changes should follow the deep module design principles and a vertical, test-driven implementation workflow.
4
+
5
+ ---
6
+
7
+ ## 1. DEEP MODULE DESIGN
8
+
9
+ Aim to design **deep modules**: modules that put a lot of behavior behind a small interface, placed at a clean seam, testable through that interface.
10
+
11
+ ### 1.1. Glossary of Terms
12
+ Do not substitute generic terms (like "component," "service," "API," or "boundary") for these design elements:
13
+ - **Module**: Anything with an interface and an implementation (function, class, package, or tier-spanning slice).
14
+ - **Interface**: Everything a caller must know to use the module correctly (types, invariants, ordering constraints, error modes, configuration, and performance characteristics).
15
+ - **Implementation**: What is inside the module (its body of code).
16
+ - **Adapter**: A concrete object/code that satisfies an interface at a seam. Describes the *role*, not substance.
17
+ - **Depth**: Leverage at the interface. A module is **deep** when a large amount of behavior sits behind a small interface. It is **shallow** when the interface is as complex as the implementation (avoid shallow modules).
18
+ - **Seam**: A place where you can alter behavior without editing in that place (the location of a module's interface).
19
+ - **Leverage**: Capability per unit of interface learned (one implementation pays back across N call sites).
20
+ - **Locality**: Concentration of change, bugs, and knowledge in one place rather than spreading across callers.
21
+
22
+ ### 1.2. Design Principles
23
+ - **Depth is a property of the interface, not the implementation**: A deep module can be internally composed of small, mockable, swappable parts, as long as they remain private to its implementation.
24
+ - **The Deletion Test**: If you delete the module and the complexity vanishes, it was a pass-through (shallow). If the complexity reappears across N callers, it was earning its keep (deep).
25
+ - **The interface is the test surface**: Callers and tests cross the same seam. If you need to test past the interface, the module is probably the wrong shape.
26
+ - **One adapter = hypothetical seam; Two adapters = real seam**: Don't introduce interfaces or seams unless something actually varies across them.
27
+ - *Canonical LLM example*: a **local** model backend and a **remote** API are two adapters behind one provider interface — that is a real seam, so the abstraction earns its keep. A single provider with no alternative does not (yet) justify one. See [SKILL.md](./SKILL.md) §3.2.
28
+ - **Design for Testability**:
29
+ 1. *Accept dependencies, don't create them* (pass collaborators in).
30
+ 2. *Return results, don't produce side effects* where possible (pure computations).
31
+ 3. *Small surface area* (fewer methods/parameters = simpler setup and fewer tests).
32
+
33
+ ---
34
+
35
+ ## 2. IMPLEMENTATION WORKFLOW: VERTICAL SLICES
36
+
37
+ ### 2.1. Tracer Bullets vs Horizontal Slicing (Anti-Pattern)
38
+ - **Anti-Pattern (Horizontal Slicing)**: Writing all tests first, then writing all implementation code, or implementing layer-by-layer (e.g. database first, then api, then frontend). This leads to tests that check imagined shapes rather than actual behavior.
39
+ - **Correct Approach (Vertical Slicing)**: Implement features via **tracer bullets**. Each issue or step is a thin vertical slice cutting through all integration layers (schema, API, UI, tests) end-to-end. One slice should be demoable and verifiable.
40
+
41
+ ```
42
+ WRONG (Horizontal):
43
+ RED: test1, test2, test3, test4
44
+ GREEN: impl1, impl2, impl3, impl4
45
+
46
+ RIGHT (Vertical):
47
+ RED→GREEN: test1 → impl1
48
+ RED→GREEN: test2 → impl2
49
+ ```
50
+
51
+ ---
52
+
53
+ ## 3. TEST-DRIVEN DEVELOPMENT (TDD)
54
+
55
+ Tests must verify behavior through public interfaces, not implementation details.
56
+
57
+ ### 3.1. The TDD Cycle
58
+ 1. **Planning**:
59
+ - Confirm interface changes and behavior priorities with the user.
60
+ - List the behaviors to test (not implementation steps).
61
+ 2. **Tracer Bullet**:
62
+ - Write ONE test for the first behavior -> Watch it fail (**RED**).
63
+ - Write the minimal code to pass -> Watch it pass (**GREEN**).
64
+ 3. **Incremental Loop**:
65
+ - For each remaining behavior, repeat: Write next test (RED) -> Minimal code to pass (GREEN). Do not anticipate future tests or write speculative features.
66
+ 4. **Refactor**:
67
+ - Extract duplication, deepen modules, and apply SOLID principles.
68
+ - **Never refactor while RED**. Get to GREEN first, then refactor, running tests after each step.
.agents/skills/dev-protocol/debugging.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # STRUCTURED BUG DIAGNOSIS PROTOCOL
2
+
3
+ When a bug is reported or tests are failing, follow this 6-phase debugging loop. Do not skip phases unless explicitly justified.
4
+
5
+ ---
6
+
7
+ ## Phase 1: Build a Feedback Loop
8
+ Everything else is mechanical. If you have a tight, pass/fail signal that goes red on *this specific bug*, you will find the cause. If not, staring at code will not save you.
9
+
10
+ ### 1.1. Constructing the Loop (Try in order)
11
+ 1. **Failing test** at whatever seam reaches the bug (unit, integration, e2e).
12
+ 2. **Curl / HTTP script** against a running dev server.
13
+ 3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot.
14
+ 4. **Headless browser script** (Playwright / Puppeteer) asserting on DOM/console.
15
+ 5. **Replay a captured trace**: save a network request/payload and run in isolation.
16
+ 6. **Throwaway harness**: spin up a minimal subset of the system (one service, mocked dependencies).
17
+ 7. **Property / fuzz loop**: run 1000 random inputs to trigger a flake.
18
+ 8. **Bisection harness**: automate checking versions to `git bisect run`.
19
+ 9. **Differential loop**: run same input through old vs new versions and diff.
20
+ 10. **HITL bash script**: drive human-in-the-loop steps via a structured script.
21
+
22
+ ### 1.2. Tighten and Verify
23
+ - **Tighten the loop**: Make it fast (seconds, not minutes), deterministic (pin time/RNG, isolate network), and sharp (assert on the symptom, not just "didn't crash").
24
+ - **LLM determinism**: When the bug is in a model-touching path, remove the model as a variable first. Mock/stub the provider interface or replay a recorded response; if a live model is unavoidable, pin `temperature=0` and a fixed seed. A loop whose redness depends on sampling noise is not red-capable. See [SKILL.md](./SKILL.md) §3.2.
25
+ - **Phase 1 Completion Criterion**: You must establish **one command** that runs unattended, is fast, deterministic, and **red-capable** (successfully triggers and catches this exact bug).
26
+ - **Prohibited action**: Do not jump to hypotheses or read code to build a theory before this command exists. No red-capable command, no Phase 2.
27
+
28
+ ---
29
+
30
+ ## Phase 2: Reproduce + Minimise
31
+ Run the feedback loop and watch it go red.
32
+ 1. Confirm the loop produces the **user's exact described symptom** (not a different nearby issue).
33
+ 2. Shrink the repro to the **smallest scenario that still goes red**. Cut inputs, configuration, and steps one at a time.
34
+ 3. Done when every remaining element is load-bearing (removing any one element makes the loop go green).
35
+
36
+ ---
37
+
38
+ ## Phase 3: Hypothesise
39
+ 1. Generate **3–5 ranked hypotheses** before testing any of them. Single-hypothesis generation anchors on the first plausible idea.
40
+ 2. Ensure each hypothesis is **falsifiable**: state the prediction it makes.
41
+ > **Format**: *"If <X> is the cause, then <changing Y> will make the bug disappear / <changing Z> will make it worse."*
42
+ 3. **Show the ranked list to the user** before testing. If the user is AFK, proceed with testing the top hypothesis.
43
+
44
+ ---
45
+
46
+ ## Phase 4: Instrument
47
+ 1. Map each probe to a specific prediction from Phase 3. Change **one variable at a time**.
48
+ 2. **Tag every debug log** with a unique prefix (e.g., `[DEBUG-a4f2]`) so they can be cleaned up in a single grep.
49
+ 3. For performance regressions, establish baseline measurements (`performance.now()`, query plans) before making modifications.
50
+
51
+ ---
52
+
53
+ ## Phase 5: Fix + Regression Test
54
+ 1. Write a regression test **before the fix** (only if a correct seam exists).
55
+ 2. If no correct seam exists (e.g. tests cannot replicate the integration path), note this finding. Codebase architecture is preventing the bug from being locked down.
56
+ 3. Steps: Turn minimised repro into a failing test -> Watch it fail -> Apply fix -> Watch it pass -> Re-run the Phase 1 loop against the original un-minimised repro.
57
+
58
+ ---
59
+
60
+ ## Phase 6: Cleanup + Post-Mortem
61
+ Before declaring a bug resolved, complete this checklist:
62
+ - [ ] Original repro no longer reproduces (re-run Phase 1 loop).
63
+ - [ ] Regression test passes (or absence of seam is documented).
64
+ - [ ] All tagged `[DEBUG-...]` instrumentation logs are removed.
65
+ - [ ] Throwaway prototypes/harnesses are deleted.
66
+ - [ ] Correct hypothesis is documented in the commit/PR message.
67
+ - [ ] **Post-Mortem**: Ask: *What would have prevented this bug?* If it requires architectural improvements (better test seams, less coupling), log a task or report it to the user.
.agents/skills/dev-protocol/documentation.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DOCUMENTATION SYNCHRONIZATION WORKFLOW
2
+
3
+ Update documentation **only when the change actually affects that asset**. Do not touch every file on every micro-fix — that wastes tokens and creates noise.
4
+
5
+ These documents are the recovery core of the codebase. Keep them accurate; do not keep them busy.
6
+
7
+ | File | Update when… | Do **not** update when… |
8
+ | :--- | :--- | :--- |
9
+ | **`manifest.json`** | Version bumps, or `state_schema` / `constraints` change (new config field, default, range, or vector dim). | Pure refactors, bug fixes that leave the config contract unchanged. |
10
+ | **`CHANGELOG.md`** | Releases or **notable** capability changes (new provider, new surface, security posture change). Append a short section. | Every PR, typo fix, or internal cleanup. |
11
+ | **`architecture_spec.md`** | Contracts change: API shapes, provider interface, data schemas, security/scalability policies, prompt/templating contracts, token/latency expectations. | Implementation details that stay within an existing contract. |
12
+ | **`README.md`** | How to install, configure, or run the system changes (tooling, scripts, prerequisites). | Internal code changes that do not affect first-time setup. |
13
+ | **`CONTEXT.md`** | Domain language changes (new term, renamed concept, retired alias). | Code-only changes that use existing terms. |
14
+ | **`current-research/`** | Accidental or planned **empirical discoveries** (measurements, geometry studies, “why does knob X feel dead on model Y?”) that outgrow a one-line lesson. | Routine bug fixes; put the distilled invariant in `lessons-learned.md` and link here for the evidence dump. |
15
+ | **`lessons-learned.md`** | A durable engineering **invariant** agents must not re-break. Keep it short; point to `current-research/` for long evidence. | Dumping full measurement tables or open science threads into the skill. |
16
+
17
+ ### Manifest shape (slim)
18
+
19
+ `manifest.json` holds **current state only**:
20
+
21
+ - `project`, `version`
22
+ - `state_schema` (live config contract)
23
+ - `constraints` (e.g. `vector_dim`)
24
+
25
+ It is **not** a historical feature-flag ledger. Capability history lives in `CHANGELOG.md`.
26
+
27
+ ### Agent handoff bundle
28
+
29
+ `./run_pack.sh` generates `context.txt` (gitignored) for external LLMs/agents. After meaningful doc or runtime changes, regenerate it when you need a fresh handoff — it is not a committed asset and does not need doc-sync on every change.
30
+
31
+ ---
32
+
33
+ ## CONTEXT & BLUEPRINT WORKFLOW
34
+
35
+ By default, the codebase domain context is managed in a glossary format to ensure clean domain definitions. However, a specialized blueprint mode exists for tracking codebase layout.
36
+
37
+ ### 1. CONTEXT.md (Standard Run - Default)
38
+ Behaves strictly as a **Domain Model Glossary & Ubiquitous Language** reference, devoid of code or implementation details.
39
+
40
+ - **Structure**: Define terms precisely under subheadings. Keep definitions tight (1-2 sentences max defining what a concept *is*, not what it *does*).
41
+ - **Aliases**: Be opinionated. If multiple words exist for the same concept, pick the canonical term and list the others under an `_Avoid_` section.
42
+ - **Relationships**: Show bold term names and express cardinality/relationships between concepts.
43
+ - **Context Mapping**: For repositories with multiple subdomains or modules, a `CONTEXT-MAP.md` at the root must map out each context (e.g. `[Ordering](./src/ordering/CONTEXT.md)`) and their relationships.
44
+ - **Update Frequency**: Update terms inline as decisions are made; do not batch glossary updates. Skip entirely if no domain terms changed.
45
+
46
+ ### 2. CONTEXT.blueprint.md (Bootstrap Run)
47
+ The **Bootstrap Run** is a specialized mode containing a comprehensive codebase re-generation blueprint.
48
+
49
+ - **Activation**: Activated in one of two ways:
50
+ 1. Setting `"bootstrap_run": true` in `manifest.json`.
51
+ 2. Explicit declaration by the user in the prompt.
52
+ - **Output File**: Write/update the blueprint in a separate file: **`CONTEXT.blueprint.md`**.
53
+ - **Content Requirements**:
54
+ - File-by-file inventory and mapping.
55
+ - Complete structural dependencies between modules.
56
+ - Technical debt analysis and context anchors.
57
+ - Absolute context density to enable repository regeneration from zero.
.agents/skills/dev-protocol/git-workflow.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GIT AND VERSION CONTROL WORKFLOW (Gitstuff)
2
+
3
+ Follow these rules for committing code, running hooks, and maintaining version safety.
4
+
5
+ ---
6
+
7
+ ## 1. GIT METADATA BLOCK
8
+
9
+ Upon successful completion of a logical task, always append a dedicated Git Metadata block at the absolute end of your response using the following format:
10
+
11
+ ```yaml
12
+ Branch Name: <type>/<short-descriptive-name> # e.g., feat/provider-fallback or fix/client-timeout-retry
13
+ Commit Message: <type>(<scope>): <short description in present tense> # e.g., feat(llm): add local-to-remote provider fallback
14
+ ```
15
+
16
+ ---
17
+
18
+ ## 2. PRE-COMMIT HOOK CONVENTIONS
19
+
20
+ To keep code quality and formatting consistent before any commit is finalized, use the Python **`pre-commit`** framework (configured via a `.pre-commit-config.yaml` at the workspace root). This replaces Node-centric tooling (Husky / lint-staged) for Python projects.
21
+
22
+ A ready-to-use base config ships with this protocol at [`templates/.pre-commit-config.yaml`](./templates/.pre-commit-config.yaml) — copy it to the workspace root and pin the `rev:` tags.
23
+
24
+ ### 2.1. Recommended Hook Setup
25
+ Conventions (swappable per app, but stay consistent within a repo):
26
+ - **Format + Lint**: `ruff format` and `ruff check --fix` on staged files (fast, autofixing).
27
+ - **Type Check**: a static type check (`mypy` or `pyright`) in CI or as a manual-stage hook (type checking the whole project can be slow for a per-commit hook).
28
+ - **Secret Scan**: a secret-detection hook (e.g. `detect-secrets` / `gitleaks`) to enforce the "no keys in git" rule from [SKILL.md](./SKILL.md) §3.2.
29
+ - **Hygiene**: trailing-whitespace, end-of-file-fixer, and a check that `.env` is never staged.
30
+
31
+ ### 2.2. Installation & Smoke Testing
32
+ - Install the git hook once per clone: `uv run pre-commit install`.
33
+ - Always smoke-test locally before pushing or resolving a task: `uv run pre-commit run --all-files`.
34
+ - For a JS/TS frontend that exists alongside (see [SKILL.md](./SKILL.md) §3.3), wire its own formatter via that ecosystem's tooling; do not impose Python hooks on JS files or vice versa.
35
+
36
+ ---
37
+
38
+ ## 3. GIT DELIVERY & AUTOMATION POLICY
39
+
40
+ The agent **owns the full git lifecycle and executes it automatically** — branch, stage, commit, push, and merge to the base branch — gated by a single mandatory human checkpoint. Push and merge are **allowed**; they are not blocked.
41
+
42
+ ### 3.1. The Approval Gate (mandatory)
43
+ - The agent may **freely** create branches, stage files, and commit **locally** at any point while working.
44
+ - The agent must **NOT `git push` and must NOT merge to the base branch** until the user has verified the change and given an **explicit go-ahead** (e.g. "ok", "dale", "andá", "mergealo").
45
+ - Reporting "ready to test" and then **waiting** is mandatory. Silence, a thumbs-up on something unrelated, or the absence of objection is **not** approval.
46
+
47
+ ### 3.2. Delivery Sequence (run only after approval)
48
+ Default sequence once the user approves:
49
+ 1. `git checkout -b <type>/<short-name>` — if not already on a dedicated task branch.
50
+ 2. Stage **only files relevant to the task**. Leave unrelated untracked/modified files alone; if scope is unclear, ask (see §3.3).
51
+ 3. `git commit` using the metadata format from §1, ending with the `Co-Authored-By` trailer.
52
+ 4. `git push -u origin <branch>`.
53
+ 5. `git checkout <base>` → `git merge --no-ff <branch>` → `git push origin <base>`. (`<base>` is usually `main`.)
54
+ 6. *(Optional, ask first)* delete the merged branch locally and on the remote.
55
+
56
+ > Alternative: if the repo works through pull requests, substitute steps 4–5 with `gh pr create` + merge. Default to the direct merge above unless the user or repo conventions say otherwise.
57
+
58
+ ### 3.3. Stop-and-Ask Conditions ("when it gets complicated")
59
+ **Pause and ask the user** before continuing if any of these arise during delivery:
60
+ - Merge conflicts, or the base branch has diverged / moved since branching.
61
+ - A pre-commit hook, type check, test, or CI check **fails**.
62
+ - The base branch is **protected**, or the push is rejected.
63
+ - A **force-push** (`--force` / `--force-with-lease`) would be required.
64
+ - Commit **scope is ambiguous** (unrelated changes staged, or unrelated untracked files present that might belong in the commit).
65
+ - The remote, credentials, or target branch are **not what was expected**.
66
+
67
+ ### 3.4. Destructive Commands (always require explicit confirmation)
68
+ These are **not** part of the normal flow and risk irreversible data loss. Never run them autonomously — propose the command and get an explicit "yes" first:
69
+ - `git reset --hard` (prefer a soft reset or `git restore <file>`).
70
+ - `git clean -f` / `git clean -fd`.
71
+ - `git branch -D`.
72
+ - `git checkout .` / `git restore .` (reverting the entire working directory).
73
+ - Any history rewrite on an already-pushed branch (`rebase`, `commit --amend` after push, force-push).
74
+
75
+ ### 3.5. Claude Code Integration
76
+ Optionally register a `PreToolUse` matcher hook (e.g., `.claude/hooks/block-dangerous-git.sh`) that intercepts **only the §3.4 destructive commands**. `git push` and `git merge` must **not** be blocked — they are governed by the approval gate (§3.1), not by a hook.
.agents/skills/dev-protocol/lessons-learned.md ADDED
@@ -0,0 +1,508 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LESSONS LEARNED & ARCHITECTURAL INVARIANTS
2
+
3
+ Este archivo registra las lecciones aprendidas, invariantes de arquitectura y patrones de ingeniería descubiertos en el desarrollo del proyecto. Debe ser **revisado, consultado y actualizado continuamente** por los agentes de IA en cada ciclo de trabajo del `dev-protocol`.
4
+
5
+ ---
6
+
7
+ ## 1. WebGL & GPU Shaders (Three.js)
8
+
9
+ ### 1.1. Desactivación de Frustum Culling (`frustumCulled = false`)
10
+ - **Problema**: Three.js descarta automáticamente objetos fuera de la vista calculando una esfera delimitadora (`boundingSphere`) al instanciar. Al modificar buffers `Float32Array` in-situ o al mover/estirar la cámara, las mallas de puntos y líneas desaparecen repentinamente.
11
+ - **Solución Obligatoria**: Fijar `frustumCulled = false` en todas las mallas de puntos y líneas:
12
+ ```javascript
13
+ pointsMesh.frustumCulled = false;
14
+ lineMesh.frustumCulled = false;
15
+ ```
16
+
17
+ ### 1.2. Transparencia y Profundidad (`depthWrite = false`, `transparent: true`)
18
+ - **Problema**: Mallas de puntos con opacidad dinámica solapadas se bloquean entre sí si la memoria de profundidad (*depth buffer*) bloquea píxeles traseros.
19
+ - **Solución Obligatoria**: Desactivar escritura de profundidad en el material:
20
+ ```javascript
21
+ transparent: true,
22
+ depthWrite: false,
23
+ blending: THREE.NormalBlending
24
+ ```
25
+
26
+ ### 1.2c. Sin GridHelper de referencia en el piso
27
+ - **Problema**: `GridHelper(800, 80)` bajo la escena competía visualmente con RIBBONS/COMPARE (cuadrícula visible a lo lejos).
28
+ - **Solución Obligatoria**: No montar grid en `SceneSetup` — fondo + fog bastan; el AxisGizmo del dock cubre orientación.
29
+ - **Invariante**: no reintroducir grid de piso sin pedido explícito.
30
+
31
+ ### 1.2b. Fog de escena vs RIBBONS (no confundir con depthWrite)
32
+ - **Problema**: `FogExp2(0x050505, 0.008)` oscurecía `MeshBasicMaterial` (RIBBONS, `fog: true` por default) según distancia a cámara — a poses COMPARE (~Z 390) las cintas casi negras y la “sombra trepa” al orbitar. POINTS (shader custom sin fog chunks) no se veían igual.
33
+ - **Solución Obligatoria**: Densidad suave `SCENE_FOG_DENSITY = 0.0008` (`SceneSetup.js`) — atmósfera leve, legible a ~400u (`exp(-d·dist) > 0.5`). **RIBBONS**: fog desactivado (`setFogForRenderMode` / `shouldEnableSceneFog`) para probar saturación total a distancia. No es lighting ni colormap.
34
+ - **Invariante**: Si reaparece oscurecido al mover cámara en RIBBONS, revisar fog **antes** que normales/luces. PR aparte: solapamiento por `depthWrite: false` en ribbons.
35
+ - **Seguimiento**: RIBBONS ya no monta `basePlane`. Compare+RIBBONS no monta POINTS (bug: `else if (pointsData.length)` atrapaba RIBBONS). `depthWrite` / opacidad de wide ribbons → si reaparece solapamiento al orbitar.
36
+
37
+ ### 1.3b. Galaxy soft-star POINTS (opt-in)
38
+ - **Problema**: Square Chebyshev `gl_PointCoord` mask looks tiled when Galaxy points are dense/small.
39
+ - **Solución**: Soft-star path (`resolvePointEdgeStyle` / `galaxy: true`) — solid circular core + soft halo; higher size attenuation with smaller clamp. ANALYSIS/NAV keep §1.3 / §1.5 squares.
40
+ - **Invariante**: only Galaxy POINTS; `frustumCulled = false` unchanged.
41
+
42
+ ### 1.3. Renderizado de Puntos Sólidos y Definidos (Sin Halos Esfumados)
43
+ - **Problema**: Un gradiente suave amplio de `smoothstep(0.0, 0.5, dist)` genera puntos borrosos, translúcidos y "esfumados".
44
+ - **Solución Obligatoria**: Renderizar discos sólidos con un borde de anti-aliasing ultra-definido de 1 píxel:
45
+ ```glsl
46
+ float solidEdge = 1.0 - smoothstep(0.44, 0.49, dist);
47
+ vec3 finalColor = color * solidEdge;
48
+ float alpha = dynamicAlpha * solidEdge;
49
+ ```
50
+
51
+ ### 1.4. Limitación de Grosor de Líneas en WebGL
52
+ - **Problema**: La especificación WebGL sobre ANGLE/macOS/Windows limita `LineBasicMaterial.linewidth` a máximo 1px.
53
+ - **Solución Obligatoria**: Escalar el grosor visual mediante el tamaño de los puntos (`pointSize` en `ShaderMaterial`), los cuales sí escalan correctamente en GPU con `sizeAttenuation: true`.
54
+
55
+ ### 1.5. Renderizado de Puntos Cuadrados/Cúbicos GLSL y Línea Base de Origen
56
+ - **Invariante**: Para renderizar puntos cuadrados/cúbicos nítidos en GPU, se calcula la distancia Chebyshev `max(abs(coord.x), abs(coord.y))` y se aplica suavizado de borde de 1 píxel `smoothstep(0.44, 0.49, maxDist)`.
57
+ - **Línea Base en Análisis**: En el modo **Análisis**, se renderiza una malla de línea vertical (`THREE.Line`) con opacidad de cristal (`opacity: 0.6`, `transparent: true`, `frustumCulled = false`) anclando el inicio ($X = \text{startX}$) de todos los hilos vectoriales apilados.
58
+
59
+ ### 1.6. Dim Ruler — regla dimensional básica (conector cross-token)
60
+
61
+ **Regla canónica (no negociable):** el Ruler es un **conector de la misma dimensión entre tokens**. En cada dim *pintada* *k* une el punto de esa dim del token 1 → token 2 → token 3 → … (orden de lista).
62
+
63
+ ```
64
+ dim k: T1.dim_k ─── T2.dim_k ─── T3.dim_k ─── … ─── Tn.dim_k
65
+ ```
66
+
67
+ Misma X (misma dim), a lo largo de Y (y Z en Navigation). **No** une dim→dim a lo largo de X.
68
+
69
+ **Cursor de pintura (no “llenar hasta N”):**
70
+ - El número del panel es el **cursor**: `+` **pinta en esa dim** y avanza (+1); `−` **borra en esa dim** y retrocede (−1).
71
+ - Las dims ya pintadas **quedan** si saltás el cursor (ej. pintaste 1…5, vas a 78 y `+` → quedan 1…5 **y** 78).
72
+ - Para borrar: ponés el cursor en la dim (ej. 5) y `−`.
73
+ - Estado: set sparse `rulerPaintedDims` (1-based). `rulerLineCount` = `painted.length` (display). Legacy `lineCount` solo → migra a `1..N`.
74
+
75
+ | | Correcto | Incorrecto (regresiones) |
76
+ | :--- | :--- | :--- |
77
+ | Geometría | token→token en cada dim pintada | envolvente max-Y uniendo dim1→dim2→dim3 |
78
+ | Mesh | solo `LineSegments` + `LineBasicMaterial` | joint `Points` / `PointsMaterial` + `sizeAttenuation` → **cuadrados blancos enormes** |
79
+ | UI / estado | cursor de pintura + set sparse | fill contiguo `1..lineCount` / toggle Path/Span |
80
+
81
+ - **Dónde**: COMPARE + ARITHMETIC en ANALYSIS y NAVIGATION. **Nunca** en Galaxy.
82
+ - **Persistencia**: `rulerCursor`, `rulerPaintedDims`, `rulerLineCount`, color, thickness en `vl3d.viz.*`.
83
+ - **Código**: `dimRuler.js` (`addDimRulerLine` / `removeDimRulerLine` / `buildDimRulerSegments`) → `Instancer._buildDimRulerMesh` → `MeshFactory.createDimRulerMesh`.
84
+ - **Lección**: si el ruler “se ve raro”, verificar segmentos **entre tokens en la misma dim**, set sparse (no fill), y sin `Points` de joints.
85
+
86
+
87
+ ---
88
+
89
+ ## 2. Visualización y Normalización de Embeddings LLM
90
+
91
+ ### 2.1. Estandarización Z-Score + Tanh
92
+ - **Problema**: Los vectores de embeddings de LLMs (`all-mpnet-base-v2`, OpenAI, etc.) tienen magnitudes absolutas pequeñas ($v_i \in [-0.15, +0.15]$). Dividir directamente por el valor máximo genera puntos oscuros e invisibles (5% de opacidad).
93
+ - **Solución Obligatoria**: Calcular la media ($\mu$) y desviación estándar ($\sigma$) del dataset y aplicar compresión sigmoidal simétrica:
94
+ ```javascript
95
+ export function calculateZScoreNormalized(values, scaleFactor = 1.2) {
96
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
97
+ const std = Math.sqrt(values.reduce((sq, n) => sq + Math.pow(n - mean, 2), 0) / values.length) || 1.0;
98
+ return values.map(v => Math.tanh(scaleFactor * ((v - mean) / std)));
99
+ }
100
+ ```
101
+
102
+ ### 2.2. Rampas Cromáticas Divergentes (Color Anchors)
103
+ User-editable hex anchors replace the former fixed dual ramp (no product mid-stops at ±0.5 orange/blue):
104
+ - **+1** default `#FFE600`, **0** `#000000`, **−1** `#9900E6`.
105
+ - Interpolation: lerp(`zero`, `positive`, t) for t≥0; lerp(`zero`, `negative`, −t) for t<0.
106
+ - POINTS shader: uniforms `uColorPos` / `uColorNeg` / `uColorZero` (never hardcode ramp in GLSL).
107
+ - Sign filter + colors are **global** (`vl3d.viz.*` in localStorage); filter runs on **normalized t** (ε=0.01), not raw dims.
108
+ - Continuity lines use `LineSegments` + index pairs; wide ribbons omit quad indices for filtered pairs (keeps full vertex buffers for compare reorder).
109
+
110
+ ### 2.3. Continuidad de hilo según RENDER mode (mutuamente excluyentes)
111
+ - **POINTS**: puntos + línea fina `createRibbonMesh` (continuidad 1px).
112
+ - **RIBBONS**: la continuidad **es** la cinta ancha (`createWideRibbonMesh`); sin plano base (el quad oscuro se veía a través de cintas transparentes). No usar `LineBasicMaterial.linewidth` (§1.4).
113
+ - Colormap RIBBONS: rampa **divergente** VHectorLab (anchors) para consistencia de marca.
114
+ - **Retired**: `RENDER: MESH` (surface heightfield) removed in v1.6.0 — `normalizeRenderMode('MESH')` → POINTS. Do not reintroduce without an explicit product decision.
115
+
116
+ ### 2.4. Optimización de Fragment Shader para Cero Activación ($|t| < 0.01$)
117
+ - **Patrón**: Evitar cálculos de interpolación `mix()` en fragmentos con intensidad casi nula.
118
+ - **Solución Obligatoria**: Evaluar $|t| < 0.01$ al inicio del Fragment Shader y realizar un early return con color negro y opacidad mínima ($\alpha \approx 0.05$):
119
+ ```glsl
120
+ if (absT < 0.01) {
121
+ gl_FragColor = vec4(vec3(0.0), 0.05 * baseOpacity * solidEdge);
122
+ return;
123
+ }
124
+ ```
125
+
126
+ ---
127
+
128
+ ## 3. Navegación y Controles 3D (WASDQE)
129
+
130
+ ### 3.1. Inercia Acotada sin Acumulación Exponencial (`lerp`)
131
+ - **Problema**: Sumar aceleración en cada frame (`velocity.add(moveVector)`) con amortiguación `velocity.multiplyScalar(damping)` causa una acumulación exponencial de velocidad (hasta $\frac{1}{1-\text{damping}} \approx 8.33\times$ la velocidad base), haciendo que la cámara salga disparada tras presionar 'W' por medio segundo.
132
+ - **Solución Obligatoria**: Acotar la velocidad mediante interpolación lineal directa al vector objetivo:
133
+ ```javascript
134
+ if (moveVector.lengthSq() > 0) {
135
+ moveVector.normalize().applyQuaternion(this.camera.quaternion).multiplyScalar(targetSpeed);
136
+ this.velocity.lerp(moveVector, 0.25); // Velocidad acotada y constante
137
+ } else {
138
+ this.velocity.multiplyScalar(this.damping); // Desaceleración suave
139
+ }
140
+ ```
141
+
142
+ ### 3.2. Aislamiento de Teclado durante Entrada de Texto en UI
143
+ - **Problema**: Al escribir en inputs HTML de la UI (ej. barra de búsqueda de palabras), las teclas 'W', 'A', 'S', 'D' mueven la cámara 3D involuntariamente.
144
+ - **Solución Obligatoria**: Filtrar eventos de teclado si el elemento activo es un input de formulario, y resetear la velocidad en el evento `blur`:
145
+ ```javascript
146
+ const tag = document.activeElement ? document.activeElement.tagName : '';
147
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
148
+ ```
149
+
150
+ ### 3.3. Vista de Análisis de Frente y Proyección de Etiquetas Flotantes
151
+ - **Patrón**: Proyectar los orígenes 3D ($X=0$) de los hilos vectoriales a coordenadas 2D de pantalla (`vector.project(camera)`) para renderizar cartelitos Glassmorphic anclados al inicio de cada hilo.
152
+ - **Invariante**: En el modo **Análisis**, los hilos se apilan verticalmente a lo largo de $Y$ con encuadre frontal directo (`Z=360`), permitiendo visualizar todos los componentes y el resultado inmediatamente sin desplazamientos manuales.
153
+
154
+ - **Invariante**: ANALYSIS/NAV flight numbers unchanged; no Spatial speed knobs in v1.
155
+
156
+ ### 3.5. Touch mobile: joystick/look no roban eventos de docks/HUD
157
+ - **Problema**: Pointers que empiezan en drawers/Compare/HUD mueven la cámara o pelean con scroll.
158
+ - **Solución Obligatoria**: Look solo si `target` es `CANVAS` y no hace match de docks/HUD/joystick (`isUiTouchTarget`). Joystick y botones Q/E escriben ejes en `Navigation` (`setMoveAxes` / `setVertical`); el update sigue el mismo `lerp` §3.1. Desktop mouse/WASD intacto.
159
+ - **Invariante**: scroll en `.compare-cosine-list` no mueve cámara; touch UI ≠ look.
160
+
161
+ ### 3.4. Default poses by MODE|VIEW|RENDER
162
+ - **Startup default**: **ARITHMETIC | ANALYSIS | POINTS** (`appViewDefaults.js`). Navbar + `main.viewMode` + empty `resolveCameraPose` / `resolveSpatialDefaults` ctx follow that triad.
163
+ - **Invariante**: Al cambiar MODE / VIEW / RENDER se reaplica cámara (`resolveCameraPose` → `Navigation.setContextView`) + sliders (`resolveSpatialDefaults` → sync UI). Claves `MODE`, `MODE|VIEW`, `MODE|VIEW|RENDER` (más específico gana).
164
+ - **Fallbacks de VIEW** (sin override): NAVIGATION corridor `POS (-178.3, 13.5, 52.2)` / `ROT (-5.4°, -51.5°, 0°)`; ANALYSIS `POS (-75.2, -0.8, 62.5)` / `ROT (0°, 0°, 0°)`.
165
+ - **Overrides capturados**:
166
+ - `ARITHMETIC|ANALYSIS|POINTS` — sliders Amplitud $Y=40$, Grosor $0.05$; cámara vía fallback ANALYSIS.
167
+ - `COMPARE|NAVIGATION|POINTS` — sliders Spacing `0.7`, Dist Y `10`, Amp `4.9`, Length `0.1`, Thickness `0.01`; cámara `POS (-106.5, 20.4, 390.2)` / `ROT (-3.9°, -8.4°, 0°)`; primer load COMPARE usa `COMPARE_AUTO_PRESETS.default` (lexicón completo), no `sample5`.
168
+ - `COMPARE|NAVIGATION|RIBBONS` — sliders Spacing `1.55`, Dist Y `10`, Amp `7`, Length `0.057`, Thickness `0.05`; cámara `POS (-575.8, 43.8, 237.9)` / `ROT (-22.4°, -35.7°, 0°)`; fog **off** en RIBBONS (`shouldEnableSceneFog`).
169
+ - **Overlay de captura**: El HUD `CAM POSE` solo se monta si `VITE_SHOW_CAM_POSE=true` (default `false` en `.env.example`).
170
+ - **Workflow de captura**: Activar overlay → navegar a la pose → screenshot POS/ROT → actualizar `CAMERA_DEFAULT_OVERRIDES` y/o `SPATIAL_DEFAULT_OVERRIDES` para la clave `MODE|VIEW|RENDER`.
171
+
172
+ ### 3.6. Galaxy flight profile (VIEW-only)
173
+ - **Problema**: UMAP world + default `moveSpeed` 75 / look `0.003` felt cramped and twitchy in Galaxy.
174
+ - **Solución**: `galaxyFlightProfile.js` — apply on enter Galaxy, restore defaults on leave. Calibrated: `moveSpeed` 56 (former Shift feel), `turboMultiplier` 2, `lookSensitivity` 0.0014. World scale via `resolveGalaxyWorldScale` (`GALAXY_DEFAULT_SCALE` 96, spacing×240).
175
+ - **Invariante**: ANALYSIS/NAV flight numbers unchanged; no Spatial speed knobs in v1.
176
+
177
+ ### 3.7. Galaxy `/project` with 1–2 tokens (UMAP floor)
178
+ - **Problema**: Token Comparison accepts 1–1024 tokens, but UMAP needs ≥3 samples. Visualize of a pair (e.g. `White, Blanco`) in VIEW GALAXY hit `UMAP requires at least 3 vectors` → pipeline aborted after encode → cosine list / scene unchanged (“nothing happens”).
179
+ - **Solución Obligatoria**: `backend/projection.py` — for `n < 3`, deterministic micro-layout (`origin` / `pca_micro`) instead of rejecting; meta reports `fallback`. Real UMAP only for `n ≥ 3`.
180
+ - **Invariante**: `/project` must succeed for any legal Compare cardinality (1..1024); never hard-fail small-n Galaxy Visualize.
181
+
182
+ ---
183
+
184
+ ## 4. UI Panels (Sidebar / Compare)
185
+
186
+ ### 4.1. Panel Arithmetic — Top-10 siempre alcanzable (scroll en la lista)
187
+ - **Problema histórico**: scrollbar del panel entero era molesta; se comprimió el Top-10 con `overflow: hidden` en `.results-list` → en viewports bajos se **cortaban** vecinos sin forma de verlos. Fórmulas `calc(100vh - 380px)` podían dar **≤0** en landscape.
188
+ - **Solución Obligatoria (actual)**:
189
+ - Tall: `#sidebar-panel` `overflow: hidden`; `#sidebar-panel .results-list` `overflow-y: auto` + `max(120px, min(280px|200px, 40dvh|36dvh))` — **floor 120px**, barrita al costado OK.
190
+ - Short (`max-height: 560px`): el **panel** scrollea (form + Top-10); lista sin max-height clip — escape hatch cuando el dock es chico.
191
+ - Dock body: `max-height` con `100dvh − navbar − HUD`, no magic `100vh - 160`.
192
+ - Contrato: `arithmeticResultsScroll.js` (`resolveResultsListMaxHeightPx`, `shouldScrollArithmeticPanel`).
193
+ - Items Top-10 **solo lectura** (`pointer-events: none`).
194
+ - **Invariante**: Top-10 siempre alcanzable (lista o panel); no es control de navegación 3D.
195
+
196
+ ### 4.1b. Mobile chrome MQ — width OR short landscape phone
197
+ - **Problema**: iPhone landscape suele tener `width > 768` → salía del path mobile (sin joystick, sin densidades, list heights desktop inútiles).
198
+ - **Solución Obligatoria**: `MOBILE_MQ = (max-width: 768px), ((max-height: 500px) and (hover: none))` en CSS + `CollapsibleDock.isMobileViewport`. Compact form Arithmetic bajo `max-height: 500px`. `viewport-fit=cover` en `index.html` para safe-area.
199
+ - **Invariante**: phone landscape short = mobile chrome; tablet portrait alto ≠ phone.
200
+
201
+ ### 4.2. COMPARE Cosine-vs-Anchor: scroll interno + reorder 3D con tween in-situ
202
+ - **Problema**: Listas largas (20/50/1024) en Compare rompen el layout si el scroll vive solo en el panel; un reorder con `clear()` + rebuild provoca flicker y desync lista↔3D. Con form SAE alto + cap `min(68vh, 600px)`, un `.compare-results { overflow: hidden; flex: 1; min-height: 0 }` **clippea** el header/lista cosine debajo de ACTIVE SEQUENCE METRICS — queda espacio vacío y la lista es inalcanzable incluso scrolleando el panel (el overflow del hijo no participa del scroll del padre).
203
+ - **Solución Obligatoria**:
204
+ - Panel Compare: `overflow-y: auto` + max-height HUD-capped (form + SAE + métricas caben vía scroll de panel).
205
+ - `#compare-panel .compare-results`: `flex: 0 0 auto; overflow: visible` — el bloque métricas+cosine **participa** del scroll del panel; nunca `overflow: hidden` que trague la lista.
206
+ - Scroll **de filas** en `#compare-panel .compare-cosine-list` (`overflow-y: auto` + `max-height` + **`min-height: 120px`** floor); la lista no puede colapsar a 0px.
207
+ - Filas de similitud: `pointer-events: none` en la fila; solo ▲/▼ con `pointer-events: auto` (métrica + reorder, sin focus de cámara).
208
+ - Reorder: recalcular `cosine_vs_first` en memoria (`compareCosine.js`); animar slots con `Instancer.animateCompareReorder` (lerp de `sequenceIndex` fraccionario ~200–400ms, reuse de ribbon/points meshes, `ThreadLabels.updateOrigins` por frame).
209
+ - Bloquear spamming de flechas mientras `_reorderBusy` (un tween a la vez).
210
+ - **Invariante**: lista COMPARE ↔ orden de hilos 3D siempre sincronizados; #1 es ancla REF con score `1.0000`; cosine list siempre alcanzable bajo ACTIVE SEQUENCE METRICS.
211
+
212
+ ### 4.3. Docks colapsables: transform + tab, sin desmontar DOM; MODE comparte estado izq.
213
+ - **Problema**: Ocultar paneles con `display: none` / desmontar nodos pierde estado de formularios, sliders y lista cosine; duplicar lógica left/right y resetear collapse al cambiar MODE rompe la UX.
214
+ - **Solución Obligatoria**:
215
+ - Host único `CollapsibleDock` por borde: slide con `transform` (~250ms); hijos permanecen montados.
216
+ - Colapsado: `pointer-events: none` en `.dock-body`; la pestaña (`.dock-tab`) sigue `pointer-events: auto` y expone `aria-expanded`.
217
+ - **Dock izq. / MODE**: un solo flag collapsed (y una key `localStorage`) para Arithmetic|Compare; el cambio de MODE solo alterna `.hidden` del panel activo dentro del body — no toca collapse.
218
+ - **Dock der.**: sliders + AxisGizmo viven en el mismo host; el HUD inferior de telemetría **nunca** entra al dock (siempre visible).
219
+ - Desktop: persistir collapsed en `localStorage`. Mobile (`max-width: 768px`): default collapsed y no persistir (hook Etapa B).
220
+ - El dock host mantiene `overflow: hidden` / `fit-content` — scroll interno vive en listas (§4.1 Top-10 / §4.2 cosine), no en el dock.
221
+ - **Invariante**: collapse ≠ unmount; MODE no resetea el dock izquierdo; HUD bottom ∉ docks.
222
+
223
+ ### 4.3b. Boot / Galaxy progress overlay
224
+ - **Problema**: El strip `#galaxy-progress` vive en el dock izquierdo (a menudo colapsado, o el panel Compare aún oculto al venir de ARITHMETIC) → canvas vacío sin feedback al entrar a Galaxy / cold start.
225
+ - **Solución**: `BootProgress` overlay fixed full-viewport (`#boot-progress`) + mirror al strip del Compare panel; `withSoftStepProgress` mueve la barra *dentro* de cada paso del pipeline.
226
+ - **Invariante**: progreso de Galaxy siempre visible sobre el canvas; no depender del dock.
227
+
228
+ ### 4.5. Control Espacial 3D — defaults = mid del rango + steps finos + dblclick reset
229
+ - **Problema**: Rangos históricos asimétricos (Distancia Y / Grosor con default = min; Amplitud hasta 240) dejan el thumb pegado a un extremo al load — solo se puede agrandar, no afinar alrededor del punto dulce. Steps gruesos (enteros / 0.1 en rangos chicos) impiden valores intermedios. Sin gesto de reset, volver al punto dulce exige adivinar el valor.
230
+ - **Solución Obligatoria**: Un solo set global de min/max (mismo track en todas las combos MODE|VIEW|RENDER). Separación $X$ ∈ $[0.4,2.0]$ step $0.05$ (COMPARE default $0.7$ se conserva; global $0.4$ en el min). Distancia $Y=10$ ∈ $[1,19]$ step $0.1$, Amplitud $Y=7$ ∈ $[1,40]$ step $0.1$ (**asimétrico**: default global 7 y override Analysis 40 se conservan; COMPARE Amp $4.9$ no fuerza mid). Longitud $Z$ ∈ $[0.001,0.2]$ step $0.001$ (nunca 0; COMPARE $0.1$ ≈ mid; global $0.2$ en el max; label 3 dec). Grosor $=0.05$ ∈ $[0.01,0.09]$ step $0.01$. Labels: 2 dec (X/Grosor), 3 dec (Z), 1 dec (Y). Defaults vía `resolveSpatialDefaults` / overrides por clave. **Doble clic** restaura solo ese slider al default del contexto.
231
+ - **Invariante**: rangos idénticos en todas las combos; defaults por combo se conservan; no todos los defaults son mid lineal; Length nunca llega a 0.
232
+
233
+ ### 4.6. ThreadLabels 3D — Arithmetic corto; Compare = tokens ingresados
234
+ - **Problema**: Badge de `type` + texto largo (`WORD_A` + `VECTOR A`, `#1 COS VECTOR (queen)`) hinchaba las cards glassmorphic en Análisis Arithmetic.
235
+ - **Solución Obligatoria**:
236
+ - **Arithmetic**: un solo span vía `arithmeticThreadLabel` → `WORD_A|WORD_B|WORD_C|RES|TOP1`. Sin badge de tipo; `res-label` / `top1-label` por `type`.
237
+ - **Compare**: el texto de la label 3D es el **token ingresado completo** (como antes). No prefijar `TOPn`, no truncar. La lista Compare / reorder sigue mostrando todos los items.
238
+ - **Invariante**: no mezclar políticas Arithmetic↔Compare; labels 3D ≠ paneles laterales (Top-10 / cosine list).
239
+ - **Lección**: acortar labels en un mode no implica el mismo formato en el otro — validar Compare aparte antes de unificar.
240
+
241
+ ### 1.6. RENDER: MESH retired (v1.6.0)
242
+ - **Decisión**: surface heightfield (`createSurfaceMesh`) removed from product — no aportaba vs POINTS/RIBBONS y costaba mantener.
243
+ - **Invariante**: navbar + runtime = POINTS | RIBBONS only. Legacy `"MESH"` → POINTS via `normalizeRenderMode`. No reintroduce surface mode without explicit ask.
244
+
245
+ ### 1.7. RIBBONS = wide mesh strips (nunca Line linewidth; sin base plane)
246
+ - **Problema**: `LineBasicMaterial.linewidth` queda capado a 1px en WebGL (§1.4); no sirve para cintas anchas de referencia. Un `createBasePlane` semitransparente bajo las cintas se veía como rectángulo oscuro a lo lejos (bordes rectos).
247
+ - **Solución Obligatoria**: `createWideRibbonMesh` (quad strip a lo largo del centerline). Sin Points. Sin montar plano base en Instancer.
248
+ - **Invariante**: RIBBONS ≠ POINTS; no reintroducir base plane bajo ribbons sin opacificar/`depthWrite` conscientes.
249
+
250
+ ### 4.4. Landscape Gate — RETIRED (portrait preferred)
251
+ - **Antes**: Soft overlay en phone portrait (“Better in landscape”).
252
+ - **Decisión**: Retirado — en producto real se ve **mejor en portrait** que de costado; el cartel era mentira UX.
253
+ - **Solución Obligatoria**: `shouldShowLandscapeGate` → siempre `false`; clase no monta copy. No reintroducir nudge landscape sin pedido explícito.
254
+ - **Invariante**: phone = portrait-first; no orientation lock; render loop nunca pausa por orientación.
255
+
256
+ ---
257
+
258
+ ### 4.7. Product copy = English; identifiers stay
259
+ - **Invariante**: Visible UI copy (navbar, panels, sliders, landscape gate, aria/titles/placeholders) is English-only. Internal keys (`data-view="ANALYSIS"`, `threadAmplitudeY`, CSS classes) are not renamed for i18n. No i18n framework — single-language product surface.
260
+ - **Compare 3D labels** remain raw input tokens (§4.6); panel chrome uses EN glossary.
261
+
262
+ ### 4.8. COMPARE groups — parse frontend, flat /compare, badges follow centroids
263
+ - **Decisión (v1.7.0)**: `GROUP_name = tokens` en textarea → `parseCompareInput` concatena grupos; `/compare` sigue flat. Anchor = primer token global (D1a). Sort/reorder cosine **global** puede romper contigüidad (D2a); badges de grupo se re-anclan al centroide de miembros actuales.
264
+ - **Invariante**: groups = metadata UI/layout, no endpoint nuevo; duplicados entre groups permitidos.
265
+ - **Visibilidad**: con groups activos, overlay muestra **solo** badges de grupo (los token cards a N alto los tapaban y el offset grande los metía bajo el dock z-index 40). Lista cosine del panel con ≥2 `groupId`: filas **grupo↔primer grupo** (centroide = media de embeddings L2 + re-L2; similitud cosine); flat / un solo grupo = token vs primer token. Reorder ▲/▼ del panel no aplica en modo grupos (3D intacto). `#thread-labels-container` z-index ≥ docks; offset screen de group badges chico.
266
+ - **Bootstrap**: textarea default **y** auto-Visualize al entrar a COMPARE deben salir de `getCompareBootstrap()` (groupsDemo + `tokenMeta`). Autoload de `COMPARE_AUTO_PRESETS.default` (136 flat, sin meta) mientras el textarea muestra `GROUP_*` → etiquetas de token forever. Preset buttons deben Visualize, no solo rellenar texto.
267
+ - **SAE + groups**: `applySaeToCompare` y encode path deben re-afirmar `groupId`/`groupLabel` desde RAW cache (no `groupName`). `ThreadLabels.updateOrigins` rebuilds DOM si el set de ids cambia (tokens → group badges).
268
+ - **Galaxy cache + regroup**: fingerprint de texts puede pegar cache de `/compare`, pero Visualize con **mismos tokens / grupos distintos** debe re-correr `attachMeta(tokenMeta)` — si no, badges/chips quedan en el groupId viejo.
269
+
270
+ ### 4.9. Visualization Controls — sign filter + color anchors + zero coverage (v1.8.x)
271
+ - **Panel**: glass card glued to the **bottom HUD** (app root); EN copy; global (not per MODE|VIEW|RENDER). Right dock keeps Spatial Controls + AxisGizmo only.
272
+ - **Sign filter**: `all | positive | negative` on **normalized t** (post z-score/tanh), ε=`0.01` aligned with shader short-circuit. +/− only hide the opposite sign **and** near-zero. Applies to POINTS (fragment `discard`) **and** continuity `LineSegments` / wide-ribbon **index omission** (F4) — keep full vertex buffers so COMPARE reorder in-situ still works.
273
+ - **Color anchors**: user hex +1 / 0 / −1 replace fixed mid-stop ramps; lerp `0↔+1` and `0↔−1`. POINTS shader uses uniforms `uColorPos` / `uColorNeg` / `uColorZero` (never hardcode ramp in GLSL).
274
+ - **Zero coverage %**: slider **below Colors, above Reset**. Remaps `|t|` so zero color occupies `coverage` of the ± range before lerp (`remapAbsTWithZeroCoverage`); CPU + `uZeroCoverage` share math. Cap 90% so ±1 remains reachable. Persist `vl3d.viz.*` including `zeroCoverage`.
275
+ - **Collapse / HUD glue**: Visualization mounts on **app root**. Short **left** dock-tab (▼ raised / ▲ resting on HUD). Expanded + collapsed share the **same HUD top seam** (right end of the bar via `--hud-pad`). Collapse only hides the body — tab stays parked **on** the HUD (flat bottom, no side orphan slot). Desktop + mobile. Key `vl3d.viz.panelCollapsed`.
276
+ - **Bottom HUD width**: tip-to-tip with equal `--hud-pad` insets (navbar-like span, not edge-glued). Right dock / AxisGizmo sit **above** the HUD (`bottom: hud-bottom + hud-height + 8`) so full-width telemetry does not collide with the gizmo.
277
+ - **Mobile chrome density**: navbar **single row** `--navbar-height: 28px` (no wrap / no second tab row); tabs ~20px / ~0.55rem; status = green/red **dot only** (hide ONLINE text). Overflow tabs: ◀ ▶ arrows via `navbarTabsScroll.js` (`getTabsScrollState` / `nextTabsScrollLeft`) — arrows only when `.is-overflowing`. HUD strip compacta y fluida al ancho — no forzar 44px en chrome decorativo (inputs/CTAs del panel sí mantienen 44px / 16px iOS zoom guard). Docks `top: calc(var(--navbar-height) + 6px)`.
278
+ - **SemVer**: if `main` already shipped a MINOR (e.g. groups `1.7.0`), the next capability must take the **next** MINOR (`1.8.0`) — never reuse a version number already on `main`.
279
+ - **Invariante**: filter-on-normalized-t; F4 = geometry not points-only; anchors always drive ramp in v1; no backend for viz controls.
280
+
281
+ ### 4.10. Top‑K SAE Clean/Denoise (v2.0.0)
282
+ - Roadmap: `roadmap/sae-denoise.md`. **Trained** Top‑K Sparse Autoencoder (PyTorch), ported from the predecessor tool — **not** sinusoidal fake projection.
283
+ - **Train scope = current workspace batch** (Compare items / Arithmetic vectors), **not** the full vocabulary. Model is **ephemeral** (RAM session); `POST /api/sae/clear` on Visualize/Calculate.
284
+ - Defaults caps: 768 → 8192 latents, K=32; `suggest_sae_dims(n)` auto-scales down for small N. Encode activations drive 3D when toggle ON; `/api/sae/status|train|encode|clear`.
285
+ - UI: Compare-only 50/50 CTA `Clean/Denoise (SAE)`; replace Compare vectors while ON; raw 768 cache restore on OFF; `vl3d.sae.*` localStorage; train progress via status poll. **Arithmetic has no SAE.**
286
+ - **HTML5 form trap**: SAE train params (`type=number`) live **inside** `#compare-form`. Invalid `min`/`step` vs default `lr` (e.g. `lr=0.001` with `min=1e-5` `step=1e-4` → step mismatch) **silently blocks** `type=submit` Visualize — no toast, params panel often hidden so no browser bubble. Presets still work (they call `onCalculate` directly). **Invariante**: `lr` input uses `step="any"`; any new number field in the Compare form must pass an HTML5 step-validity check against product defaults (see `tests/saeControls.test.js`).
287
+ - Archived “NO SAE” in `roadmap/archivo/big-picture.md` is **superseded**.
288
+ - **Train fast-path**: `from_numpy` + full-batch when N small; MPS/CUDA via `SAE_DEVICE=AUTO`; CUDA AMP; `inference_mode` for final metrics; `suggest_train_schedule` caps epochs (≤12 if hidden≤128). Defaults UI epochs=20.
289
+ - Encode already had bucketing + inference_mode + autocast; MPS autocast attempted with FP32 fallback.
290
+ - **Encode I/O bottleneck**: GPU matmul ~20ms; dense `.tolist()` of `[N, 8192]` JSON was the freeze. Fix: `encode_vectors_sparse` → `{format:topk_sparse, indices[N,K], values[N,K]}` + router `ORJSONResponse`; `RemoteProvider.saeEncode` densifies via `densifyTopKActivations`. `load_model()` is singleton (`model is not None` short-circuit) — do not `torch.load` per request.
291
+ - **Train UI hang**: UI could sit on `Starting SAE training… · working Ns` while backend was already `success`. Causes: (1) poll started only after `POST /train` returned — large embeddings JSON could stall the POST; (2) `refreshSaeStatusUi` nulled status on fetch error → fallback label "Starting…"; (3) async `setInterval` overlap. Fix: poll immediately on busy; keep last status on error; fetch timeouts; ignore pre-POST `idle`; clear poll in `_stopSaeTrainBusy`.
292
+
293
+ ### 4.11. COMPARE group contrast visibility (v2.1.0)
294
+ - **SAE + global z-score**: Top‑K densified matrix (~5% nonzero) → exact zeros normalize to \(t \approx -0.24\) (false negative “dust”). Mitigation in v1: auto **+ Only** on SAE encode success; restore previous filter on SAE OFF (`saeFilterBridge`). Do not change global z-score contract without an explicit D10.
295
+ - **Amplitude floor**: `COMPARE|ANALYSIS|POINTS` at Amp `1.0` collapses relief even when math has signal — default Amp **16** (RIBBONS twin too).
296
+ - **Dim sort**: optional client permutation by max pairwise `|mean_Gi − mean_Gj|`; session-only, OFF default; only when ≥2 `groupId`. Does not mutate backend payload.
297
+ - **Cosine ▲/▼ vs groups**: disable while groups active — global cosine sort breaks GROUP_* contiguity and soft Y gaps.
298
+ - Soft ANALYSIS Y gap: +1 empty slot between consecutive different `groupId` blocks (`groupStackLayout`).
299
+
300
+ #### Handoff for predecessor / sister apps (architect brief)
301
+ Portable findings from VHectorLab 3D `v2.1.0` — apply if the older app shares ANALYSIS matrix paint + groups + SAE Top‑K:
302
+
303
+ | Finding | Detail | Recommended fix |
304
+ | :--- | :--- | :--- |
305
+ | Sparse SAE + **global** z-score+tanh | Exact zeros → \(t\approx-0.24\) (painted as negative “dust”); nonzeros saturate near +1 | Prefer **+ Only** when SAE ON; optionally restore prior filter on SAE OFF. Avoid changing global norm unless product accepts colormap shift |
306
+ | Flat relief | Amplitude default at slider floor (~1) hides real peaks (RAW peak \|a\|~0.22 → Y tiny) | Raise ANALYSIS Amplitude default (VL3D used **16**; ARITH often **40**) |
307
+ | Unsorted X | Dim index order hides domain bands even when between-group signal exists | Client **dim permutation** by `max_{i,j} \|mean_Gi − mean_Gj\|`; toggle OFF = raw order |
308
+ | Cosine list sort vs groups | Global cosine reorder breaks group contiguity on Y | Disable cosine ▲/▼ while ≥2 groups, or regroup after sort |
309
+ | Expectation vs math | RAW G1↔G2 centroid cosine ~0.55 (not antipodal); SAE can improve (~0.22) but paint still hides it | Fix paint/order first; wider SAE train is L3, not required for readability |
310
+ | Layout axes (ANALYSIS) | X=dim, Y=thread stack + val×amp, Z=0 | Soft Y gap between `GROUP_*` blocks (+1× spacing) aids domain reading |
311
+
312
+ ### 4.11b. Group contrast paint (shared noise / sign conflict)
313
+ - **Problema**: En Compare, dims “chatas” compartidas por todos los tokens son ruido común; dims G1↔G2 de signo opuesto son señal — sin controles de paint dedicados.
314
+ - **Solución**: Deep module `groupDimContrast.js` — **Shared noise** = min/max crudo sobre **todos** los embeddings del batch (`groupId` ignored; gate ≥2 tokens); `sim = 1−|Δ|/(|a|+|b|)`; cancel ZC-style. **Sign conflict** = means G1↔G2; highlight + conflict cover. Solo paint (Y intacto). UI: Shared noise junto a Zero coverage; Group contrast = Sign conflict + Group hue (≥2 grupos). Coverage 30…100% vía knobs A/mA (`coverageAmKnobs.js`).
315
+ - **Invariante**: Zero coverage y Shared noise independientes; On habilita knobs (Off = grisado); shader POINTS usa `aCancel`/`aHighlight` + `uColorHighlight`; métrica Shared noise = valores **crudos** del embedding visualizado (no t normalizado).
316
+
317
+ ### 4.11c. Group hue (per-group black → color)
318
+ - **Problema**: Rampa divergente global no distingue dominios `GROUP_*` en Galaxy/Compare.
319
+ - **Solución**: Toggle `groupHueEnabled` + map `groupHueColors`; base = lerp(black, groupHex, remapped t) when ON (`groupHuePaint.js`); POINTS via `aBaseColor` + `uUseGroupHueBase`; RIBBONS via CPU `colorForActivationWithGroupHue`. Zero coverage remaps \|t\| then black↔color. Missing `groupId` → divergent fallback.
320
+ - **Invariante**: Shared noise / Sign conflict still apply after base; default OFF; gate ≥2 groups; persist under `vl3d.viz.*`.
321
+ - **UI labels**: filas Group hue usan `groupsForHueUi` → `{ id, label }` con `groupLabel` del textarea (mismo nombre que badges 3D / chips). No reutilizar el grid de Colors (`28px` para `+1`/`0`/`−1`); layout propio ~3 filas visibles + `overflow-y: auto` si hay 4+.
322
+
323
+ ### 4.12. Bottom HUD hover activation (POINTS / RIBBONS)
324
+ - **Problema**: `ACTIVATION` siempre `0.0000` — `Interaction` pasaba `userData` del mesh (`{ pointsData }`), y el HUD leía `userData.val` / `data.activation` (inexistentes).
325
+ - **Solución Obligatoria**: Resolver vía `resolveHoverTelemetry` → `pointsData[index].activation` (POINTS) o `userData.activations` + face/`index` (RIBBONS / continuity). Formatear con `formatActivationValue` (hasta 32 decimales, shrink + scientific si el slot es angosto; label `ACT:` en mobile). `title` tooltip con valor completo.
326
+ - **Invariante**: telemetría de hover ≠ normalización de color; mostrar valor **crudo** del embedding/activación en ese índice.
327
+
328
+ ---
329
+
330
+ ### 4.13. Field-info tips ("i") — tap, not hover-only
331
+ - **Problema**: Native `title=` tooltips fail on phone (long-press unreliable); editable fields had almost no per-control help.
332
+ - **Solución Obligatoria**: Deep module `fieldInfo.js` — `FIELD_INFO` short EN catalog (≤28 chars), `infoTipMarkup`, `wireFieldInfo` (one tip at a time, outside/Escape closes, viewport-clamped). Wire on Arithmetic / Compare / SAE params / Spatial sliders / Visualization. Touch: `.field-info-btn` / `.field-info-tip` in `isUiTouchTarget`.
333
+ - **Invariante**: help copy = English, short, mobile-first; do not rely on hover-only `title` for field meaning.
334
+
335
+ ### 4.14. GUI & Art epic (post-2.2.0)
336
+ - **Norte**: canvas 3D = héroe; chrome glass fino; EN copy; portrait-first; no landscape-gate; tips tap (§4.13).
337
+ - **Roadmap**: `roadmap/gui-art.md` + prompt `roadmap/PROMPT-gui-art.md` — cerrar A1–A6 (tipo, glass, emoji, rampa, motion, scope) **antes** de codear.
338
+ - **Invariante**: polish visual no puede romper §4.1 Top-10, §4.1b mobile MQ, §3.4 startup ANALYSIS, ni WebGL §1.
339
+
340
+ ---
341
+
342
+ ## 5. Protocolo de Mantenimiento de Lecciones Aprendidas
343
+
344
+ 1. **Consulta Obligatoria**: El agente **DEBE** leer este archivo al iniciar cualquier tarea de implementación, diseño de shaders, navegación o refactorización.
345
+ 2. **Actualización Continua**: Al descubrir una nueva invariante técnica, bug de renderizado o patrón de rendimiento, el agente **DEBE** agregarla a este archivo antes de finalizar la tarea.
346
+
347
+ ---
348
+
349
+ ## 6. Dev tooling / ngrok / Vite ↔ backend
350
+
351
+ ### 6.0e. Workbench theme is the default UI (2.4.1+) — parallel skins retired
352
+ - **Regla**: el chrome Magic Workbench es la UI **default** en `/` (`src/theme/` + `body.workbench-theme`). Product name = **VHectorLab 3D** (sin la palabra “Amiga” en marca).
353
+ - **Retirado en 2.4.1**: MPA `/v25/`, `/amiga/`, árboles `src/v25/**`, `v25/**`, `amiga/**`, `src/amiga/**`. No reintroducir skins paralelos sin pedido explícito.
354
+ - **Layout**: fullscreen `#app` + navbar/docks/HUD flotantes. **PROHIBIDO** shell multi-zona left|canvas|right (patrón v25 histórico).
355
+ - **Colores**: `VITE_AMIGA_*` en `.env` (nombre histórico de vars; documentadas como Workbench theme).
356
+ - **Vite**: `getViteInputs` → solo `main` (`index.html`). Redirects MPA bare-dir vacíos por default.
357
+ - **Lección**: no volver a forkear UI en `/v25` o `/amiga` “por si acaso”; un solo producto en `/`.
358
+
359
+ ### 6.0d. _(histórico)_ `src/v25/**` — retirado
360
+ - El árbol v25 fue **eliminado** en 2.4.1. Lecciones 6.0b/6.0c abajo quedan como memoria de bugs de ese experimento; no aplican a código vivo.
361
+
362
+ ### 6.0c. _(histórico v25)_ MODE switch = visibility toggle, never wipe left host
363
+ - **Problema**: montar Compare con `zones.left.innerHTML = …` destruye el panel Arithmetic (form state + Top-10). Además `display: flex` en `.lab-left-host > [data-panel]` **pisa** el UA `[hidden]{display:none}` → el slot Arithmetic oculto sigue ocupando flex y deja un **hueco negro** arriba del Compare (lista cosine aplastada abajo).
364
+ - **Solución Obligatoria**: host `.lab-left-host` con dos slots; ambos paneles montados; MODE alterna `hidden` + `.is-hidden` **y** CSS `display: none !important` en `[data-panel][hidden]`. Canvas guarda `lastArithmetic` **y** `lastCompare`. Al entrar COMPARE, VIEW preferido = **NAVIGATION** (framing multi-thread); ARITHMETIC vuelve a **ANALYSIS**.
365
+ - **Invariante**: collapse/MODE ≠ unmount; nunca asumir que `[hidden]` gana a una regla `display:` propia.
366
+
367
+ ### 6.0b. _(histórico v25)_ canvas host size = container, not `window`
368
+ - **Problema**: `SceneSetup` legado dimensiona el renderer con `window.innerWidth/Height` (fullscreen `#app`). En un host celda de grid → buffer/aspect incorrectos.
369
+ - **Solución Obligatoria**: override `onWindowResize` → `clientWidth/Height` del host; `ResizeObserver`; ThreadLabels al tamaño del host.
370
+ - **Invariante actual**: el producto default usa fullscreen `#app` (window size) otra vez; no reintroducir grid de zonas sin necesidad.
371
+
372
+ ### 6.0. Vite entry + FastAPI static
373
+ - **Estado 2.4.1+**: un solo `index.html` en la raíz. `getViteInputs` → `{ main }`. FastAPI `resolve_dist_file` sigue sirviendo `path/index.html` si algún día hay subpaths.
374
+ - **No hacer**: contaminar el producto con MPA skins paralelos sin OK humano.
375
+ - **URL de prueba**: `http://127.0.0.1:5173/`
376
+
377
+ ### 6.1. Proxy `/api` en Vite: prefijo general, no ruta-a-ruta
378
+ - **Problema**: Exponer frontend y backend con **el mismo** hostname ngrok (dos agentes → `:5173` y `:8000`) se pisa; el celu no puede llamar a `127.0.0.1:8000`.
379
+ - **Solución Obligatoria**:
380
+ - Un solo túnel ngrok → Vite (`:5173`).
381
+ - En `vite.config.js`, proxy de prefijo: `'/api' → http://127.0.0.1:8000` (el backend ya monta el router con `prefix="/api"`).
382
+ - `VITE_API_BASE_URL=/api` en `.env` (habilitado por defecto en dev/ngrok). `RemoteProvider` usa esa base; header `ngrok-skip-browser-warning` cuando el host es ngrok.
383
+ - **¿Hay que mapear endpoint por endpoint?** **No**, si todo el API vive bajo el mismo prefijo (`/api/health`, `/api/arithmetic`, `/api/compare`, …). Un solo `proxy['/api']` cubre rutas nuevas automáticamente.
384
+ - **Cuándo sí ruta-a-ruta**: solo si exponés paths **fuera** de `/api` (p.ej. `/health` bare sin prefijo) y querés proxearlos — ahí cada path top-level necesita su propia entrada en `server.proxy`, o movés el contrato a `/api/*`.
385
+ - **Invariante**: nuevas rutas backend bajo `/api` → cero cambio en Vite; si alguien agrega un mount root-level, o lo mete bajo `/api` o agrega proxy explícito + lesson.
386
+
387
+ ---
388
+
389
+ ## 7. Product versioning (SemVer) — VHectorLab 3D
390
+
391
+ ### 7.0. Product name (2026-08-03)
392
+ - Canonical product name: **VHectorLab 3D** (was VectorLab 3D / VECTORLAB). Keep `roadmap/` historical docs as-is.
393
+ - Do **not** rename `localStorage` keys `vl3d.*` — technical prefix, not brand; renaming breaks persisted prefs.
394
+ - Sync brand + SemVer: `manifest.json`, `package.json`, Navbar `version-tag`, FastAPI `app.version`, `CHANGELOG`. v25 retirado en 2.4.1 — no hay `src/v25/version.js`.
395
+
396
+ ### 7.1. Diagnosis (why it felt broken)
397
+ - **Too fast (Aug 2026 epic day)**: `1.1.0`→`1.5.0` burned a **MINOR per roadmap etapa** (docks / landscape / touch / MESH / RIBBONS) on the same calendar day. Semantically each etapa *was* a capability, but the Navbar tag looked like five releases without five ship moments.
398
+ - **Too slow (post-`1.5.0`)**: many user-visible changes (defaults, fog, EN copy, slider ranges) sat only under `[Unreleased]` while `manifest` / Navbar stayed `1.5.0`. The tag stopped tracking “what users can do now.”
399
+
400
+ ### 7.2. Policy (what to bump when)
401
+ Sync **`manifest.json` + `package.json` + Navbar `version-tag` + `CHANGELOG` section** together.
402
+
403
+ | Bump | When |
404
+ | :--- | :--- |
405
+ | **MAJOR (`x.0.0`)** | Breaking backend/API contracts, embedding dim, or data shapes that break clients — **or** an explicit product milestone reset when the human asks to restart the line (e.g. **3.0.0** coverage-knobs ship after a long tag lag). |
406
+ | **MINOR (`1.y.0`)** | Add **or remove** a product surface: MODE / VIEW / RENDER mode, major panel capability, or comparable user-facing feature. |
407
+ | **PATCH (`1.y.z`)** | Fixes, spatial/camera defaults, fog/copy/i18n polish, docs-only sync that still ships. Prefer PATCH over leaving long Unreleased tails. |
408
+
409
+ **Cadence rule**: bump **once per shippable delivery** of a notable change — not once per internal etapa on the same day, and not “never until the next epic.” Batch same-day etapas into one MINOR if they ship together. Do **not** leave the Navbar stuck for weeks while Unreleased grows.
410
+
411
+ ### 7.3. Build numbers — analysis (do **not** use as product version)
412
+ Options considered: `1.5.0+42`, `1.5.0.42`, CI build id in the Navbar.
413
+
414
+ | Approach | Pros | Cons for this repo |
415
+ | :--- | :--- | :--- |
416
+ | **SemVer build metadata (`1.6.0+gitsha`)** | Traceable artifacts | Not for “is this a new feature?”; Navbar noise; easy to confuse with PATCH |
417
+ | **Four-part `1.6.0.N`** | Monotonic every merge | Not npm/SemVer; invents a fourth digit users don’t understand |
418
+ | **CI build only in debug HUD** | Debug without product inflation | Fine as *optional* `VITE_SHOW_BUILD` — not the public version |
419
+
420
+ **Decision**: product version stays **three-part SemVer**. History of capability = `CHANGELOG`. Optional CI/git SHA belongs in a debug overlay (like cam pose), **not** in the brand version tag. Build numbers do **not** replace PATCH/MINOR discipline.
421
+
422
+ ---
423
+
424
+ ## 8. Local onboarding / `setup.sh` (macOS)
425
+
426
+ ### 8.1. README must describe the control panel, not a backend-only path
427
+ - **Problema**: Un README que solo documenta `cd backend && uv sync && uv run …` deja a juniors sin el flujo real (`./setup.sh` opción 1: deps, tests, backend+frontend, browser).
428
+ - **Solución Obligatoria**: README en **inglés**, centrado en `./setup.sh` opción **1** como camino recomendado. Documentar qué hace cada opción del menú.
429
+
430
+ ### 8.2. Prerequisites: check **and install** on macOS (do not only fail)
431
+ - **Problema**: “Verificar que estén instalados” y abortar con “instalalo vos” frena onboarding; el panel debe cerrar el gap.
432
+ - **Solución Obligatoria** en `setup.sh` (opción 1 / `ensure_prerequisites`):
433
+ - Si falta **`uv`** → instalar con el installer oficial Astral; refrescar `PATH` (`~/.local/bin`, Homebrew).
434
+ - Si falta **Node/`npm`** → asegurar Homebrew y `brew install node`.
435
+ - Si falta **`.env`** → copiar desde `.env.example`.
436
+ - Sync backend: `uv sync --extra dev`; frontend: `npm install` si no hay `node_modules`.
437
+ - **Invariante**: en Darwin, option 1 no debe pedir instalación manual de `uv`/Node salvo fallo de red/permisos.
438
+
439
+ ### 8.3. Platform scope is macOS-only until explicitly ported
440
+ - **Problema**: Scripts con `open`, Homebrew paths y bash asumen Mac; documentar “multiplataforma” sin pruebas miente.
441
+ - **Hecho / soportado**: creado y probado en **macOS 26.5.1 (Darwin 25.5.0, arm64)**.
442
+ - **No preparado**: Windows ni Linux (aunque con pocos ajustes de package manager / PATH / process control suele ser viable).
443
+ - **Invariante**: README + banner de `setup.sh` deben declarar esa versión de SO y el estado unsupported; auto-install de Node vía Brew es **macOS-only**. En no-Darwin: warn claro; no fingir soporte.
444
+
445
+ ### 8.4. Docker Desktop is optional (HF image only)
446
+ - **Problema**: Juniors asumen que hace falta Docker Desktop para correr la app; o al revés, option 7 falla sin explicación.
447
+ - **Hecho**: el flujo diario (opción **1**) es bare-metal (`uv` + Vite) — **no** requiere Docker.
448
+ - **Cuándo sí**: opción **7** (`docker build` de la imagen HF Spaces, torch CPU + vocab NPZ). Opción **8** publica Space Docker cpu-basic vía `hf repos create` + `git push` — **no** necesita Docker Desktop local (el Hub buildea).
449
+ - **Solución Obligatoria**: README marca Docker como **optional**; menú etiqueta option 7 con Docker Desktop; `ensure_docker` antes del build; option 8 usa `ensure_hf_cli` + `hf auth`.
450
+ - **Invariante**: no meter Docker en `ensure_prerequisites` de option 1.
451
+
452
+ ### 8.5. HF Space cpu-basic packaging
453
+ - **Problema**: `uv sync` en Linux tira wheels NVIDIA; encode de ~10k vocab en cada cold start OOMea o tarda demasiado.
454
+ - **Problema 2**: HF exige YAML frontmatter en el `README.md` del Space (`sdk: docker`, `app_port: 7860`); ensuciar el README de GitHub rompe el contrato “README estándar de producto”.
455
+ - **Problema 3**: `UV_TORCH_BACKEND=cpu` **no** aplica a `uv sync` (solo a `uv pip`) — el lock PyPI Linux seguía jalando `nvidia-*` multi-GB.
456
+ - **Problema 4**: push al Space rechaza binarios no-Xet (p.ej. `demo/vhectorlab-gui-tour.gif`). Sacar el path del tip **no alcanza** si el commit tiene `-p HEAD`: el pack manda historial GitHub donde el GIF sigue viviendo; el hook de HF escanea el pack entero. Además: si `./setup.sh` quedó abierto desde antes del fix, la función vieja sigue **en memoria** — hay que salir (0) y re-lanzar.
457
+ - **Solución Obligatoria**: Linux torch vía `[tool.uv.sources]` → index `pytorch-cpu` en `backend/pyproject.toml` + `uv.lock`; Dockerfile `uv sync --frozen` (sin confiar en `UV_TORCH_BACKEND`); precompute `public/vocab_embeddings.npz` en build; `.dockerignore` incluye `demo/`; option 8 compone README, strippea `demo/*` del index y pushea un tip **huérfano** (`commit-tree` **sin** `-p HEAD`) + `--force`; **guards** pre-push (`parents=0` + no `demo/`/gif reachable); contrato Space en **`deploy/hf/space-frontmatter.yml`**.
458
+ - **Runtime device**: `/health.device` + navbar `ONLINE (model · cpu|cuda|mps)`.
459
+ - **ARITHMETIC persist**: por visitante en `localStorage` (`vl3d.arithmetic.*`) — no disco del Space.
460
+ - **Invariante**: no asumir GPU en Spaces Docker; ZeroGPU no aplica a sdk docker. **No** force-pushear `HEAD` crudo al Space si el README de producto no lleva frontmatter — siempre inyectar desde `deploy/hf/`. Option 7 necesita disco libre local (BuildKit I/O); option 8 no necesita Docker Desktop.
461
+ - **Space model (2026-08)**: cpu-basic aguanta Arctic-m (~1.2 GB) + truncate 256 + vocab EN∪ES; alinear Dockerfile a `MODEL_PROFILE=local-full` para que Shared noise coincida con el lab Mac. No volver a pinear `all-mpnet` en el Space sin OK explícito.
462
+
463
+ ### 8.5. Local folder / clone name = `vhectorlab`
464
+ - **Problema**: el working copy histórico se llamaba `lsv2`, luego `VHectorLab-3D`, mientras el remoto GitHub pasó a **`vhectorlab`**.
465
+ - **Solución Obligatoria**: carpeta local canónica **`vhectorlab`** (minúsculas, alineada al repo). README: `git clone …/vhectorlab.git` + `cd vhectorlab`. Producto UI sigue **VHectorLab 3D**. No renombrar `vl3d.*` localStorage ni packages npm (`vhectorlab-3d`) solo por el folder/repo slug.
466
+ - **Invariante**: `roadmap/` puede seguir mencionando `lsv2` / VectorLab / `VHectorLab-3D` como historia; no reescribir archivo salvo necesidad.
467
+
468
+ ### 8.6. Renaming the repo folder breaks `backend/.venv` shebangs
469
+ - **Problema**: Tras `mv lsv2 → VHectorLab-3D`, `uv sync` reporta OK pero `uv run pytest` falla: `Failed to spawn: pytest` / `No such file or directory`. Los entrypoints en `.venv/bin/*` siguen con shebang absoluto a la ruta vieja (`…/lsv2/backend/.venv/bin/python3`).
470
+ - **Solución Obligatoria**:
471
+ 1. En `setup.sh` `ensure_project_deps`: si `backend/.venv/bin/pytest` existe y su intérprete shebang **no existe**, `rm -rf backend/.venv` y re-sync.
472
+ 2. Invocar tests como `uv run python -m pytest` (menos frágil que el script `pytest` con shebang roto).
473
+ 3. Recuperación manual: `rm -rf backend/.venv && cd backend && uv sync --extra dev`.
474
+ - **Invariante**: un rename/move del working copy **implica** recrear el venv; `uv sync` solo no reescribe shebangs rotos.
475
+
476
+ ### 8.7. Idempotent start — never bounce a healthy stack
477
+ - **Problema**: Opción 1 hacía `pkill` + relaunch siempre, aunque backend/frontend ya estuvieran healthy → pérdida de estado, re-carga del modelo, downtime innecesario.
478
+ - **Problema 2 (Ctrl+C / PGID)**: sin sesión nueva, backend/vite compartían señales con el panel → SIGINT los mataba.
479
+ - **Problema 3 (`set -m` / STAT T)**: `set -m` + job control dejó Vite en **stopped** (`STAT T`): puerto en LISTEN pero HTTP timeout → probe **sick** y el usuario quedaba trabado si option 1 solo abortaba.
480
+ - **Solución Obligatoria** en `setup.sh` (opciones que arrancan servicios):
481
+ - Arrancar con **`launch_detached`** (`python3` + `start_new_session=True`, stdin DEVNULL) — sin controlling TTY; **no** usar `set -m` para esto.
482
+ - **healthy** = proceso matching (`pgrep`) **y** health OK (`/health` con `"status":"ok"` en `:8000`; HTTP OK en `:5173`). Puerto ocupado sin ese combo = **sick**.
483
+ - Ambos healthy → **skip** prerequisites, **skip** tests y **skip** start; abrir browser. Tests explícitos = opciones **4/5**.
484
+ - **Sick** o parcial → **recycle** ambos (kill + start), no abortar dejando al usuario trabado.
485
+ - Ambos down → flujo normal (prereqs → tests → start).
486
+ - Opción **2** (backend only): skip si healthy; recycle si sick.
487
+ - Opción **10** siempre hace stop real (`kill_stack`), no no-op.
488
+ - **Ctrl+C** nunca baja servicios: en live logs pausa → Enter vuelve al menú; segundo Ctrl+C sale del panel.
489
+ - **Invariante**: no hay flag “force restart” separado; el stop explícito basta. No bajar+subir un stack ya healthy. Solo opción **10** detiene el stack a propósito. Servicios NUNCA comparten sesión/TTY con el panel.
490
+
491
+ ### 8.8. Arctic / GTE remote code on transformers≥5 (no xformers)
492
+ - **Problema**: `Snowflake/snowflake-arctic-embed-m-v2.0` (profile `local-full`) falla al swap/precompute con `AssertionError: please install xformers` porque el Hub config trae `use_memory_efficient_attention=true`. En macOS/MPS xformers suele no estar / no servir.
493
+ - **Problema 2**: aunque se desactive MEA, transformers **5.x** materializa en meta device y **corrompe** buffers `persistent=False` del remote GTE (`position_ids`, RoPE `inv_freq`/`cos_cached`/`sin_cached`) → `IndexError` o embeddings NaN. El remote code no los re-inicia en `_init_weights` (HF #43644 / #43950).
494
+ - **Solución Obligatoria** en `backend.model_catalog.build_model` cuando `trust_remote_code`:
495
+ 1. `config_kwargs={use_memory_efficient_attention: False, unpad_inputs: False}` (sin xformers; unpad sin MEA rompe RoPE).
496
+ 2. Post-load `_repair_gte_nonpersistent_buffers`: rearmar `position_ids` + `rotary_emb` (`inv_freq` + `_set_cos_sin_cache`).
497
+ - **Invariante**: no agregar `xformers` como dep de lab/macOS; no asumir que el remote GTE es compatible con transformers 5 sin repair.
498
+
499
+ ### 8.9. Shared noise visibility depends on embedding geometry (not model-specific wiring)
500
+ - **Problema**: Shared noise “funcionaba” en el lab Mac (`local-full` Arctic @256) y en HF con `all-mpnet-base-v2` @768 el knob **no movía nada**. Primera lectura: bug de deploy / cableado distinto por modelo.
501
+ - **Hecho**: el path JS (`groupDimContrast.js`) es **agnóstico al modelo** — solo min/max + sameSign + similarity sobre floats. No hay `if (model === …)`.
502
+ - **Causa real**: veto D4 (un token con signo opuesto anula la dim) + batch diverso ⇒ en mpnet @768 casi no quedan dims same-sign (medido ~7% / ~3% con cancel>0 @77% Similarity); en Arctic @256 el mismo texto deja ~30% same-sign / ~21% cancel>0 → se ve. Densidad de paint ≈ fracción de dims, no conteo absoluto.
503
+ - **Solución Obligatoria**:
504
+ 1. No “arreglar” Shared noise especializando por Hub id sin decisión de producto.
505
+ 2. Para demos alineadas al lab: Space = `MODEL_PROFILE=local-full` (Dockerfile).
506
+ 3. Ante knob “muerto”: medir `sameSign%` / cancel density del batch **antes** de tocar shaders.
507
+ 4. Estudios empíricos de este tipo viven en **`current-research/`** (no en `lessons-learned` ni en `roadmap/` como si fueran tickets). Evidencia completa: `current-research/DISCOVERY-shared-noise-embedding-geometry.md`.
508
+ - **Invariante**: lección de ingeniería acá; ciencia abierta / ablaciones / rediseño de métrica → `current-research/` + handoff omit-common. SAE ON no es el instrumento para cazar “pack compartido” en RAW.
.agents/skills/dev-protocol/qa-review.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # QUALITY ASSURANCE & SYSTEM REVIEW WORKFLOWS
2
+
3
+ Follow these protocols to maintain spec compliance, code quality, and clear, durable issue reporting.
4
+
5
+ ---
6
+
7
+ ## 1. QUALITY ASSURANCE & ISSUE FILING
8
+
9
+ When the user describes problems during QA, or when planning a feature, translate them into durable, clear issues on the project issue tracker.
10
+
11
+ ### 1.1. Rules for Filing Issues
12
+ - **Durable**: Issues should remain valid after major refactors.
13
+ - **Describe behaviors, not code**: Say *"the login page fails to redirect after submitting"* instead of *"authController.js throws on line 42"*.
14
+ - **No file paths or line numbers**: These go stale rapidly.
15
+ - **Use the project's domain language**: Reference terms as defined in `architecture_spec.md`.
16
+ - **Reproduction steps are mandatory**: List concrete, numbered steps a developer can follow, including inputs, flags, or configuration.
17
+
18
+ ### 1.2. Single Issue vs Breakdown
19
+ - **Single Issue**: Use when it is a single behavior wrong in one place, or multiple symptoms caused by the exact same root behavior.
20
+ - **Breakdown**: Break a task or report into multiple issues when:
21
+ - The work spans multiple independent areas.
22
+ - Slices are independently fixable and verifiable.
23
+ - There are blocker/dependency relationships (e.g. Issue B cannot be tested until Issue A is completed).
24
+ - **Tracer Bullet Breakdown**: When breaking down a plan, create **vertical slices** (tracer bullets) that cut through all layers (schema -> API -> UI -> tests) and are demoable. Publish issues in dependency order (blockers first).
25
+
26
+ ---
27
+
28
+ ## 2. TWO-AXIS REVIEW WORKFLOW
29
+
30
+ To verify changes before merging, perform a review of the diff against `HEAD` along two distinct, independent axes:
31
+
32
+ ### 2.1. The Two Review Axes
33
+ 1. **Standards Axis**: Checks if the diff conforms to this repository's documented coding standards (e.g., `CODING_STANDARDS.md`, formatting rules, type structures).
34
+ 2. **Spec Axis**: Checks if the diff faithfully implements the originating issue, spec, or PRD.
35
+ - Detects missing/partial requirements.
36
+ - Detects scope creep (behavior in the diff that wasn't asked for).
37
+ - Detects incorrect implementations of requirements.
38
+
39
+ ### 2.2. Parallel Evaluation Protocol
40
+ - **Parallel Sub-Agents**: Run the Standards review and Spec review as two parallel sub-agents (using `general-purpose` sub-agents) to prevent context pollution.
41
+ - **Integration**: Aggregate both reports under `## Standards` and `## Spec` headers verbatim. Do not merge or rerank their findings, as a change can pass one axis while failing the other (e.g., standard-compliant code that implements the wrong feature).
.agents/skills/dev-protocol/templates/.env.example ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # DEV AGENT PROTOCOL — environment template for Python LLM apps
3
+ # -----------------------------------------------------------------------------
4
+ # Usage in a new app:
5
+ # 1. Copy this file to the workspace ROOT as `.env.example` and commit it.
6
+ # 2. Copy it again to `.env`, fill in real values, and KEEP `.env` GITIGNORED.
7
+ #
8
+ # Rules (see dev-protocol/SKILL.md §3.2):
9
+ # - Secrets (keys/tokens) live here, never in code, never committed.
10
+ # - `.env.example` documents the variables WITHOUT real values.
11
+ # - Model / provider / endpoint are configuration, not hardcoded constants.
12
+ #
13
+ # Document only the variables your app actually reads; delete the rest.
14
+ # =============================================================================
15
+
16
+ # --- Provider selection ------------------------------------------------------
17
+ # Which adapter to use behind the single provider interface.
18
+ # Examples: "local" | "remote" (or a concrete backend id your app supports)
19
+ LLM_PROVIDER=local
20
+
21
+ # --- Remote provider (hosted API) --------------------------------------------
22
+ # Leave blank in the example; fill real values only in your local .env.
23
+ LLM_API_KEY=
24
+ LLM_BASE_URL=https://api.example.com/v1
25
+ LLM_MODEL=
26
+
27
+ # --- Local provider (e.g. Ollama / llama.cpp / vLLM) -------------------------
28
+ # Endpoint of the locally running model server.
29
+ LOCAL_LLM_BASE_URL=http://localhost:11434
30
+ LOCAL_LLM_MODEL=
31
+
32
+ # --- Generation defaults (configuration, not literals in code) ---------------
33
+ LLM_TEMPERATURE=0.0
34
+ LLM_MAX_TOKENS=1024
35
+ LLM_REQUEST_TIMEOUT_SECONDS=60
36
+
37
+ # --- Observability (token / latency / cost logging — SKILL.md §3.2) ---------
38
+ # Toggle structured logging of token counts and latency for regression tracking.
39
+ LLM_LOG_USAGE=true
40
+
41
+ # --- App runtime -------------------------------------------------------------
42
+ LOG_LEVEL=INFO
43
+ # HOST=0.0.0.0
44
+ # PORT=8000
.agents/skills/dev-protocol/templates/.pre-commit-config.yaml ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # DEV AGENT PROTOCOL — pre-commit base template (Python / uv)
3
+ # -----------------------------------------------------------------------------
4
+ # Usage in a new app:
5
+ # 1. Copy this file to the workspace ROOT as `.pre-commit-config.yaml`.
6
+ # 2. Install the git hook once: uv run pre-commit install
7
+ # 3. Smoke-test the whole repo: uv run pre-commit run --all-files
8
+ #
9
+ # Pin the `rev:` tags to the latest releases when you adopt this in a repo,
10
+ # then let `pre-commit autoupdate` keep them current. Versions below are
11
+ # conservative defaults, not gospel — see dev-protocol/git-workflow.md §2.
12
+ # =============================================================================
13
+
14
+ repos:
15
+ # --- General hygiene -------------------------------------------------------
16
+ - repo: https://github.com/pre-commit/pre-commit-hooks
17
+ rev: v5.0.0
18
+ hooks:
19
+ - id: trailing-whitespace
20
+ - id: end-of-file-fixer
21
+ - id: check-merge-conflict
22
+ - id: check-added-large-files
23
+ args: ["--maxkb=1024"]
24
+ - id: check-yaml
25
+ - id: check-toml
26
+ - id: detect-private-key # blocks committed private keys
27
+
28
+ # --- Lint + Format (ruff replaces black + flake8 + isort) ------------------
29
+ - repo: https://github.com/astral-sh/ruff-pre-commit
30
+ rev: v0.8.4
31
+ hooks:
32
+ - id: ruff # lint
33
+ args: ["--fix"]
34
+ - id: ruff-format # format
35
+
36
+ # --- Secret scanning (enforces "no keys in git" — SKILL.md §3.2) -----------
37
+ - repo: https://github.com/gitleaks/gitleaks
38
+ rev: v8.21.2
39
+ hooks:
40
+ - id: gitleaks
41
+
42
+ # --- Local guard: never let an actual .env file get staged -----------------
43
+ - repo: local
44
+ hooks:
45
+ - id: block-dotenv
46
+ name: block committing .env files
47
+ entry: "Refusing to commit a .env file. Keep secrets out of git; commit .env.example instead."
48
+ language: fail
49
+ files: '(^|/)\.env(\..+)?$'
50
+ # Allow the documented example file through:
51
+ exclude: '(^|/)\.env\.example$'
52
+
53
+ # --- Optional: project-wide type checking ------------------------------------
54
+ # Type checking the full tree is often too slow for every commit. Prefer
55
+ # running it in CI, or enable it here as a manual stage:
56
+ # uv run pre-commit run --hook-stage manual mypy
57
+ #
58
+ # - repo: https://github.com/pre-commit/mirrors-mypy
59
+ # rev: v1.13.0
60
+ # hooks:
61
+ # - id: mypy
62
+ # stages: [manual]
63
+ # additional_dependencies: [] # add type stubs your project needs
.dockerignore ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .github
3
+ .venv
4
+ backend/.venv
5
+ node_modules
6
+ dist
7
+ # GitHub README demo assets — not needed in Space image; HF also rejects non-Xet binaries on git push
8
+ demo
9
+ **/__pycache__
10
+ **/*.py[cod]
11
+ **/.pytest_cache
12
+ **/.ruff_cache
13
+ **/.mypy_cache
14
+ **/.coverage
15
+ **/htmlcov
16
+ *.log
17
+ logs
18
+ .env
19
+ .env.*
20
+ !.env.example
21
+ .DS_Store
22
+ *.swp
23
+ .cursor
24
+ .agents
25
+ agent-transcripts
26
+ roadmap/archivo
27
+ CONTEXT.blueprint.md
28
+ context.txt
29
+ public/vocab_embeddings.npz
30
+ backend/artifacts
31
+ *.pt
32
+ .vscode
33
+ .idea
34
+ coverage
35
+ test-results
.env.example ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VHectorLab 3D - Environment Variables
2
+ HOST=127.0.0.1
3
+ PORT=8000
4
+ MODEL_NAME=all-mpnet-base-v2
5
+ # Default stays EN baseline (all-mpnet). Optional profiles after setup option 11:
6
+ # MODEL_PROFILE=local-comfort
7
+ # MODEL_NAME=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
8
+ # TRUNCATE_DIM=
9
+ VOCAB_PATH=public/vocab.txt
10
+ # EN∪ES merged vocab (after: uv run python scripts/merge_vocab_en_es.py):
11
+ # VOCAB_PATH=public/vocab_en_es.txt
12
+ # Optional precomputed vocab embeddings (Docker/HF builds this automatically)
13
+ VOCAB_EMBEDDINGS_PATH=public/vocab_embeddings.npz
14
+ LOG_LEVEL=INFO
15
+ # Local bare-metal: unset or 1. Docker/HF Space: 0
16
+ # UVICORN_RELOAD=1
17
+
18
+ # Frontend (Vite) — show live camera POS/ROT overlay for capturing default views
19
+ VITE_SHOW_CAM_POSE=false
20
+
21
+ # Top-K SAE + embedding runtime device: AUTO | CPU | CUDA | MPS
22
+ SAE_DEVICE=AUTO
23
+
24
+ # API base for the browser client (RemoteProvider).
25
+ # Enabled default for ngrok/phone: same-origin `/api` → Vite proxies to :8000
26
+ # (one public tunnel to :5173 is enough; do not reuse the same ngrok host for :8000).
27
+ #
28
+ # VITE_API_BASE_URL=/api
29
+ #
30
+ # Alternatives:
31
+ # (unset) auto — localhost → http://127.0.0.1:8000 ; public host → /api
32
+ # http://127.0.0.1:8000
33
+ # https://<backend-subdomain>.ngrok-free.dev # dual-tunnel (two different URLs)
34
+ VITE_API_BASE_URL=/api
35
+
36
+ # --- Workbench theme (MagicWB) — hex #RRGGBB; omit to use defaults ---
37
+ # Dark gray surfaces (pens 0/4/5 + bg); light fg for contrast
38
+ VITE_AMIGA_PEN_0=#2A2A2A
39
+ VITE_AMIGA_PEN_1=#000000
40
+ VITE_AMIGA_PEN_2=#FFFFFF
41
+ VITE_AMIGA_PEN_3=#3B67A2
42
+ VITE_AMIGA_PEN_4=#1A1A1A
43
+ VITE_AMIGA_PEN_5=#3D3D3D
44
+ VITE_AMIGA_PEN_6=#AA907C
45
+ VITE_AMIGA_PEN_7=#FFA997
46
+ VITE_AMIGA_BG=#222222
47
+ VITE_AMIGA_FG=#F0F0F0
48
+ VITE_AMIGA_ACCENT=#3B67A2
49
+
50
+ # Hugging Face Space publish (setup.sh option 8) — Enter accepts these defaults
51
+ HF_SPACE_ID=hbauzan/llm-semantic-visualizer
52
+ # Force-push ephemeral tip → Space main (Space history ≠ GitHub; needed to replace old Space content)
53
+ HF_SPACE_FORCE_PUSH=1
54
+ # YAML contract injected into Space README at publish (GitHub README stays without frontmatter)
55
+ # HF_SPACE_FRONTMATTER=deploy/hf/space-frontmatter.yml
.github/ISSUE_TEMPLATE/bug_report.md ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Bug report
3
+ about: Create a report to help us improve
4
+ title: ''
5
+ labels: ''
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+ **Describe the bug**
11
+ A clear and concise description of what the bug is.
12
+
13
+ **To Reproduce**
14
+ Steps to reproduce the behavior:
15
+ 1. Go to '...'
16
+ 2. Click on '....'
17
+ 3. Scroll down to '....'
18
+ 4. See error
19
+
20
+ **Expected behavior**
21
+ A clear and concise description of what you expected to happen.
22
+
23
+ **Screenshots**
24
+ If applicable, add screenshots to help explain your problem.
25
+
26
+ **Desktop (please complete the following information):**
27
+ - OS: [e.g. iOS]
28
+ - Browser [e.g. chrome, safari]
29
+ - Version [e.g. 22]
30
+
31
+ **Smartphone (please complete the following information):**
32
+ - Device: [e.g. iPhone6]
33
+ - OS: [e.g. iOS8.1]
34
+ - Browser [e.g. stock browser, safari]
35
+ - Version [e.g. 22]
36
+
37
+ **Additional context**
38
+ Add any other context about the problem here.
.github/ISSUE_TEMPLATE/custom.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Custom issue template
3
+ about: Describe this issue template's purpose here.
4
+ title: ''
5
+ labels: ''
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+
.github/ISSUE_TEMPLATE/feature_request.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Feature request
3
+ about: Suggest an idea for this project
4
+ title: ''
5
+ labels: ''
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+ **Is your feature request related to a problem? Please describe.**
11
+ A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12
+
13
+ **Describe the solution you'd like**
14
+ A clear and concise description of what you want to happen.
15
+
16
+ **Describe alternatives you've considered**
17
+ A clear and concise description of any alternative solutions or features you've considered.
18
+
19
+ **Additional context**
20
+ Add any other context or screenshots about the feature request here.
.gitignore ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Environments
7
+ .venv/
8
+ venv/
9
+ ENV/
10
+ env/
11
+
12
+ # Environment variables
13
+ .env
14
+ !.env.example
15
+
16
+ # Node dependencies & builds
17
+ node_modules/
18
+ dist/
19
+ .vite/
20
+ .pnpm-store/
21
+ pnpm-lock.yaml
22
+ pnpm-workspace.yaml
23
+
24
+ # Pytest / Coverage
25
+ .pytest_cache/
26
+ .coverage
27
+ htmlcov/
28
+
29
+ # SAE / model weight binaries
30
+ *.pt
31
+ !backend/artifacts/.gitkeep
32
+ public/vocab_embeddings.npz
33
+
34
+ # Local AI regeneration blueprint (do not publish)
35
+ CONTEXT_HAB.MD
36
+
37
+ # Demo GIF capture tooling / scratch (published tour GIF is tracked)
38
+ scripts/capture-galaxy-demo.mjs
39
+ demo/**
40
+ !demo/vhectorlab-gui-tour.gif
41
+
42
+ # Logs & temp
43
+ *.log
44
+ .DS_Store
CHANGELOG.md ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to VHectorLab 3D will be documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ### Fixed
8
+ - **HF Space publish (option 8)**: ephemeral tip strips `demo/` binaries and is an **orphan** commit (no `-p HEAD`) so the push pack does not include GitHub history that still has the GIF; HF rejects non-Xet binaries in the whole pack. Composed Space README drops local demo media embeds.
9
+ - **HF / Docker torch CPU**: Linux installs `torch` from `pytorch-cpu` index via `[tool.uv.sources]` (nvidia-* no longer pulled). `UV_TORCH_BACKEND` alone never affected `uv sync`.
10
+
11
+ ### Changed
12
+ - **HF Space model = local-full**: Docker/Space now runs `Snowflake/snowflake-arctic-embed-m-v2.0` with `TRUNCATE_DIM=256` + `vocab_en_es.txt` (aligned with Mac lab Shared noise). Fits cpu-basic (~1.2 GB weights / 16 GB RAM).
13
+ ### Added
14
+ - **`current-research/`**: home for empirical discovery notes (beyond `lessons-learned`). First note: Shared noise × embedding geometry (Arctic@256 vs mpnet@768).
15
+ - **HF Space README inject**: `deploy/hf/space-frontmatter.yml` + `scripts/compose_hf_space_readme.sh`; option 8 pushes an ephemeral tip with `sdk: docker` / `app_port: 7860` without polluting the GitHub product README.
16
+ - **Compare group cosine panel**: with ≥2 `GROUP_*`, the lower list shows group-centroid cosine vs the first group in the textarea (mean of L2 unit vectors → re-L2 → dot); flat / single group stays token-vs-first. 3D viewer unchanged.
17
+ - **Multi-embedding catalog (local)**: profiles `local-comfort` / `local-full` / `hf-demo`, setup.sh **option 11** swap (stage `.env` → rebuild vocab NPZ → restart), EN∪ES vocab merge, `/health` fields (`model_profile`, `short_label`, `embedding_dim`, `truncate_dim`), navbar ONLINE chip shows profile · label · dim · device. Cross-lingual smoke: `scripts/smoke_crosslingual_cosine.py` (non-blocking; `--strict` optional). Default `.env.example` remains **all-mpnet-base-v2**. HF Space model policy unchanged.
18
+ - **Galaxy / boot progress overlay**: full-viewport card over the canvas (`#boot-progress`) so encode→UMAP progress stays visible when the left dock is collapsed (e.g. switching ARITHMETIC → GALAXY on mobile).
19
+
20
+ ### Changed
21
+ - Left Compare/Arithmetic dock: a bit taller (`min(68vh, 600px, …HUD)`) with side scrollbar + `scrollbar-gutter` when content overflows; never taller than the space above the bottom HUD.
22
+ - **License**: project relicensed from MIT to **Apache License 2.0** (`LICENSE` + `NOTICE`; README / HF Space card `license: apache-2.0`; `package.json` `license` field).
23
+ - Bootstrap / 2 Groups demo labels: `it_core` (IT terms), `vehicles` (auto lexicon), `women` (English women’s names) — replaces `GROUP_it_core` / `GROUP_1` / `GROUP_2`.
24
+ - Panel buttons: Visualize Sequence + Clean/Denoise (SAE) slightly larger; all other panel buttons compact at Shared-noise scale (`0.68rem` / weight 600).
25
+ - 3D Spatial Controls: denser padding/gaps (Compare-like); dock stays top-right with gizmo stacked under sliders (no empty mid-gap).
26
+ - HUD hover: token caption left of `HOVER TELEMETRY` (right TOKEN column removed); grouped Compare points show `group/token`.
27
+ - Galaxy default camera pose: POS `25.5, 155.2, 328.9` / ROT `-23.2, 14.0, 0` (captured CAM POSE).
28
+ - Startup chrome defaults: **ARITHMETIC | ANALYSIS | POINTS** (desktop + mobile; was COMPARE | GALAXY).
29
+
30
+ ## [3.0.1] - 2026-08-26
31
+
32
+ ### Added
33
+ - **Thread lines** toggle (Visualization panel, top): show/hide POINTS continuity lines along each thread; points and magnitude colors stay. Persists `vl3d.viz.threadLinesVisible` (default ON).
34
+
35
+ ## [3.0.0] - 2026-08-26
36
+
37
+ Milestone cut: start the **3.x** line after shipping the coverage / Shared noise chrome (the tag had lagged the product for too long).
38
+
39
+ ### Changed
40
+ - **Coverage knobs (Zero coverage + Shared noise)**: DAW-style vertical drag (`ns-resize`), long throw, A=100 + mA forces A→99; **mA** fine fraction now **5 decimal places** (`0…0.99999`, step `1e-5`). Room for a future µA knob if needed.
41
+ - Builds on **2.4.3** Shared noise token-batch + A/mA Workbench chrome (30%…100%).
42
+
43
+ ## [2.4.3] - 2026-08-26
44
+
45
+ ### Changed
46
+ - **Shared noise** retarget: cancel from **token-batch** min/max across all Compare embeddings (`groupId` ignored); Sign conflict stays G1↔G2 means. Control sits after Zero coverage; gate ≥2 tokens. Group contrast = Sign conflict + Group hue (≥2 groups).
47
+ - **Zero coverage / Shared noise Similarity**: range **30%…100%**; dual **A / mA** Workbench knobs + editable % readout (one persisted percent). Dual coarse/fine knobs for Zero coverage and Shared noise — yes, it looks a bit like studio gear cosplay on a Workbench panel. Keeping it anyway: one slider fighting the top end was worse UX than a little theatrical.
48
+
49
+ ## [2.4.2] - 2026-08-08
50
+
51
+ ### Added
52
+ - **Galaxy feel**: larger UMAP world (`GALAXY_DEFAULT_SCALE` 96, spacing×240) + Galaxy-only flight profile (cruise 56, Shift ×2, softer look); leave Galaxy restores ANALYSIS/NAV speeds.
53
+ - **Galaxy soft stars**: circular solid core + soft halo POINTS (ANALYSIS/NAV squares unchanged).
54
+ - **Group hue** (Visualization → Group contrast): per-`GROUP_*` black (−1) → picked color (+1); default OFF; transversal COMPARE paint (POINTS + RIBBONS); Shared noise / Sign conflict still layer on top.
55
+
56
+ ### Changed
57
+ - Workbench value fields (`input` / `textarea` / `select`): dark fill `#1a1a1a` instead of white (color/range/checkbox unchanged).
58
+
59
+ ## [2.4.1] - 2026-08-08
60
+
61
+ ### Added
62
+ - **Magic Workbench chrome as the default UI** at `/`: Topaz (self-hosted), dark MagicWB surfaces, Workbench bevels, Compare/Arithmetic panel scroll, env-tunable pens via `VITE_AMIGA_*` (`src/theme/`).
63
+ - Product stays branded **VHectorLab 3D** (no “Amiga” in the product name).
64
+
65
+ ### Changed
66
+ - **Single frontend entry**: Vite builds only `index.html` → `/`. Parallel MPAs **`/v25/`** and **`/amiga/`** removed (code under `src/v25/**`, `v25/`, `amiga/`, `src/amiga/**` deleted).
67
+ - Theme modules live in `src/theme/` (tokens, chrome overrides, Topaz, `magicwbEnvColors`).
68
+ - Roadmaps for v25 / amiga MPA / gui-art parallel skin moved to `roadmap/archivo/`.
69
+
70
+ ### Fixed (during Workbench cutover)
71
+ - Right-dock gray slab behind Spatial Controls (dock-body no longer painted).
72
+ - Compare token textarea: black field, muted text.
73
+ - Compare/Arithmetic left panels: scroll like Visualization (`min(56vh, 520px)`); preset chips wrap.
74
+ - Font antialiasing enabled for Workbench chrome.
75
+
76
+ ## [2.4.0] - 2026-08-08
77
+
78
+ ### Added
79
+ - _(Superseded by 2.4.1 cutover.)_ Interim `/amiga/` MPA scaffold + `VITE_AMIGA_*` color env — folded into default `/` in 2.4.1.
80
+
81
+ ## [2.3.0] - 2026-08-08
82
+
83
+ ### Added
84
+ - **Galaxy VIEW** (in progress): new VIEW tab with UMAP/PCA/t-SNE chips (PCA/t-SNE grayed); entering Galaxy locks MODE=COMPARE + RENDER=POINTS.
85
+ - **`POST /project`**: backend UMAP projection of precomputed embeddings (`umap-learn`); pca/tsne → 501.
86
+ - **Bootstrap corpus**: `GROUP_it_core` (100 IT tokens) + existing `GROUP_1` / `GROUP_2` demos (REF inside IT core).
87
+ - **Galaxy layout**: one point per token from UMAP positions; group badges at centroids; camera frames IT core; no dim-axis ribbons.
88
+ - **Galaxy pipeline + progress**: client-driven encode → SAE? → UMAP → build with status text, progress bar, and step **k/n**; Visualize + SAE toggle in Galaxy; reuse `/compare` cache when texts unchanged.
89
+
90
+ ## [2.2.1] - 2026-08-07
91
+
92
+ ### Added
93
+ - **`/v25/` MPA scaffold** (Fase 1): `v25/index.html` + `src/v25/main.js` hello shell; Vite multi-page via `getViteInputs()`; FastAPI `resolve_dist_file` serves nested `dist/v25/index.html` (directory index). Legacy `/` unchanged.
94
+ - **GUI & Art v25 plan** (`roadmap/gui-art-v25.md` + `PROMPT-gui-art-v25.md`); epic index `gui-art.md` points agents at `/v25/` phases.
95
+
96
+ ## [2.2.0] - 2026-08-07
97
+
98
+ ### Added
99
+ - **Field-info tips ("i")** on every editable control (Arithmetic, Compare, SAE params, Spatial sliders, Visualization): short English tap tips, mobile-safe popover (not hover-only `title`).
100
+ - **HF Space cpu-basic demo path** (`feat/hf-space-cpu-demo`): Docker torch CPU (`UV_TORCH_BACKEND=cpu`), vocab embeddings NPZ at image build, `/health.device`, navbar `ONLINE (model · device)`, ARITHMETIC `localStorage` persistence (`vl3d.arithmetic.*`), setup option 7 smoke-run + option 8 `hf` create Space (docker / cpu-basic) + push, README Space YAML frontmatter.
101
+
102
+ ### Changed
103
+ - **Repo / local folder slug**: GitHub + working copy **`vhectorlab`** (was `VHectorLab-3D`). Product name **VHectorLab 3D** unchanged; npm/manifest id stays `vhectorlab-3d`.
104
+ - **Landscape gate retired**: no more “Better in landscape” cartel — phone portrait is preferred.
105
+ - **Arithmetic Top-10**: list scrolls with a **120px floor** (`max(120px, min(…, dvh))`); on short viewports (≤560px) the whole Arithmetic panel scrolls so neighbors stay reachable. Mobile MQ also matches short landscape phones (`max-height: 500px` + `hover: none`) so touch chrome does not drop off.
106
+ - **Startup chrome**: default is **ARITHMETIC | ANALYSIS | POINTS** (was NAVIGATION).
107
+
108
+ ### Fixed
109
+ - **`setup.sh` option 8**: defaults from `.env` (`HF_SPACE_ID`, `HF_SPACE_FORCE_PUSH=1`); skip create if Space exists; force-push to Space remote only (not GitHub) so Enter×N publishes over divergent predecessor history.
110
+ - **`setup.sh` idempotent start**: options 1/2 probe process + health before launch; skip bounce when already healthy; refuse when sick; restart both only on partial stack. README + lessons §8.7.
111
+ - **`setup.sh` Ctrl+C**: pauses log follow / exits panel without stopping services; only option 10 stops. Menu banner OS line removed.
112
+ - **`setup.sh` process group**: enable `set -m` so backend/vite are not in the panel PGID (Ctrl+C was killing the stack). Healthy path also skips tests.
113
+ - **`setup.sh` detached session**: replace `set -m` with `start_new_session` launch (Vite was freezing at STAT T / sick). Option 1 recycles sick stacks instead of aborting.
114
+ - **Offline modal**: no longer hardcodes `127.0.0.1:8000` (works for local setup + HF Space).
115
+ - **Uvicorn reload**: off by default when `HOST=0.0.0.0` or `UVICORN_RELOAD=0` (Docker/HF).
116
+
117
+ ## [2.1.1] - 2026-08-03
118
+
119
+ ### Changed
120
+ - **Rebrand**: product name **VectorLab 3D** → **VHectorLab 3D** (UI, docs, package manifest).
121
+
122
+ ### Fixed
123
+ - **HUD ACTIVATION stuck at 0.0000**: resolve hover from `pointsData[index]` / ribbon `activations`; adaptive precision up to 32 decimals (shrink / scientific / `ACT:` on narrow mobile slots).
124
+
125
+ ## [2.1.0] - 2026-08-02
126
+
127
+ ### Added
128
+ - **COMPARE group contrast visibility** (`feat/compare-group-contrast-viz`):
129
+ - Amplitude default **16** for `COMPARE|ANALYSIS|POINTS` and RIBBONS twin (was floor `1.0`).
130
+ - SAE ON auto-sets Visualization filter to **+ Only**; SAE OFF restores the previous filter.
131
+ - Soft Y gap (+1× Dist Y) between contiguous `GROUP_*` blocks in ANALYSIS.
132
+ - Toggle **Sort dims by group contrast** (session-only, OFF default; visible when ≥2 groups) — permutes X by max pairwise `|Δmean|`.
133
+ - Cosine ▲/▼ disabled while groups are active (preserves block layout).
134
+
135
+ ### Changed
136
+ - Roadmap decisions D1–D12 closed (pack 1: L1+L2; L3 deferred; SemVer MINOR).
137
+
138
+ ## [2.0.0] - 2026-08-02
139
+
140
+ ### Added
141
+ - **Top‑K SAE Clean/Denoise** (`feat/topk-sae-denoise`): trained Sparse Autoencoder (PyTorch), **not** L1 SAE and **not** sinusoidal fake projection.
142
+ - Defaults: 768 → 8192 latents (cap), K=32; **train on current Compare/Arithmetic scope** (not full vocab); **ephemeral in-RAM** session model; `suggest_sae_dims` auto-scales for small N; clear on Visualize/Calculate.
143
+ - API: `GET /api/sae/status`, `POST /api/sae/train` (embeddings + async poll), `POST /api/sae/encode` (**Top‑K sparse** `indices`/`values` + `ORJSONResponse`; client densifies), `POST /api/sae/clear`.
144
+ - UI: Compare-only 50/50 CTA `[ VISUALIZE | Clean/Denoise (SAE) ]`, Train SAE + params, progress, metrics strip. **No SAE in Arithmetic.**
145
+ - Semantics: SAE ON **replaces** all Arithmetic/Compare vectors used for 3D + cosine with sparse activations; OFF restores cached raw 768D. Preference in `localStorage` (`vl3d.sae.*`). Scope change invalidates the session SAE.
146
+ - Encode I/O: wire format is `[N, K]` indices+values (not dense `[N, hidden]`); model singleton stays in RAM after first `load_model()`.
147
+ - **Zero coverage %** (carried onto this branch): Visualization panel slider expands how much of |t| stays at the zero/black color before blending to ±1 (cap 90%); `vl3d.viz.zeroCoverage`.
148
+ - **Hide/Show labels** in Visualization panel: toggles floating thread/group badges; persists `vl3d.viz.labelsVisible`.
149
+ - **SAE camera framing**: on Clean/Denoise toggle ON, dim-axis pitch scales so sparse features keep ~RAW wall width; camera soft-lerps to content bounds (ANALYSIS front / NAVIGATION angled). OFF restores Length + context pose with lerp. Empty train hyperparams no longer clamp to 32/k=1.
150
+
151
+ ## [1.8.0] - 2026-08-02
152
+
153
+ ### Added
154
+ - **Visualization Controls** (`feat/visualization-sign-color-controls`): right-dock panel under 3D Spatial Controls.
155
+ - Sign filter: `All | + Only | − Only` on **normalized** activations (ε=0.01); hides opposite sign and near-zero for ± only.
156
+ - Applies to POINTS (shader discard) and RIBBONS / continuity lines (index omission) in ARITHMETIC + COMPARE.
157
+ - Three hex color anchors (+1 / 0 / −1) replace the fixed mid-stop divergent ramp; defaults `#FFE600` / `#000000` / `#9900E6`.
158
+ - POINTS shader uniforms `uColorPos` / `uColorNeg` / `uColorZero`; live update + `localStorage` (`vl3d.viz.*`) + Reset.
159
+ - Edge collapse tab (dock-tab affordance) slides the Visualization card to a thin strip; persists `vl3d.viz.panelCollapsed`.
160
+
161
+ ## [Unreleased]
162
+
163
+ ### Changed
164
+ - **Defaults COMPARE|ANALYSIS|POINTS** (`feat/compare-analysis-points-defaults`):
165
+ - Sliders: Spacing `1.45`, Dist Y `1.0`, Amp `1.0`, Length `0.2`, Thickness `0.01`.
166
+ - Camera: `POS (-150.3, 0.7, 276.6)` / `ROT (-0.5, 0.9, 0)`.
167
+
168
+ ### Fixed
169
+ - **Defaults COMPARE|NAVIGATION|RIBBONS + fog off for RIBBONS** (`feat/compare-nav-ribbons-defaults-no-fog`):
170
+ - Sliders: Spacing `1.55`, Dist Y `10`, Amp `7`, Length `0.057`, Thickness `0.05`.
171
+ - Camera: `POS (-575.8, 43.8, 237.9)` / `ROT (-22.4, -35.7, 0)`.
172
+ - `setFogForRenderMode('RIBBONS')` clears scene fog; POINTS keeps soft FogExp2.
173
+ - Compare+RIBBONS no longer mounts the POINTS cloud (square dots on strips).
174
+ - **RIBBONS dark rectangle through translucent strips** (`fix/remove-ribbons-base-plane`): stop mounting `createBasePlaneForThreads` under wide ribbons (Arithmetic + Compare). Factory helpers retained unused.
175
+ - **Remove floor GridHelper** (`fix/remove-scene-grid`): no reference grid under the 3D scene (cleaner RIBBONS/COMPARE views).
176
+ - **Scene fog too dense for far RIBBONS** (`fix/soften-scene-fog`): `FogExp2` density `0.008` → `0.0008` so ribbons stay readable at COMPARE-scale camera distance; avoids distance “creeping” darkening. POINTS unaffected (custom shader).
177
+
178
+ ### Changed
179
+ - **Spacing (X) range → `[0.4, 2.0]`** (`feat/spacing-range-center-compare`): same track for all MODE|VIEW|RENDER; COMPARE default `0.7` unchanged. Other combo defaults unchanged (Amp/Thickness ranges untouched).
180
+ - **Length (Z) range → `[0.001, 0.2]`** step `0.001`, label 3 decimals: never reaches 0; COMPARE `0.1` ≈ mid; global/Analysis `0.2` at max.
181
+ - **Defaults COMPARE|NAVIGATION|POINTS** (`feat/compare-nav-points-defaults`):
182
+ - Sliders: Spacing `0.7`, Vector Distance `10`, Amplitude `4.9`, Length `0.1`, Thickness `0.01`.
183
+ - Camera: `POS (-106.5, 20.4, 390.2)` / `ROT (-3.9, -8.4, 0)` via `cameraViewDefaults.js`.
184
+ - Camera poses now resolve per MODE|VIEW|RENDER (VIEW fallbacks + overrides); applied with sliders on context change.
185
+ - First COMPARE entry loads full EN auto-manual lexicon (`COMPARE_AUTO_PRESETS.default`), not `sample5`.
186
+ - **English-only product UI** (`feat/english-user-facing-copy`): Navbar VIEW/ANALYSIS/NAVIGATION, spatial slider labels, Arithmetic/Compare buttons, Compare empty/sort/copy + EN auto-parts presets, landscape gate. Internal `data-view` / mode keys unchanged. Test titles and in-scope `src/` comments translated to EN; historical CHANGELOG entries left in Spanish.
187
+ - **Docs: English-only UI roadmap** — glossary + D6–D8 closed (EN Compare vocab; EN test titles/comments; keep historical CHANGELOG in Spanish). See `roadmap/english-ui-i18n.md` + `roadmap/PROMPT-english-ui.md`.
188
+ - **ThreadLabels cortas** (`feat/short-thread-labels` + `fix/compare-labels-full-tokens`):
189
+ - Arithmetic 3D: `WORD_A` / `WORD_B` / `WORD_C` / `RES` / `TOP1` (sin badge de tipo).
190
+ - Compare 3D: token completo ingresado (sin `TOPn` / sin truncar) — todos los items de la secuencia.
191
+ - **Defaults ARITHMETIC|ANALYSIS|POINTS** (`feat/arithmetic-analysis-points-defaults`):
192
+ - Sliders: Separación `0.4`, Distancia Y `10`, Amplitud Y `40`, Longitud Z `0.2`, Grosor `0.05` (override en `spatialSliderDefaults.js`).
193
+ - Cámara Análisis: `POS (-75.2, -0.8, 62.5)` / `ROT (0, 0, 0)`.
194
+ - Al cambiar MODE/VISTA/RENDER se reaplica el preset resuelto + sync de sliders.
195
+ - **Grosor Puntos mid → 0.05** (`feat/thickness-mid-0.05`):
196
+ - Default/mid `0.05` ∈ `[0.01, 0.09]` step `0.01` (simétrico lineal).
197
+ - **Amplitud (Y) max → 40** (`feat/amplitude-y-max-40`):
198
+ - Rango `[1.0, 40.0]` step `0.1`; default sigue en `7.0` (asimétrico — sin regresión del punto dulce al load).
199
+ - **Control Espacial 3D — dblclick reset** (`feat/spatial-slider-dblclick-reset`):
200
+ - Doble clic en un slider restaura solo ese valor al default del contexto MODE/VISTA/RENDER (hoy = global mid; overrides listos en `spatialSliderDefaults.js`).
201
+ - **Control Espacial 3D — finer steps** (`feat/finer-spatial-slider-steps`):
202
+ - Gradual intermediate values: Separación step `0.05` (2 dec), Distancia/Amplitud Y step `0.1` (1 dec), Longitud/Grosor step `0.01` (2 dec). Min/max/mid unchanged.
203
+ - **Control Espacial 3D — ranges re-centrados** (`feat/recenter-spatial-slider-ranges`):
204
+ - Defaults (punto dulce) son el mid lineal de cada slider: Separación X `0.4` ∈ `[0.1, 0.7]`, Distancia Y `10` ∈ `[1, 19]`, Amplitud Y `7` ∈ `[1, 13]`, Longitud Z `0.2` ∈ `[0.1, 0.3]`, Grosor `0.10` ∈ `[0.05, 0.15]`.
205
+ - Corrige Distancia Y y Grosor pegados al mínimo al load.
206
+
207
+ ### Added
208
+ - **Ngrok / phone dev access** (`feat/ngrok-dev-access`):
209
+ - Vite `allowedHosts` + `/api` proxy to `127.0.0.1:8000` (prefix proxy — not per-route).
210
+ - `VITE_API_BASE_URL=/api` enabled in `.env` / `.env.example`; `RemoteProvider` honors it.
211
+ - Lesson §6.1: new backend routes under `/api` need no Vite remap.
212
+
213
+ ## [1.7.0] - 2026-08-02
214
+
215
+ ### Added
216
+ - **COMPARE groups** (`feat/compare-group-labels`): parse `GROUP_name = tokens…` in the textarea; concatenate groups into the sequence; floating `GROUP_*` badges at member centroids. Preset **2 Groups**. Anchor remains global #1; cosine sort is global (may break contiguity). When groups are active, overlay shows group badges only (token cards hidden — still listed in cosine panel); label layer z-index above docks so badges stay visible.
217
+
218
+ ### Fixed
219
+ - **COMPARE group badges never appeared** (`feat/compare-group-labels`): `ComparePanel` → `handleCalculateCompare` callback dropped `tokenMeta`, so `groupId` never reached Instancer/ThreadLabels (token stack stayed, no `GROUP_*`).
220
+
221
+ ## [1.6.0] - 2026-08-02
222
+
223
+ ### Removed
224
+ - **RENDER: MESH** (`chore/remove-render-mesh`): surface heightfield mode retired from navbar and runtime. Supported modes: POINTS | RIBBONS. `normalizeRenderMode` maps legacy `"MESH"` → POINTS. Deleted `createSurfaceMesh` / `updateSurfaceMeshPositions` and `tests/MeshSurface.test.js`.
225
+
226
+ ## [1.5.0] - 2026-08-01
227
+
228
+ ### Added
229
+ - **RENDER: RIBBONS + base plane (Etapa E)**:
230
+ - `MeshFactory.createWideRibbonMesh` / `createBasePlane` — real-width quad strips + translucent ground (no Line linewidth).
231
+ - `Instancer` mutually exclusive branch for `RIBBONS` (Arithmetic + Compare); compare reorder updates wide ribbons in-situ.
232
+ - Vitest: `tests/MeshRibbons.test.js`.
233
+
234
+ ## [1.4.0] - 2026-08-01
235
+
236
+ ### Added
237
+ - **RENDER: MESH surface (Etapa D)**:
238
+ - `MeshFactory.createSurfaceMesh` — indexed quad heightfield (threads × dims), divergent colormap.
239
+ - `Instancer` branches on `renderMode === 'MESH'` (no Points); Arithmetic + Compare; compare reorder updates surface in-situ.
240
+ - Vitest: `tests/MeshSurface.test.js`.
241
+
242
+ ## [1.3.0] - 2026-08-01
243
+
244
+ ### Added
245
+ - **Mobile touch navigation (Etapa C)**:
246
+ - Virtual joystick (move) + canvas finger-drag look + ▲/▼ (Q/E) via `TouchControls` → `Navigation.setMoveAxes` / `applyLookDelta` / `setVertical`.
247
+ - UI touches on docks/HUD/navbar do not steal look; desktop WASDQE path unchanged (`lerp` §3.1).
248
+ - Vitest: `tests/TouchControls.test.js`.
249
+
250
+ ## [1.2.0] - 2026-08-01
251
+
252
+ ### Added
253
+ - **Responsive phone layout + landscape-first gate (Etapa B)**:
254
+ - Soft portrait overlay (`LandscapeGate`) with dismiss → `sessionStorage`; tablet/desktop unaffected; render loop never pauses.
255
+ - `@media (max-width: 768px)`: compact navbar, ≥44px targets, ≥16px inputs, safe-area insets, docks as overlay drawers.
256
+ - Vitest coverage in `tests/LandscapeGate.test.js`.
257
+
258
+ ## [1.1.0] - 2026-08-01
259
+
260
+ ### Added
261
+ - **Collapsible side docks (Etapa A)**:
262
+ - `CollapsibleDock` (`src/ui/CollapsibleDock.js`): left/right docks with edge tabs, ~250ms `transform` slide, no DOM unmount, `aria-expanded` on the tab.
263
+ - **Left dock**: hosts Arithmetic *or* Compare (MODE). Shared collapsed state across MODE switches (same `localStorage` key `vl3d.dock.left.collapsed`).
264
+ - **Right dock**: spatial sliders + `AxisGizmo` (D1). Key `vl3d.dock.right.collapsed`.
265
+ - Desktop persists collapsed in `localStorage`; mobile probe (`max-width: 768px`) defaults collapsed and skips persist (D4 hook for Etapa B).
266
+ - Bottom telemetry HUD remains always visible (D3).
267
+ - Vitest coverage in `tests/CollapsibleDock.test.js`.
268
+
269
+ ## [1.0.0] - 2026-08-02
270
+
271
+ ### Added
272
+ - **COMPARE Cosine-vs-Anchor List + 3D Reorder Tween**:
273
+ - `/compare` response enriched with `anchor` and per-item `cosine_vs_first` (dot product vs L2-normalized first token).
274
+ - `ComparePanel` scrollable similarity list under ACTIVE SEQUENCE METRICS (`SIMILITUD COSENO vs «…»`), REF badge on #1, per-row ▲/▼ reorder, and header ▼/▲ sort (desc/asc by score, REF stays #1) without camera focus.
275
+ - Default COMPARE presets use Spanish auto-manual vocabulary (`rueda`, `motor`, `freno`, `volante`, `embrague`, carrocería, fluidos, …); buttons 5/20/50 slice that lexicon.
276
+ - In-memory score recompute on reorder (no backend re-call); `Instancer.animateCompareReorder` lerps thread layout slots (~320ms ease-out) while reusing ribbon/points meshes; `ThreadLabels.updateOrigins` follows during the tween.
277
+ - Panel scrollbar invariant preserved: scroll only inside `.compare-cosine-list`.
278
+ - **Camera Pose Overlay (`VITE_SHOW_CAM_POSE`)**: Optional live `POS`/`ROT` HUD for capturing default navigation views. Gated by Vite env var; default `false` (see `.env.example`).
279
+ - **Square / Cube Point GLSL Shader (`src/visualizer/DivergentShading.js`)**:
280
+ - Replaced circular disc discard in `divergentFragmentShader` with axis-aligned square box distance calculation (`max(coord.x, coord.y)`).
281
+ - Crisp 1-pixel anti-aliased square bounding edge using `smoothstep(0.44, 0.49, maxDist)` for sharp 3D point cloud rendering.
282
+ - **Vertical Origin Baseline in Analysis Mode (`src/visualizer/Instancer.js` & `MeshFactory.js`)**:
283
+ - Vertical reference baseline mesh connecting thread origins at $X = \text{startX}$ in Analysis mode.
284
+ - Rendered with subtle cyan/gold glass opacity (`opacity: 0.6`, `transparent: true`, `frustumCulled: false`).
285
+ - **Dual Workspace Mode Selector (`MODE: [ ARITHMETIC | COMPARE ]`)**:
286
+ - Navbar top selector buttons `MODE: [ ARITHMETIC | COMPARE ]`.
287
+ - `ComparePanel` sidebar component (`src/ui/ComparePanel.js`) supporting sequence inputs of **1 to 1024 tokens**.
288
+ - Fast-loading preset buttons (5, 20, 50 tokens).
289
+ - Multi-sequence 3D WebGL layout engine in `Instancer.js` (`renderCompareData`).
290
+ - **Backend Batch Compare Endpoint (`/compare`)**:
291
+ - Pydantic `CompareRequest` accepting 1 to 1024 text/token items.
292
+ - Fast batch encoding and L2 normalization in `AppState.perform_compare`.
293
+ - Full Pytest coverage in `backend/tests/test_backend.py`.
294
+
295
+ ### Changed
296
+ - **Default Navigation Camera Pose (`src/engine/Navigation.js`)**: Startup `NAVEGACIÓN` view locked to captured corridor pose `POS (-178.3, 13.5, 52.2)` / `ROT (-5.4°, -51.5°, 0°)` with matching spatial slider defaults (Separación `0.4`, Amplitud `7.0`).
297
+ - **Top-10 UI Space Optimization (`src/ui/Sidebar.js` & `src/style.css`)**:
298
+ - Removed Top-K dropdown selector from sidebar.
299
+ - Locked `top_k` parameter to `10` across form inputs and API calls.
300
+ - Compact Top-10 rows (tighter padding/gap) so the Vector Arithmetic panel fits without a scrollbar; panel uses `height: fit-content` + `overflow: hidden`.
301
+
302
+ ## [0.2.0] - 2026-08-02
303
+
304
+ ### Added
305
+ - **Vista de "Análisis" 3D con Encuadre Frontal y Cartelitos (`src/ui/ThreadLabels.js` & `src/visualizer/LayoutEngine.js`)**:
306
+ - Vista por defecto al iniciar la app con cámara frontal (`Z=360`) encuadrando todos los hilos vectoriales de frente sin necesidad de desplazarse.
307
+ - Selector en la Navbar superior: `VISTA: [ ANÁLISIS | NAVEGACIÓN ]` junto al selector de RENDER.
308
+ - Apilamiento vertical en el eje $Y$ con separación constante para los hilos `word_a`, `word_b`, `word_c` y `res`, manteniendo activaciones $+1$ (hacia arriba) y $-1$ (hacia abajo).
309
+ - Sliders de control espacial 3D ampliados (`src/ui/ThreadSliders.js`): **Distancia Vectores (Y)** (distancia entre baselines de los 5 hilos) y **Amplitud (Y)** (escalado de la altura de picos $+1$ y valles $-1$ con rango ampliado de $1.0$ a $240.0$).
310
+ - Inclusión del 5.º hilo `#1 COS VECTOR` posicionado debajo de RESULT VECTOR.
311
+ - Cartelitos flotantes Glassmorphic vinculados en tiempo real a la proyección 3D del inicio de cada hilo.
312
+ - Tests unitarios en `tests/ThreadLabels.test.js` y `tests/LayoutEngine.test.js`.
313
+
314
+ ### Changed
315
+ - **Simplificación de Rampas Cromáticas Divergentes por Activación 3D (`src/visualizer/DivergentShading.js`)**:
316
+ - Eliminación de pasos intermedios de colores Rojo (rango positivo) y Verde (rango negativo) para transicionar directamente desde Negro/Transparente.
317
+ - Nueva rampa positiva ($0 \rightarrow +1$): Negro (`vec3(0.0)`) $\rightarrow$ Naranja (`#FF8000`) $\rightarrow$ Amarillo Incandescente (`#FFE600`).
318
+ - Nueva rampa negativa ($0 \rightarrow -1$): Negro (`vec3(0.0)`) $\rightarrow$ Azul Eléctrico (`#0040FF`) $\rightarrow$ Violeta Neón (`#9900E6`).
319
+ - Optimización computacional GPU/GLSL y CPU para $|t| < 0.01$: retorno directo de opacidad mínima ($\alpha \approx 0.05$) evitando operaciones de interpolación no necesarias.
320
+ - Cobertura de tests unitarios en `tests/DivergentShading.test.js` actualizada y extendida.
321
+
322
+ ## [0.1.0] - 2026-08-01
323
+
324
+ ### Added
325
+ - **Divergent Activation Shading (`roadmap/archivo/Shading Divergente por Activación.md`)**:
326
+ - Solid Circular Point GLSL Shader (`src/visualizer/DivergentShading.js`) rendering crisp, solid circular points with 1-pixel anti-aliasing edges instead of blurred halos.
327
+ - Dual Multi-Stop Color Ramps (`src/visualizer/DivergentShading.js`): Positive range ($0 \rightarrow +1$) transitions Negro $\rightarrow$ Rojo $\rightarrow$ Naranja $\rightarrow$ Amarillo. Negative range ($0 \rightarrow -1$) transitions Negro $\rightarrow$ Verde $\rightarrow$ Azul $\rightarrow$ Violeta.
328
+ - Full integration in `MeshFactory.js` and `Instancer.js` connecting all 3D thread vector points with continuous ribbon lines.
329
+ - Integration with `ThreadFactory.js` buffer attributes (`intensity`, `color`) and `frustumCulled = false` invariant.
330
+ - Real-time spatial control sliders (`src/ui/ThreadSliders.js`) updated with specified ranges ($X \in [0.1, 10.0]$, $Z \in [0.1, 5.0]$, Grosor Puntos $\in [0.1, 1.0]$ con valor por defecto $0.3$).
331
+ - TDD unit test suite (`tests/DivergentShading.test.js`) verifying multi-stop color ramp math.
332
+
333
+ - **Thread Geometry & Spatial Sliders 3D (`roadmap/archivo/sliders.md`)**:
334
+ - Synthetic 3D vector thread data factory (`src/visualizer/ThreadFactory.js`) generating buffer geometries for 3D lines and point nodes.
335
+ - In-situ GPU Float32Array buffer mutator (`updateAllThreadPositions` in `src/visualizer/LayoutEngine.js`) ensuring zero re-creation of geometries and zero memory leaks.
336
+ - Interactive spatial control UI panel (`src/ui/ThreadSliders.js`) with real-time 60fps sliders for lateral separation ($X$), longitudinal scale ($Z$), and node thickness.
337
+ - Pure layout math functions (`executeLayoutMath`) with Vitest test coverage (`tests/LayoutEngine.test.js`).
338
+ - WebGL scene manager (`src/engine/Scene.js`) with perspective camera, lights, and reference grid at $Y=0$.
339
+
340
+ - **Backend Core (Phase 1)**:
341
+ - FastAPI server with lifespan lazy-loading of SentenceTransformer (`all-mpnet-base-v2`).
342
+ - Pre-computed vocabulary embedding matrix in RAM for fast cosine similarity lookup.
343
+ - Core API endpoints: `/health`, `/embed`, `/tokenize`, and `/arithmetic` ($A - B + C$).
344
+ - Vocabulary generator script `scripts/generate_vocab.py` with custom word count and URL source support.
345
+ - Heartbeat test runner `backend/perform_tests.py`.
346
+ - Full unit test suite with `pytest`.
347
+
348
+ - **WebGL 3D Engine & Shaders (Phase 2)**:
349
+ - Fixed WASD camera flight controller (`src/engine/Navigation.js`) with linear velocity interpolation (`lerp`) and input safety (ignoring keys when focused on UI form inputs and clearing inputs on window blur).
350
+ - Custom GLSL point shader with glowing incandescent halos and anti-aliased radial smoothing (`src/engine/Shaders.js`).
351
+ - Three.js 3D scene orchestrator with dark background (`#050505`), fog, and reference grid (`src/engine/SceneSetup.js`).
352
+ - Inertial flight camera controller with WASDQE, mouse drag look, and Shift turbo acceleration (`src/engine/Navigation.js`).
353
+ - Raycaster mouse picking for 3D vector points (`src/engine/Interaction.js`).
354
+ - Spatial 3D layout mapper for vector dimension coordinates $X, Y, Z$ (`src/visualizer/LayoutEngine.js`).
355
+ - GPU instancing and Mesh factory enforcing `frustumCulled = false` invariant (`src/visualizer/MeshFactory.js` & `Instancer.js`).
356
+ - Corner 3D orientation axis gizmo (`src/visualizer/AxisGizmo.js`).
357
+ - Vitest test suite (`npm test`).
358
+
359
+ - **Control Panel & Hugging Face Deployment (Phase 4)**:
360
+ - Interactive CLI control panel (`setup.sh`) supporting dev mode, bare-metal server, heartbeat, vitest, pytest, vocabulary management, and HF Spaces deployment.
361
+ - Custom vocabulary management allowing custom file loads or generation of N words.
362
+ - Monolithic production Dockerfile for Hugging Face Spaces serving FastAPI on port `7860`.
363
+ - Static files serving integration in `backend/server.py` for bundled `dist/` production assets.
CONTEXT.md ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Context & Domain Model Glossary - VHectorLab 3D
2
+
3
+ ## Ubiquitous Language
4
+
5
+ ### Vector Arithmetic
6
+ Operation calculating a composite semantic vector $V_{res} = V_A - V_B + V_C$ in high-dimensional embedding space.
7
+
8
+ ### Vocabulary Matrix
9
+ Pre-computed L2-normalized array of word embeddings in RAM enabling sub-millisecond similarity search via matrix multiplication.
10
+
11
+ ### Lazy Loading
12
+ Initialization pattern deferring heavy model loading (PyTorch / SentenceTransformer) to the application lifespan startup event, preserving module import speed and clean testability.
13
+
14
+ ### Halo Shader
15
+ WebGL fragment shader rendering glowing incandescence for 3D activation points mapped to vector magnitudes.
16
+
17
+ ### Thread Geometry
18
+ 3D vector representations constructed using continuous Three.js `Line` and `Points` buffer geometries to display multi-point activation series over space.
19
+
20
+ ### In-situ Buffer Mutation
21
+ Updating existing GPU `Float32Array` attributes directly and setting `needsUpdate = true`, avoiding geometry re-creation and memory leaks during real-time UI interactions.
22
+
23
+ ### Compare Sequence
24
+ Ordered list of 1–1024 token/text items visualized as parallel 3D threads; list order equals layout `sequenceIndex`.
25
+
26
+ ### Compare Group
27
+ Named block in Compare input (`name = tokens…`) that concatenates members into the sequence and renders a floating group badge at the member-origin centroid (screen-offset left of token labels). Default bootstrap: `it_core` / `vehicles` / `women`. Global cosine sort may interleave groups.
28
+
29
+ ### Anchor Token
30
+ The token currently at position #1 in a Compare Sequence. Cosine similarity for every row is computed against this embedding (`cosine_vs_first`).
31
+
32
+ ### Cosine-vs-First Score
33
+ Dot product between an item’s L2-normalized embedding and the Anchor Token embedding; the anchor row is always `1.0000` (REF).
34
+
35
+ ### Collapsible Dock
36
+ Edge-hosted UI host that slides off-screen via CSS `transform` while keeping children mounted; left dock hosts Arithmetic or Compare, right dock hosts spatial sliders and AxisGizmo. The bottom telemetry HUD is not a dock.
37
+
38
+ _Avoid_: treating Sidebar/ComparePanel as independent minimize targets; unmounting panel DOM to “hide” it.
39
+
40
+ ### Landscape Gate
41
+ _Retired._ Former soft portrait overlay that suggested landscape; product prefers phone portrait. Do not reintroduce without an explicit ask.
42
+
43
+ ### Field Info Tip
44
+ Compact tap “i” control next to an editable field; shows a short English popover (mobile-safe, not hover-only `title`). Catalog lives in `fieldInfo.js`.
45
+
46
+ ### Wide Ribbon
47
+ Quad-strip mesh with real lateral width following a thread centerline, colored by activation; used by `RENDER: RIBBONS`. Distinct from 1px WebGL lines.
48
+
49
+ ### Visualization Controls
50
+ Bottom-HUD glass panel for global sign filter, divergent color anchors, Zero Coverage, Shared noise (Compare ≥2 tokens), and Group contrast (Compare ≥2 groups).
51
+
52
+ ### Sign Filter
53
+ Global show mode `all | positive | negative` over **normalized** activations (post z-score/tanh); near-zero `|t| < 0.01` is treated as neutral and hidden by +/− only.
54
+
55
+ ### Color Anchor
56
+ User-editable hex for normalized activations at +1, 0, and −1; replaces the former fixed dual mid-stop ramp via linear RGB lerp.
57
+
58
+ ### Zero Coverage
59
+ Percent of the |t| range held at the zero color (default black) before blending toward ±1 anchors; range 30%…100%, edited via A/mA knobs + readout.
60
+
61
+ ### Shared Noise
62
+ Compare paint that blackens dims where **all tokens in view** agree in sign and magnitude (min/max over embeddings; `groupId` ignored). Sits with Zero coverage; Similarity uses the same A/mA coverage chrome. Default OFF.
63
+
64
+ ### Group Contrast
65
+ Visualization paint for Compare with ≥2 groups: **Sign conflict** highlights opposite-sign dims (G1↔G2 means, custom color × |Δ|) and can blacken them by difference; **Group hue** optional. Geometry Y unchanged.
66
+
67
+ ### Workbench Theme
68
+ Default product chrome at `/`: Magic Workbench–inspired palette, Topaz typography, and bevelled panels on the fullscreen + floating-dock layout. Tunable via `VITE_AMIGA_*` in `.env`. Product name remains **VHectorLab 3D**. Value fields use dark fills (not white).
69
+
70
+ ### Galaxy feel
71
+ Galaxy VIEW uses a larger UMAP world scale and a slower flight profile (WASD/QE + mouse look) than ANALYSIS/NAVIGATION; leaving Galaxy restores default speeds. Galaxy POINTS render as soft circular stars.
72
+
73
+ ### Group hue
74
+ Optional Visualization → Group contrast mode: each `GROUP_*` paints black (−1) → a picked color (+1), coexisting with Shared noise / Sign conflict. Default OFF.
75
+
76
+ _Avoid_: parallel MPA skins (`/v25/`, `/amiga/` — retired in 2.4.1); multi-zone lab grids as the primary layout.
77
+
78
+ ### Shared Noise Similarity
79
+ `1 − |mean_G1 − mean_G2| / (|mean_G1| + |mean_G2|)` per dimension — high values mean shared-sign “noise” between groups.
80
+
81
+ ### Top‑K SAE
82
+ Trained sparse autoencoder with exactly K active latents per input (ReLU + Top‑K, no L1 shrinkage). Trained on the **current workspace scope** (Compare/Arithmetic batch); ephemeral in-RAM session model. Default caps 768 → 8192 with K=32 (auto-scaled for small N).
83
+
84
+ ### Embedding Catalog
85
+ Backend single source of truth (`model_catalog`) listing allowed SentenceTransformer Hub IDs plus flags (E5 mode, trust_remote_code, gated, default truncate).
86
+
87
+ ### Model Profile
88
+ Named local preset (`local-comfort`, `local-full`, `hf-demo`) that resolves to a catalog Hub ID and optional `TRUNCATE_DIM`. Selected only from `setup.sh` option 11 — not from the web UI.
89
+
90
+ ### Active Model
91
+ The one SentenceTransformer loaded in the backend process; exposed on `/health` as `model` / `short_label` / `embedding_dim` / `device` (and `model_profile` when set).
92
+
93
+ ### Truncate Dim
94
+ Optional Matryoshka width (`TRUNCATE_DIM`) applied after encode; effective `embedding_dim` is the truncated width when set.
95
+
96
+ _Avoid_: generative LLM “embeddings”; multi-model residency; in-app model switcher. HF Space model is pinned in the Dockerfile (currently `local-full` / Arctic @256); changing it needs an explicit approval + republish (option 8).
97
+
98
+ ### Clean/Denoise (SAE)
99
+ Compare-only toggle that replaces raw 768D embeddings with SAE sparse activations for 3D threads and cosine while ON. Requires Train SAE on current Visualize data; scope changes clear the session model. Not available in Arithmetic.
100
+
101
+ ### SAE Feature Space
102
+ Expanded latent dimension (default cap 8192, auto-scaled for small N) used for visualization and metrics while Clean/Denoise is enabled.
103
+
104
+ ### Dead Features
105
+ Latents that never activated on the training set; reported in SAE train metrics.
106
+
Dockerfile ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Monolithic Dockerfile for VHectorLab 3D on Hugging Face Spaces (Port 7860)
2
+ # Target hardware: cpu-basic (2 vCPU / 16 GB / 50 GB) — Arctic-m ~1.2 GB fits.
3
+ # Space profile matches local-full so Shared noise / Compare match the Mac lab.
4
+ FROM python:3.10-slim
5
+
6
+ # Install system dependencies & Node.js
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ curl \
9
+ git \
10
+ build-essential \
11
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
12
+ && apt-get install -y nodejs \
13
+ && apt-get clean \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ WORKDIR /app
17
+
18
+ # Install uv for Python package management
19
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
20
+
21
+ # Copy configuration and package files
22
+ COPY package.json package-lock.json ./
23
+ COPY backend/pyproject.toml backend/uv.lock backend/README.md ./backend/
24
+
25
+ # CPU-only torch on Linux via [tool.uv.sources] → pytorch-cpu (not UV_TORCH_BACKEND / uv sync).
26
+ RUN npm ci
27
+ RUN cd backend && uv sync --frozen
28
+
29
+ # Copy source code and vocabulary
30
+ COPY . .
31
+
32
+ # Production frontend bundle (same-origin /api)
33
+ ENV VITE_API_BASE_URL=/api
34
+ RUN npm run build
35
+
36
+ # Match local-full: Arctic-m-v2 Matryoshka @ 256 + EN∪ES vocab (precompute at image build).
37
+ ENV MODEL_PROFILE=local-full
38
+ ENV MODEL_NAME=Snowflake/snowflake-arctic-embed-m-v2.0
39
+ ENV TRUNCATE_DIM=256
40
+ ENV VOCAB_PATH=public/vocab_en_es.txt
41
+ ENV VOCAB_EMBEDDINGS_PATH=public/vocab_embeddings.npz
42
+ ENV SAE_DEVICE=CPU
43
+ RUN uv run --directory backend --frozen python /app/scripts/precompute_vocab_embeddings.py \
44
+ --device CPU \
45
+ --profile local-full \
46
+ --truncate-dim 256 \
47
+ --vocab /app/public/vocab_en_es.txt \
48
+ --out /app/public/vocab_embeddings.npz \
49
+ --model Snowflake/snowflake-arctic-embed-m-v2.0
50
+
51
+ # Expose Hugging Face Space default port 7860
52
+ EXPOSE 7860
53
+
54
+ # Environment variables for Docker / HF Space mode
55
+ ENV HOST=0.0.0.0
56
+ ENV PORT=7860
57
+ ENV UVICORN_RELOAD=0
58
+ ENV MODEL_PROFILE=local-full
59
+ ENV MODEL_NAME=Snowflake/snowflake-arctic-embed-m-v2.0
60
+ ENV TRUNCATE_DIM=256
61
+ ENV VOCAB_PATH=public/vocab_en_es.txt
62
+ ENV VOCAB_EMBEDDINGS_PATH=public/vocab_embeddings.npz
63
+ ENV SAE_DEVICE=CPU
64
+
65
+ # Entrypoint: FastAPI serving API + static frontend
66
+ CMD ["uv", "run", "--directory", "backend", "--frozen", "python", "-m", "server"]
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Hector Bauzan
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
NOTICE ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ VHectorLab 3D
2
+ Copyright 2026 Hector Bauzan
3
+
4
+ This product includes software developed for the VHectorLab 3D study / laboratory tool.
README.md ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: VHectorLab 3D
3
+ emoji: 🧭
4
+ colorFrom: gray
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: apache-2.0
10
+ short_description: VHectorLab 3D - Semantic embeddings study tool in WebGL
11
+ ---
12
+
13
+ # VHectorLab 3D
14
+
15
+ **A local study / laboratory tool for exploring semantic embeddings in 3D (WebGL). Not a production SaaS.**
16
+
17
+
18
+ 3D visualization (WebGL/Three.js) and semantic embedding vector arithmetic (`A − B + C`), plus compare sequences and related lab experiments.
19
+
20
+ | | |
21
+ | :--- | :--- |
22
+ | **License** | [Apache-2.0](./LICENSE) © 2026 Hector Bauzan |
23
+ | **Public demo** | [Hugging Face Space](https://huggingface.co/spaces/hbauzan/llm-semantic-visualizer) |
24
+ | **Local (macOS)** | `./setup.sh` → option `1` → http://127.0.0.1:5173 |
25
+
26
+ ---
27
+
28
+ ## Public demo (no install)
29
+
30
+ Try the hosted **cpu-basic** demo (same profile as `local-full`: Arctic-m-v2 @ 256-D, EN∪ES vocab):
31
+
32
+ 👉 **https://huggingface.co/spaces/hbauzan/llm-semantic-visualizer**
33
+
34
+ That Space is a shared study sandbox (Docker, CPU). Expect cold starts, variable latency, and soft resource limits. There is **no SLA**, no multi-tenant isolation, and no auth — please be gentle (avoid automated flooding of `/embed`, `/compare`, `/arithmetic`).
35
+
36
+ For serious work or heavier SAE/training experiments, run **locally** on macOS (below).
37
+
38
+ ---
39
+
40
+ ## Platform support
41
+
42
+ **Created and tested on macOS 26.5.1 (Darwin 25.5.0, Apple Silicon).**
43
+
44
+ This project was **not** prepared or validated for Windows or Linux. It may work there with a few adjustments (package managers, paths, process control), but that is unsupported. Prefer a Mac matching the versions above.
45
+
46
+ The Hugging Face Space image runs on Linux CPU inside HF’s Docker runtime — that path is supported for the **demo**, not as a general Linux desktop install guide.
47
+
48
+ ---
49
+
50
+ ## Quick start (recommended)
51
+
52
+ Everything goes through `./setup.sh`. On macOS, **option 1 installs missing tools for you** when needed.
53
+
54
+ ```bash
55
+ # 1. Clone the repo (folder name matches the GitHub repo)
56
+ git clone https://github.com/hbauzan/vhectorlab.git
57
+ cd vhectorlab
58
+
59
+ # 2. Open the control panel
60
+ chmod +x setup.sh # first time only, if needed
61
+ ./setup.sh
62
+ ```
63
+
64
+ Choose **option `1`** (`Deploy / Start Tool`).
65
+
66
+ That option will:
67
+
68
+ 1. Probe backend (`:8000`) and frontend (`:5173`): matching process **and** health check
69
+ 2. **If both healthy**: skip install/sync, tests, and start; open the browser
70
+ 3. **If either is sick** (port/process/health mismatch, e.g. frozen Vite): recycle both (stop + start after tests)
71
+ 4. **If only one is healthy**: restart **both** services after prereqs + tests
72
+ 5. **If both down**: check/install prerequisites on macOS (`uv`, Homebrew+Node if needed, `.env`, `uv sync`, `npm install` if needed), ensure vocab, run tests, start both, open browser
73
+ 6. After a fresh start: stream live backend logs (`Ctrl+C` pauses the tail — services stay up)
74
+
75
+ App URL when ready:
76
+
77
+ 👉 **http://127.0.0.1:5173** (VHectorLab 3D — Magic Workbench chrome)
78
+
79
+ - Pause live logs: `Ctrl+C` → **Enter** returns to the menu; **Ctrl+C** again exits the panel (services keep running)
80
+ - Stop services: option **`10`** only (Ctrl+C never stops the stack)
81
+
82
+ > First run can take a while: it may download Homebrew/Node/`uv`, Python packages, and the embedding model.
83
+ > Re-running option **1** while the stack is already healthy will **not** bounce the servers or re-run tests.
84
+
85
+ ---
86
+
87
+ ## What `setup.sh` expects (and installs on Mac)
88
+
89
+ | Tool | Role | If missing on macOS |
90
+ | :--- | :--- | :--- |
91
+ | **uv** | Python deps / run backend | Installed via [official uv installer](https://docs.astral.sh/uv/) |
92
+ | **Node.js + npm** | Frontend (Vite / Vitest) | Installed via Homebrew (`brew install node`) |
93
+ | **Homebrew** | Used only to install Node if needed | Installed from [brew.sh](https://brew.sh) |
94
+ | **`.env`** | Runtime config | Copied from `.env.example` |
95
+ | **Backend deps** | FastAPI / PyTorch stack | `uv sync --extra dev` in `backend/` |
96
+ | **Frontend deps** | Three.js / Vite | `npm install` when `node_modules` is absent |
97
+ | **Docker Desktop** | **Optional** — only for option **7** (HF Spaces image build) | Not installed by option 1. Option 7 checks Docker; on macOS it can install the Docker Desktop cask via Homebrew and asks you to start the app |
98
+
99
+ **Daily local use (option 1) does not need Docker Desktop** — only `uv` + Node/`npm` (+ network).
100
+
101
+ You do **not** need to install `uv` or Node by hand on a typical Mac — option 1 handles that. You *do* need network access and (for Homebrew) permission to install software.
102
+
103
+ ### Optional: Docker Desktop (option 7)
104
+
105
+ Install [Docker Desktop for Mac](https://www.docker.com/products/docker-desktop/) if you want to build the Hugging Face Spaces image locally. After install, open Docker Desktop and wait until the engine is running (whale icon steady), then use option **7**.
106
+
107
+ Option **8** publishes a **Docker** Space on **cpu-basic** via the `hf` CLI. It injects `deploy/hf/space-frontmatter.yml` into the Space README at push time (GitHub `README.md` stays clean) and does **not** require Docker Desktop locally.
108
+
109
+ ---
110
+
111
+ ## `setup.sh` menu
112
+
113
+ | Option | What it does |
114
+ | :--- | :--- |
115
+ | **1** | Deploy/start: **idempotent** — if both healthy, only open browser; else prereqs→tests→start (**no Docker**) |
116
+ | **2** | Backend only (`:8000`) — **skip if already healthy**; refuse if sick |
117
+ | **3** | System heartbeat / health check |
118
+ | **4** | Frontend unit tests (Vitest) |
119
+ | **5** | Backend unit tests (pytest) |
120
+ | **6** | Vocabulary: load a custom file or generate N words |
121
+ | **7** | Build HF Spaces Docker image locally (torch CPU · optional :7860 smoke) |
122
+ | **8** | Publish HF Space: inject `deploy/hf/space-frontmatter.yml` into README at push (GitHub README stays clean) |
123
+ | **9** | View backend logs |
124
+ | **10** | **Stop** / clean services (always kills; not idempotent) |
125
+ | **0** | Exit |
126
+
127
+ ---
128
+
129
+ ## Environment variables
130
+
131
+ Copy `.env.example` → `.env` (option 1 does this if `.env` is missing). Common keys:
132
+
133
+ | Variable | Default | Description |
134
+ | :--- | :--- | :--- |
135
+ | `HOST` / `PORT` | `127.0.0.1` / `8000` | Backend listen address |
136
+ | `MODEL_NAME` | `all-mpnet-base-v2` | Embedding model |
137
+ | `VOCAB_PATH` | `public/vocab.txt` | App vocabulary file (use `public/vocab_en_es.txt` for EN∪ES) |
138
+ | `VITE_API_BASE_URL` | `/api` | Browser API base URL |
139
+ | `VITE_SHOW_CAM_POSE` | `false` | Live camera POS/ROT overlay (debug) |
140
+ | `VITE_AMIGA_PEN_0`…`_7` | MagicWB pens | Workbench theme palette pens (`#RRGGBB`) |
141
+ | `VITE_AMIGA_BG` / `_FG` / `_ACCENT` | `#222222` / `#F0F0F0` / `#3B67A2` | Page/panel bg, text, titlebar accent |
142
+
143
+ ### EN + ES vocabulary
144
+
145
+ Default `public/vocab.txt` is English-only. For multilingual Arithmetic/Compare neighbors:
146
+
147
+ ```bash
148
+ uv run python scripts/merge_vocab_en_es.py
149
+ # → public/vocab_en_es.txt (~10k EN ∪ ES seed; includes king/rey … cat/gato)
150
+ ```
151
+
152
+ Set `VOCAB_PATH=public/vocab_en_es.txt`, then regenerate embeddings (`scripts/precompute_vocab_embeddings.py` or **setup.sh option 11**). Languages beyond EN+ES are out of scope for now.
153
+
154
+ ### Embedding model / profile (setup option 11)
155
+
156
+ From `./setup.sh` → **11. Select Embedding Model / Profile**: pick `P#` / `M#`, stage `.env`, rebuild `vocab_embeddings.npz` (EN∪ES), restart backend, print `/health`. On failure the previous `.env` is restored. HF Space options 7/8 are unchanged.
157
+
158
+ Default `.env.example` keeps **`all-mpnet-base-v2`** (EN baseline). Switch to `local-comfort` / `local-full` via option 11 when you want multilingual.
159
+
160
+ Cross-lingual smoke (after option 11, non-blocking):
161
+
162
+ ```bash
163
+ uv run --directory backend python ../scripts/smoke_crosslingual_cosine.py
164
+ uv run --directory backend python ../scripts/smoke_crosslingual_cosine.py --profile local-comfort --include-fr
165
+ # Fail only if mean EN↔ES cosine is soft-low:
166
+ uv run --directory backend python ../scripts/smoke_crosslingual_cosine.py --strict --threshold 0.35
167
+ ```
168
+
169
+ ---
170
+
171
+ ## Manual start (without the panel)
172
+
173
+ Only if you prefer not to use `setup.sh` (still assumes macOS + tools already available):
174
+
175
+ ```bash
176
+ # Backend
177
+ cd backend
178
+ uv sync --extra dev
179
+ uv run python -m server
180
+
181
+ # Frontend (another terminal, repo root)
182
+ npm install
183
+ npx vite --port 5173 --host 127.0.0.1
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Troubleshooting
189
+
190
+ | Symptom | What to check |
191
+ | :--- | :--- |
192
+ | Unsupported platform warning | You are not on Darwin/macOS — unsupported; adapt paths/package managers yourself |
193
+ | Homebrew install asks for a password | Normal on first install; approve locally |
194
+ | `uv` / `npm` still missing after option 1 | Open a **new** terminal (PATH refresh), then re-run `./setup.sh` |
195
+ | Backend tests slow / fail on first run | Model download needs network; wait and retry |
196
+ | Browser does not open | Open http://127.0.0.1:5173 manually |
197
+ | Port already in use / option 1 says **sick** | Option 1 now recycles automatically. Or use option `10`, then start again |
198
+ | Option 1 restarts everything every time | It should not when both are healthy — report a bug if it still kills/relaunches a healthy stack |
199
+ | Option 7 fails / “Docker daemon not running” | Install/open **Docker Desktop**, wait until it is running, retry option 7 |
200
+ | `Failed to spawn: pytest` / No such file | Stale `backend/.venv` after renaming the folder. Option 1 now recreates it; or run `rm -rf backend/.venv && cd backend && uv sync --extra dev` |
201
+
202
+ ---
203
+
204
+ ## License
205
+
206
+ Apache License 2.0 — see [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
architecture_spec.md ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture Specification - VHectorLab 3D
2
+
3
+ ## System Architecture
4
+
5
+ VHectorLab 3D is a 3D semantic vector visualizer and vector arithmetic explorer.
6
+
7
+ ### Backend Tier (`backend/`)
8
+ - **Framework**: FastAPI with Uvicorn.
9
+ - **Model Interface**: Lazy-loaded `SentenceTransformer` via catalog selection (`MODEL_PROFILE` / `MODEL_NAME` / `TRUNCATE_DIM`) in FastAPI `lifespan`. Catalog module: `backend/model_catalog.py` (`resolve_selection_from_env`, `build_model`, `encode_texts`).
10
+ - **Encode adapter**: All query/vocab encoding goes through `encode_texts` (E5 `query:` prefixes when `e5_mode`, optional Matryoshka `truncate_dim`, L2 normalize). Frontend never applies model-specific quirks.
11
+ - **Vocabulary Acceleration**: Pre-computed L2-normalized embedding matrix $(N \times D)$ kept in RAM for instant matrix-vector dot product cosine similarity calculation:
12
+ $$\text{Sim}(V_{res}, V_{vocab}) = V_{vocab} \cdot V_{res}^T$$
13
+ - **SAE dim safety**: On load, if session SAE `input_dim` ≠ active `embedding_dim`, SAE RAM + checkpoint are cleared automatically.
14
+ - **Vocab NPZ**: Prefer `VOCAB_EMBEDDINGS_PATH` when present. Keys: `words`, `embeddings`, `model_name`, `embedding_dim`, optional `truncate_dim`. **Mismatch strategy**: if `model_name` or effective width disagrees with the active catalog selection, log a loud warning, re-encode from `VOCAB_PATH` via `encode_texts`, and overwrite the NPZ (local DX auto-rebuild — not a hard fail).
15
+ - **CORS Policy**: `allow_origins=["*"]` with `allow_credentials=False` for cross-origin WebGL clients.
16
+
17
+ ### API Surface
18
+ - `GET /health`: Server status, model Hub id, optional `model_profile`, `embedding_dim`, `truncate_dim`, vocabulary size, and runtime `device` (`cpu`|`cuda`|`mps`).
19
+ - `POST /embed`: Computes embedding vector for input text.
20
+ - `POST /tokenize`: Returns tokenization details.
21
+ - `POST /arithmetic`: Computes $V_{res} = V_A - V_B + V_C$ and returns top-$K$ nearest vocabulary words and component vectors.
22
+ - `POST /compare`: Batch-encodes 1–1024 texts, L2-normalizes embeddings, and returns per-item cosine vs the first token (anchor).
23
+ - `POST /project`: Projects precomputed embedding vectors to 2D/3D (does **not** re-encode text). v1 method = **`umap` only**; `pca` / `tsne` → **501**; other methods → **400**. Default seed `42`. When `dim > 50`, internal PCA→50 before UMAP. Positions are zero-mean and RMS-scaled server-side.
24
+
25
+ ### Data Contracts
26
+
27
+ #### Health
28
+ ```json
29
+ {
30
+ "status": "ok",
31
+ "model": "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
32
+ "model_profile": "local-comfort",
33
+ "short_label": "MiniLM-multi",
34
+ "embedding_dim": 384,
35
+ "truncate_dim": null,
36
+ "device": "mps",
37
+ "is_loaded": true,
38
+ "vocab_size": 12345
39
+ }
40
+ ```
41
+ `model_profile` / `truncate_dim` / `short_label` may be `null` when unset. `embedding_dim` is the effective width after truncate. Navbar ONLINE chip uses profile (if any) · short_label · `{D}D` · device.
42
+
43
+ #### Arithmetic
44
+ ```json
45
+ {
46
+ "word_a": "king",
47
+ "word_b": "man",
48
+ "word_c": "woman",
49
+ "top_k": 10
50
+ }
51
+ ```
52
+ Returns:
53
+ ```json
54
+ {
55
+ "inputs": {"word_a": "king", "word_b": "man", "word_c": "woman"},
56
+ "vector_res": [0.01, ..., 0.05],
57
+ "components": {"vec_a": [...], "vec_b": [...], "vec_c": [...]},
58
+ "results": [
59
+ {"word": "queen", "score": 0.892, "token_id": 42}
60
+ ]
61
+ }
62
+ ```
63
+
64
+ #### Compare
65
+ ```json
66
+ { "texts": ["king", "queen", "man"] }
67
+ ```
68
+ Returns:
69
+ ```json
70
+ {
71
+ "count": 3,
72
+ "anchor": { "index": 0, "text": "king" },
73
+ "items": [
74
+ {
75
+ "id": "tok_0",
76
+ "index": 0,
77
+ "text": "king",
78
+ "embedding": [0.01, "..."],
79
+ "cosine_vs_first": 1.0
80
+ }
81
+ ]
82
+ }
83
+ ```
84
+ `cosine_vs_first` is $\text{dot}(\hat{e}_i, \hat{e}_0)$ on L2-normalized embeddings. Frontend reorders may recompute scores in memory without re-calling `/compare`.
85
+
86
+ #### Project (Galaxy / UMAP)
87
+ ```json
88
+ {
89
+ "vectors": [[0.01, "..."], ["..."]],
90
+ "method": "umap",
91
+ "n_components": 3,
92
+ "seed": 42,
93
+ "params": { "n_neighbors": 15, "min_dist": 0.1, "metric": "cosine" }
94
+ }
95
+ ```
96
+ Constraints: `len(vectors)` ∈ 1..1024; uniform row dim; `n_components` ∈ {2, 3}; `method` = `umap` (green path).
97
+
98
+ Returns:
99
+ ```json
100
+ {
101
+ "method": "umap",
102
+ "n_components": 3,
103
+ "positions": [[x, y, z], "..."],
104
+ "meta": {
105
+ "seed": 42,
106
+ "n_neighbors": 15,
107
+ "min_dist": 0.1,
108
+ "metric": "cosine",
109
+ "pre_pca_dims": 50
110
+ }
111
+ }
112
+ ```
113
+ `meta.pre_pca_dims` is present only when the internal PCA pre-step ran. Encoding stays on `/compare` (or SAE encode); `/project` is additive.
backend/README.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VHectorLab 3D — Backend
2
+
3
+ FastAPI service for this **study / laboratory** tool: semantic embeddings, vector arithmetic (`A − B + C`), tokenize, and compare APIs that drive the WebGL UI.
4
+
5
+ This is **not** a production multi-tenant API. Prefer the [public Hugging Face demo](https://huggingface.co/spaces/hbauzan/llm-semantic-visualizer) for a quick look, or run the full stack locally via the root [`../README.md`](../README.md) (`./setup.sh`).
6
+
7
+ ## Requirements & setup
8
+
9
+ From this directory (macOS + [`uv`](https://docs.astral.sh/uv/)):
10
+
11
+ ```bash
12
+ uv sync --extra dev
13
+ uv run pytest
14
+ uv run python -m server
15
+ ```
16
+
17
+ Default listen address comes from the repo `.env` / `.env.example` (`HOST` / `PORT`, typically `127.0.0.1:8000`).
18
+
19
+ ## License
20
+
21
+ Apache License 2.0 — see [`../LICENSE`](../LICENSE) and [`../NOTICE`](../NOTICE).
backend/artifacts/.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # SAE checkpoints (large binaries) — train locally; do not commit weights.
2
+ *.pt
3
+ !.gitkeep
backend/artifacts/.gitkeep ADDED
File without changes
backend/artifacts/README.md ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # SAE artifacts
2
+
3
+ Compare-scope Top-K SAE checkpoint: `sae_weights.pt` (gitignored).
4
+
5
+ Created by `POST /api/sae/train`. Deleted by `POST /api/sae/clear` (Retrain confirm in UI).
6
+ Loaded automatically on `/api/sae/status` and `/api/sae/encode`.
backend/crosslingual_smoke.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cross-lingual cosine helpers for smoke harness (multillm Slice 7).
3
+
4
+ Pure math + pair tables — encode happens in the CLI script.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ import numpy as np
12
+ from backend.vocab_merge import DEMO_PAIRS
13
+
14
+ # Encode-only FR triads (FR need not be in vocab file).
15
+ FR_TRIADS: tuple[tuple[str, str, str], ...] = (
16
+ ("brother", "hermano", "frère"),
17
+ ("city", "ciudad", "ville"),
18
+ ("sun", "sol", "soleil"),
19
+ ("book", "libro", "livre"),
20
+ ("truth", "verdad", "vérité"),
21
+ )
22
+
23
+ DEFAULT_SOFT_THRESHOLD = 0.35
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class PairScore:
28
+ left: str
29
+ right: str
30
+ cosine: float
31
+
32
+
33
+ def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
34
+ va = np.asarray(a, dtype=np.float64).reshape(-1)
35
+ vb = np.asarray(b, dtype=np.float64).reshape(-1)
36
+ if va.shape != vb.shape:
37
+ raise ValueError(f"dim mismatch: {va.shape} vs {vb.shape}")
38
+ na = float(np.linalg.norm(va))
39
+ nb = float(np.linalg.norm(vb))
40
+ if na == 0.0 or nb == 0.0:
41
+ return 0.0
42
+ return float(np.dot(va, vb) / (na * nb))
43
+
44
+
45
+ def score_pairs(
46
+ embeddings: dict[str, np.ndarray],
47
+ pairs: tuple[tuple[str, str], ...] = DEMO_PAIRS,
48
+ ) -> list[PairScore]:
49
+ scores: list[PairScore] = []
50
+ for left, right in pairs:
51
+ if left not in embeddings or right not in embeddings:
52
+ raise KeyError(f"missing embedding for pair {left!r}/{right!r}")
53
+ scores.append(
54
+ PairScore(
55
+ left=left,
56
+ right=right,
57
+ cosine=cosine_similarity(embeddings[left], embeddings[right]),
58
+ )
59
+ )
60
+ return scores
61
+
62
+
63
+ def mean_cosine(scores: list[PairScore]) -> float:
64
+ if not scores:
65
+ return 0.0
66
+ return float(sum(s.cosine for s in scores) / len(scores))
backend/device.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolve torch runtime device from env (lazy torch import — safe for AppState top-level)."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def get_optimal_device(env_device: str = "AUTO") -> str:
7
+ """Resolve device string: AUTO|CPU|CUDA|MPS|GPU → cuda|mps|cpu."""
8
+ env_device = (env_device or "AUTO").upper().strip()
9
+ if env_device == "CPU":
10
+ return "cpu"
11
+
12
+ import torch
13
+
14
+ if env_device == "CUDA":
15
+ return "cuda" if torch.cuda.is_available() else "cpu"
16
+ if env_device == "MPS":
17
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
18
+ return "mps"
19
+ return "cpu"
20
+ if env_device in ("GPU", "AUTO"):
21
+ if torch.cuda.is_available():
22
+ return "cuda"
23
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
24
+ return "mps"
25
+ return "cpu"
26
+ return "cpu"
backend/model_catalog.py ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Embedding model catalog — single source of truth for profiles and Hub IDs.
3
+
4
+ Resolves selection from env, builds SentenceTransformer, and encodes texts
5
+ with E5 prefixes / Matryoshka truncate / L2 norm in one adapter.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass, replace
14
+ from typing import Any
15
+
16
+ import numpy as np
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Explicit no-go list from roadmap §2.3 (must never appear in list_models).
21
+ NO_GO_HUB_IDS: frozenset[str] = frozenset(
22
+ {
23
+ "BAAI/bge-m3",
24
+ "jinaai/jina-embeddings-v3",
25
+ }
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ModelSelection:
31
+ """Resolved embedding selection for env / setup / AppState (Slice 2+)."""
32
+
33
+ hub_id: str
34
+ profile: str | None
35
+ trust_remote_code: bool
36
+ e5_mode: bool
37
+ truncate_dim: int | None
38
+ gated: bool
39
+ short_label: str
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class _ModelEntry:
44
+ hub_id: str
45
+ short_label: str
46
+ trust_remote_code: bool = False
47
+ e5_mode: bool = False
48
+ default_truncate_dim: int | None = None
49
+ gated: bool = False
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class ProfileInfo:
54
+ id: str
55
+ hub_id: str
56
+ default_truncate_dim: int | None
57
+
58
+
59
+ # §2.1 required + §2.2 inferred extras (order = menu order later).
60
+ _MODELS: tuple[_ModelEntry, ...] = (
61
+ _ModelEntry(
62
+ hub_id="sentence-transformers/all-mpnet-base-v2",
63
+ short_label="EN baseline (mpnet)",
64
+ ),
65
+ _ModelEntry(
66
+ hub_id="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
67
+ short_label="MiniLM-multi",
68
+ ),
69
+ _ModelEntry(
70
+ hub_id="Snowflake/snowflake-arctic-embed-m-v2.0",
71
+ short_label="Arctic-m-v2",
72
+ trust_remote_code=True,
73
+ default_truncate_dim=256,
74
+ ),
75
+ _ModelEntry(
76
+ hub_id="Alibaba-NLP/gte-multilingual-base",
77
+ short_label="GTE-multi",
78
+ trust_remote_code=True,
79
+ ),
80
+ _ModelEntry(
81
+ hub_id="intfloat/multilingual-e5-small",
82
+ short_label="E5-small-multi",
83
+ e5_mode=True,
84
+ ),
85
+ _ModelEntry(
86
+ hub_id="google/embeddinggemma-300m",
87
+ short_label="EmbeddingGemma-300m",
88
+ gated=True,
89
+ ),
90
+ _ModelEntry(
91
+ hub_id="sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
92
+ short_label="mpnet-multi",
93
+ ),
94
+ _ModelEntry(
95
+ hub_id="intfloat/multilingual-e5-base",
96
+ short_label="E5-base-multi",
97
+ e5_mode=True,
98
+ ),
99
+ _ModelEntry(
100
+ hub_id="sentence-transformers/distiluse-base-multilingual-cased-v2",
101
+ short_label="distiluse-multi",
102
+ ),
103
+ )
104
+
105
+ _MODEL_BY_HUB: dict[str, _ModelEntry] = {m.hub_id: m for m in _MODELS}
106
+
107
+ # Named profiles (§2.4). Space Docker pins `local-full` (see Dockerfile).
108
+ # `hf-demo` remains a local MiniLM preset (comfort twin), not the live Space model.
109
+ _PROFILES: dict[str, ProfileInfo] = {
110
+ "local-comfort": ProfileInfo(
111
+ id="local-comfort",
112
+ hub_id="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
113
+ default_truncate_dim=None,
114
+ ),
115
+ "local-full": ProfileInfo(
116
+ id="local-full",
117
+ hub_id="Snowflake/snowflake-arctic-embed-m-v2.0",
118
+ default_truncate_dim=256,
119
+ ),
120
+ "hf-demo": ProfileInfo(
121
+ id="hf-demo",
122
+ hub_id="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
123
+ default_truncate_dim=None,
124
+ ),
125
+ }
126
+
127
+
128
+ def _selection_from_entry(
129
+ entry: _ModelEntry,
130
+ *,
131
+ profile: str | None,
132
+ truncate_dim: int | None,
133
+ ) -> ModelSelection:
134
+ return ModelSelection(
135
+ hub_id=entry.hub_id,
136
+ profile=profile,
137
+ trust_remote_code=entry.trust_remote_code,
138
+ e5_mode=entry.e5_mode,
139
+ truncate_dim=truncate_dim,
140
+ gated=entry.gated,
141
+ short_label=entry.short_label,
142
+ )
143
+
144
+
145
+ def list_models() -> Sequence[ModelSelection]:
146
+ """Catalog models as ModelSelection with profile=None and entry default truncate."""
147
+ return tuple(
148
+ _selection_from_entry(
149
+ entry,
150
+ profile=None,
151
+ truncate_dim=entry.default_truncate_dim,
152
+ )
153
+ for entry in _MODELS
154
+ )
155
+
156
+
157
+ def list_profiles() -> Sequence[ProfileInfo]:
158
+ """Named profiles in stable order: comfort, full, hf-demo."""
159
+ order = ("local-comfort", "local-full", "hf-demo")
160
+ return tuple(_PROFILES[pid] for pid in order)
161
+
162
+
163
+ def resolve_profile(profile_id: str) -> ModelSelection:
164
+ """Map a profile id to a full ModelSelection (including default truncate_dim)."""
165
+ info = _PROFILES.get(profile_id)
166
+ if info is None:
167
+ raise ValueError(f"unknown profile: {profile_id!r}")
168
+ entry = _MODEL_BY_HUB.get(info.hub_id)
169
+ if entry is None:
170
+ raise ValueError(
171
+ f"profile {profile_id!r} maps to missing catalog hub_id {info.hub_id!r}"
172
+ )
173
+ return _selection_from_entry(
174
+ entry,
175
+ profile=info.id,
176
+ truncate_dim=info.default_truncate_dim,
177
+ )
178
+
179
+
180
+ def _lookup_entry(hub_id: str) -> _ModelEntry | None:
181
+ """Exact Hub ID or bare name matching a catalog suffix."""
182
+ entry = _MODEL_BY_HUB.get(hub_id)
183
+ if entry is not None:
184
+ return entry
185
+ bare = hub_id.split("/")[-1]
186
+ matches = [m for m in _MODELS if m.hub_id == bare or m.hub_id.endswith("/" + bare)]
187
+ if len(matches) == 1:
188
+ return matches[0]
189
+ return None
190
+
191
+
192
+ def get_model(hub_id: str) -> ModelSelection:
193
+ """Look up a catalog model by Hub ID or bare name (no profile)."""
194
+ entry = _lookup_entry(hub_id)
195
+ if entry is None:
196
+ raise ValueError(f"unknown model: {hub_id!r}")
197
+ return _selection_from_entry(
198
+ entry,
199
+ profile=None,
200
+ truncate_dim=entry.default_truncate_dim,
201
+ )
202
+
203
+
204
+ def _parse_truncate_dim(raw: str | int | None) -> int | None:
205
+ if raw is None:
206
+ return None
207
+ if isinstance(raw, int):
208
+ return raw if raw > 0 else None
209
+ text = str(raw).strip()
210
+ if not text:
211
+ return None
212
+ value = int(text)
213
+ if value <= 0:
214
+ raise ValueError(f"TRUNCATE_DIM must be positive, got {value}")
215
+ return value
216
+
217
+
218
+ def _passthrough_selection(hub_id: str, truncate_dim: int | None) -> ModelSelection:
219
+ """Legacy / unlisted Hub ID — minimal flags inferred from the id string."""
220
+ lower = hub_id.lower()
221
+ return ModelSelection(
222
+ hub_id=hub_id,
223
+ profile=None,
224
+ trust_remote_code=False,
225
+ e5_mode="e5" in lower,
226
+ truncate_dim=truncate_dim,
227
+ gated=False,
228
+ short_label=hub_id.split("/")[-1],
229
+ )
230
+
231
+
232
+ def resolve_selection(
233
+ *,
234
+ profile: str | None = None,
235
+ model_name: str | None = None,
236
+ truncate_dim: int | None = None,
237
+ ) -> ModelSelection:
238
+ """
239
+ Resolve ModelSelection from explicit knobs.
240
+
241
+ Profile wins over model_name for hub_id. truncate_dim overrides profile/model defaults
242
+ when provided (including overriding local-full's 256).
243
+ """
244
+ if profile and profile.strip():
245
+ sel = resolve_profile(profile.strip())
246
+ if truncate_dim is not None:
247
+ return replace(sel, truncate_dim=truncate_dim)
248
+ return sel
249
+
250
+ name = (model_name or "").strip() or "sentence-transformers/all-mpnet-base-v2"
251
+ entry = _lookup_entry(name)
252
+ if entry is None:
253
+ return _passthrough_selection(name, truncate_dim)
254
+ sel = _selection_from_entry(
255
+ entry,
256
+ profile=None,
257
+ truncate_dim=entry.default_truncate_dim,
258
+ )
259
+ if truncate_dim is not None:
260
+ return replace(sel, truncate_dim=truncate_dim)
261
+ return sel
262
+
263
+
264
+ def resolve_selection_from_env(
265
+ *,
266
+ profile: str | None = None,
267
+ model_name: str | None = None,
268
+ truncate_dim: str | int | None = None,
269
+ ) -> ModelSelection:
270
+ """Read MODEL_PROFILE / MODEL_NAME / TRUNCATE_DIM (args override env)."""
271
+ env_profile = profile if profile is not None else os.getenv("MODEL_PROFILE")
272
+ env_model = model_name if model_name is not None else os.getenv("MODEL_NAME")
273
+ if truncate_dim is not None:
274
+ dim = _parse_truncate_dim(truncate_dim)
275
+ else:
276
+ dim = _parse_truncate_dim(os.getenv("TRUNCATE_DIM"))
277
+ return resolve_selection(
278
+ profile=env_profile,
279
+ model_name=env_model,
280
+ truncate_dim=dim,
281
+ )
282
+
283
+
284
+ def _gte_auto_model(st_model: Any) -> Any | None:
285
+ """Return the underlying HF auto model for a SentenceTransformer, if present."""
286
+ try:
287
+ first = st_model[0]
288
+ except (TypeError, IndexError, KeyError):
289
+ return None
290
+ return getattr(first, "auto_model", None)
291
+
292
+
293
+ def _repair_gte_nonpersistent_buffers(st_model: Any) -> None:
294
+ """
295
+ Re-init GTE/Arctic non-persistent buffers after transformers 5.x meta load.
296
+
297
+ Remote GTE code registers position_ids / RoPE caches with persistent=False but
298
+ does not restore them in _init_weights. transformers≥5 materializes on meta and
299
+ leaves those buffers as uninitialized / zero storage → RoPE NaNs or IndexError.
300
+ See HF transformers#43644 / #43950.
301
+ """
302
+ import torch
303
+
304
+ auto = _gte_auto_model(st_model)
305
+ if auto is None:
306
+ return
307
+ embeddings = getattr(auto, "embeddings", None)
308
+ if embeddings is None:
309
+ return
310
+
311
+ position_ids = getattr(embeddings, "position_ids", None)
312
+ if isinstance(position_ids, torch.Tensor) and position_ids.numel() > 0:
313
+ expected = torch.arange(
314
+ position_ids.numel(),
315
+ device=position_ids.device,
316
+ dtype=position_ids.dtype,
317
+ )
318
+ if not torch.equal(position_ids, expected):
319
+ embeddings.register_buffer(
320
+ "position_ids", expected, persistent=False
321
+ )
322
+ logger.info("Repaired GTE embeddings.position_ids after meta load")
323
+
324
+ rotary = getattr(embeddings, "rotary_emb", None)
325
+ if rotary is None:
326
+ return
327
+ dim = int(getattr(rotary, "dim", 0) or 0)
328
+ base = float(getattr(rotary, "base", 10000.0) or 10000.0)
329
+ max_pos = int(
330
+ getattr(rotary, "max_position_embeddings", 0)
331
+ or getattr(auto.config, "max_position_embeddings", 0)
332
+ or 0
333
+ )
334
+ if dim <= 0 or max_pos <= 0:
335
+ return
336
+ device = next(auto.parameters()).device
337
+ inv_freq = 1.0 / (
338
+ base ** (torch.arange(0, dim, 2, device=device, dtype=torch.float32) / dim)
339
+ )
340
+ rotary.register_buffer("inv_freq", inv_freq, persistent=False)
341
+ # Prefer the module's own cache builder (covers NTKScalingRotaryEmbedding).
342
+ if hasattr(rotary, "_set_cos_sin_cache"):
343
+ rotary._set_cos_sin_cache(
344
+ seq_len=max_pos,
345
+ device=device,
346
+ dtype=torch.get_default_dtype(),
347
+ )
348
+ logger.info("Repaired GTE rotary_emb buffers after meta load")
349
+
350
+
351
+ def build_model(selection: ModelSelection, device: str) -> Any:
352
+ """Construct SentenceTransformer for the selection (trust_remote_code when declared).
353
+
354
+ Arctic (GTE remote code) ships with use_memory_efficient_attention=true and
355
+ unpad_inputs=true, which hard-require xformers. That package is often
356
+ unavailable on macOS/MPS and unnecessary for lab encode workloads, so we
357
+ force both flags off via config_kwargs. Also repairs non-persistent RoPE /
358
+ position buffers corrupted by transformers≥5 meta-device loading.
359
+ """
360
+ from sentence_transformers import SentenceTransformer
361
+
362
+ kwargs: dict[str, Any] = {"device": device}
363
+ if selection.trust_remote_code:
364
+ kwargs["trust_remote_code"] = True
365
+ # Arctic Hub config: use_memory_efficient_attention=true (+ unpad_inputs)
366
+ # hard-requires xformers; without MEA, unpad+RoPE blows up on encode.
367
+ # Force both off for CPU/MPS/lab loads (HF discussion #15).
368
+ kwargs["config_kwargs"] = {
369
+ "use_memory_efficient_attention": False,
370
+ "unpad_inputs": False,
371
+ }
372
+ logger.info(
373
+ "Building SentenceTransformer hub_id=%s trust_remote_code=%s device=%s",
374
+ selection.hub_id,
375
+ selection.trust_remote_code,
376
+ device,
377
+ )
378
+ model = SentenceTransformer(selection.hub_id, **kwargs)
379
+ if selection.trust_remote_code:
380
+ _repair_gte_nonpersistent_buffers(model)
381
+ return model
382
+
383
+
384
+ def _apply_e5_prefixes(texts: Sequence[str]) -> list[str]:
385
+ """Symmetric lab tasks use query: for all strings (E5 STS guidance)."""
386
+ out: list[str] = []
387
+ for text in texts:
388
+ t = text.strip()
389
+ if t.startswith(("query:", "passage:")):
390
+ out.append(t)
391
+ else:
392
+ out.append(f"query: {t}")
393
+ return out
394
+
395
+
396
+ def _l2_normalize(matrix: np.ndarray) -> np.ndarray:
397
+ if matrix.ndim == 1:
398
+ norm = float(np.linalg.norm(matrix))
399
+ if norm == 0:
400
+ norm = 1e-9
401
+ return (matrix / norm).astype(np.float32, copy=False)
402
+ norms = np.linalg.norm(matrix, axis=1, keepdims=True)
403
+ norms[norms == 0] = 1e-9
404
+ return (matrix / norms).astype(np.float32, copy=False)
405
+
406
+
407
+ def encode_texts(
408
+ model: Any,
409
+ texts: str | Sequence[str],
410
+ selection: ModelSelection,
411
+ *,
412
+ show_progress_bar: bool = False,
413
+ ) -> np.ndarray:
414
+ """
415
+ Encode via the catalog adapter: optional E5 prefixes, truncate_dim, L2 normalize.
416
+
417
+ Single string → 1-D float32 vector; sequence → 2-D (N, D).
418
+ """
419
+ single = isinstance(texts, str)
420
+ batch: list[str] = [texts] if single else [str(t) for t in texts]
421
+ if selection.e5_mode:
422
+ batch = _apply_e5_prefixes(batch)
423
+
424
+ raw = model.encode(
425
+ batch, show_progress_bar=show_progress_bar, convert_to_numpy=True
426
+ )
427
+ arr = np.asarray(raw, dtype=np.float32)
428
+ if arr.ndim == 1:
429
+ arr = arr.reshape(1, -1)
430
+
431
+ if selection.truncate_dim is not None:
432
+ dim = selection.truncate_dim
433
+ if dim > arr.shape[1]:
434
+ raise ValueError(
435
+ f"truncate_dim={dim} exceeds model embedding width {arr.shape[1]}"
436
+ )
437
+ arr = arr[:, :dim]
438
+
439
+ arr = _l2_normalize(arr)
440
+ if single:
441
+ return arr[0]
442
+ return arr
backend/model_swap.py ADDED
@@ -0,0 +1,552 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Embedding model swap helpers for setup.sh option 11.
3
+
4
+ Stage .env → precompute vocab NPZ → commit on success; restore backup on failure.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from backend.model_catalog import (
18
+ ModelSelection,
19
+ get_model,
20
+ list_models,
21
+ list_profiles,
22
+ resolve_profile,
23
+ resolve_selection,
24
+ )
25
+ from backend.progress_cli import print_fail, print_ok, print_phase
26
+ from backend.vocab_merge import merge_en_es_files
27
+
28
+ _ENV_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
29
+
30
+ # ANSI (safe for most terminals used with setup.sh)
31
+ _BOLD = "\033[1m"
32
+ _DIM = "\033[2m"
33
+ _CYAN = "\033[36m"
34
+ _MAGENTA = "\033[35m"
35
+ _GREEN = "\033[32m"
36
+ _YELLOW = "\033[33m"
37
+ _RESET = "\033[0m"
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class EnvSnapshot:
42
+ path: Path
43
+ backup_path: Path | None
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class MenuEntry:
48
+ """One selectable row in option 11."""
49
+
50
+ index: int
51
+ token: str # P1 / M3 / etc.
52
+ kind: str # profile | model
53
+ selection: ModelSelection
54
+ title: str
55
+ subtitle: str
56
+
57
+
58
+ def repo_root() -> Path:
59
+ return Path(__file__).resolve().parent.parent
60
+
61
+
62
+ def read_dotenv(path: Path) -> dict[str, str]:
63
+ values: dict[str, str] = {}
64
+ if not path.is_file():
65
+ return values
66
+ for line in path.read_text(encoding="utf-8").splitlines():
67
+ stripped = line.strip()
68
+ if not stripped or stripped.startswith("#"):
69
+ continue
70
+ match = _ENV_KEY_RE.match(stripped)
71
+ if not match:
72
+ continue
73
+ key, raw = match.group(1), match.group(2)
74
+ if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ("'", '"'):
75
+ raw = raw[1:-1]
76
+ values[key] = raw
77
+ return values
78
+
79
+
80
+ def apply_dotenv(path: Path, *, override: bool = False) -> dict[str, str]:
81
+ """
82
+ Load KEY=VAL from a .env file into os.environ.
83
+
84
+ By default does not override variables already set in the process environment
85
+ (explicit exports / systemd / CI win). Used by the backend so option 1 and
86
+ option 11 honor the repo-root `.env` even when `uv run` cwd is `backend/`.
87
+ """
88
+ values = read_dotenv(path)
89
+ for key, value in values.items():
90
+ if override or key not in os.environ:
91
+ os.environ[key] = value
92
+ return values
93
+
94
+
95
+ def upsert_dotenv(path: Path, updates: dict[str, str | None]) -> None:
96
+ """
97
+ Upsert keys in a .env file. Value None removes the key line.
98
+ Preserves unrelated lines/comments; appends missing keys at end.
99
+ """
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ lines: list[str] = []
102
+ if path.is_file():
103
+ lines = path.read_text(encoding="utf-8").splitlines()
104
+
105
+ seen: set[str] = set()
106
+ out: list[str] = []
107
+ for line in lines:
108
+ stripped = line.strip()
109
+ match = (
110
+ _ENV_KEY_RE.match(stripped)
111
+ if stripped and not stripped.startswith("#")
112
+ else None
113
+ )
114
+ if match is None:
115
+ out.append(line)
116
+ continue
117
+ key = match.group(1)
118
+ if key not in updates:
119
+ out.append(line)
120
+ continue
121
+ seen.add(key)
122
+ value = updates[key]
123
+ if value is None:
124
+ continue
125
+ out.append(f"{key}={value}")
126
+
127
+ for key, value in updates.items():
128
+ if key in seen or value is None:
129
+ continue
130
+ out.append(f"{key}={value}")
131
+
132
+ text = "\n".join(out)
133
+ if text and not text.endswith("\n"):
134
+ text += "\n"
135
+ path.write_text(text, encoding="utf-8")
136
+
137
+
138
+ def stage_env(path: Path, updates: dict[str, str | None]) -> EnvSnapshot:
139
+ backup = path.with_suffix(path.suffix + ".multillm.bak")
140
+ if path.is_file():
141
+ shutil.copy2(path, backup)
142
+ else:
143
+ backup.write_text("", encoding="utf-8")
144
+ path.write_text("", encoding="utf-8")
145
+ upsert_dotenv(path, updates)
146
+ return EnvSnapshot(path=path, backup_path=backup)
147
+
148
+
149
+ def commit_env(snapshot: EnvSnapshot) -> None:
150
+ if snapshot.backup_path and snapshot.backup_path.is_file():
151
+ snapshot.backup_path.unlink()
152
+
153
+
154
+ def rollback_env(snapshot: EnvSnapshot) -> None:
155
+ if snapshot.backup_path is None:
156
+ return
157
+ if snapshot.backup_path.is_file():
158
+ shutil.copy2(snapshot.backup_path, snapshot.path)
159
+ snapshot.backup_path.unlink()
160
+
161
+
162
+ def resolve_choice(
163
+ *, profile: str | None = None, model: str | None = None
164
+ ) -> ModelSelection:
165
+ if profile:
166
+ return resolve_profile(profile)
167
+ if model:
168
+ try:
169
+ return get_model(model)
170
+ except ValueError:
171
+ return resolve_selection(model_name=model)
172
+ raise ValueError("Provide --profile or --model")
173
+
174
+
175
+ def ensure_en_es_vocab(root: Path | None = None) -> Path:
176
+ root = root or repo_root()
177
+ out = root / "public" / "vocab_en_es.txt"
178
+ en = root / "public" / "vocab.txt"
179
+ es = root / "public" / "vocab_es.txt"
180
+ if not out.is_file():
181
+ merge_en_es_files(en, es, out)
182
+ return out
183
+
184
+
185
+ def selection_env_updates(
186
+ selection: ModelSelection, *, vocab_path: str
187
+ ) -> dict[str, str | None]:
188
+ truncate = str(selection.truncate_dim) if selection.truncate_dim is not None else ""
189
+ return {
190
+ "MODEL_PROFILE": selection.profile or "",
191
+ "MODEL_NAME": selection.hub_id,
192
+ "TRUNCATE_DIM": truncate,
193
+ "VOCAB_PATH": vocab_path,
194
+ }
195
+
196
+
197
+ def run_precompute(
198
+ selection: ModelSelection,
199
+ *,
200
+ root: Path | None = None,
201
+ vocab_path: Path,
202
+ out_path: Path,
203
+ device: str = "AUTO",
204
+ ) -> None:
205
+ """Run precompute with live stdout/stderr (tqdm + phase logs visible)."""
206
+ root = root or repo_root()
207
+ cmd = [
208
+ "uv",
209
+ "run",
210
+ "python",
211
+ str(root / "scripts" / "precompute_vocab_embeddings.py"),
212
+ "--model",
213
+ selection.hub_id,
214
+ "--vocab",
215
+ str(vocab_path),
216
+ "--out",
217
+ str(out_path),
218
+ "--device",
219
+ device,
220
+ ]
221
+ if selection.profile:
222
+ cmd.extend(["--profile", selection.profile])
223
+ if selection.truncate_dim is not None:
224
+ cmd.extend(["--truncate-dim", str(selection.truncate_dim)])
225
+ env = os.environ.copy()
226
+ env.setdefault("PYTHONUNBUFFERED", "1")
227
+ completed = subprocess.run(
228
+ cmd,
229
+ cwd=str(root),
230
+ env=env,
231
+ check=False,
232
+ )
233
+ if completed.returncode != 0:
234
+ raise RuntimeError(f"precompute failed (exit {completed.returncode})")
235
+
236
+
237
+ def apply_model_swap(
238
+ selection: ModelSelection,
239
+ *,
240
+ root: Path | None = None,
241
+ env_path: Path | None = None,
242
+ device: str = "AUTO",
243
+ skip_precompute: bool = False,
244
+ quiet: bool = False,
245
+ ) -> ModelSelection:
246
+ """
247
+ Stage .env, ensure EN∪ES vocab, precompute NPZ, commit env.
248
+ Rolls back .env on any failure after staging.
249
+ """
250
+ root = root or repo_root()
251
+ env_path = env_path or (root / ".env")
252
+ total = 4 if not skip_precompute else 3
253
+ step = 0
254
+
255
+ def _phase(title: str, detail: str = "") -> None:
256
+ nonlocal step
257
+ step += 1
258
+ if not quiet:
259
+ print_phase(step, total, title, detail=detail)
260
+
261
+ _phase(
262
+ "Ensure EN∪ES vocabulary",
263
+ detail="public/vocab_en_es.txt (merge if missing)",
264
+ )
265
+ vocab_en_es = ensure_en_es_vocab(root)
266
+ if not quiet:
267
+ n_words = sum(1 for line in vocab_en_es.open(encoding="utf-8") if line.strip())
268
+ print_ok(f"Vocab ready — {n_words} words at {vocab_en_es.name}")
269
+
270
+ vocab_rel = "public/vocab_en_es.txt"
271
+ npz_rel = read_dotenv(env_path).get(
272
+ "VOCAB_EMBEDDINGS_PATH", "public/vocab_embeddings.npz"
273
+ )
274
+ updates = selection_env_updates(selection, vocab_path=vocab_rel)
275
+
276
+ _phase(
277
+ "Stage .env (backup → write)",
278
+ detail=f"MODEL_NAME={selection.hub_id}",
279
+ )
280
+ snapshot = stage_env(env_path, updates)
281
+ if not quiet:
282
+ print_ok(f"Staged {env_path.name} (rollback available on failure)")
283
+
284
+ try:
285
+ if not skip_precompute:
286
+ _phase(
287
+ "Precompute vocab embeddings NPZ",
288
+ detail="download/load model + encode (progress bar below)",
289
+ )
290
+ run_precompute(
291
+ selection,
292
+ root=root,
293
+ vocab_path=vocab_en_es,
294
+ out_path=root / npz_rel,
295
+ device=device,
296
+ )
297
+ if not quiet:
298
+ print_ok(f"NPZ written → {npz_rel}")
299
+
300
+ _phase("Commit .env (drop backup)")
301
+ commit_env(snapshot)
302
+ if not quiet:
303
+ print_ok("Environment committed")
304
+ print()
305
+ trunc = (
306
+ selection.truncate_dim
307
+ if selection.truncate_dim is not None
308
+ else "—"
309
+ )
310
+ print(
311
+ f" {_GREEN}{_BOLD}Swap prepare OK{_RESET} "
312
+ f"model={selection.short_label} "
313
+ f"profile={selection.profile or '—'} "
314
+ f"truncate={trunc}"
315
+ )
316
+ except Exception:
317
+ if not quiet:
318
+ print_fail("Failure — restoring previous .env")
319
+ rollback_env(snapshot)
320
+ raise
321
+ return selection
322
+
323
+
324
+ def _entry_subtitle(sel: ModelSelection, *, kind: str) -> str:
325
+ bits: list[str] = []
326
+ if kind == "profile" and sel.profile:
327
+ bits.append(sel.profile)
328
+ hub_short = sel.hub_id.split("/")[-1]
329
+ bits.append(hub_short)
330
+ if sel.truncate_dim is not None:
331
+ bits.append(f"MRL→{sel.truncate_dim}D")
332
+ if sel.e5_mode:
333
+ bits.append("E5")
334
+ if sel.gated:
335
+ bits.append("gated")
336
+ if sel.trust_remote_code:
337
+ bits.append("trust_remote")
338
+ return " · ".join(bits)
339
+
340
+
341
+ def build_menu_entries() -> list[MenuEntry]:
342
+ entries: list[MenuEntry] = []
343
+ idx = 1
344
+ for i, p in enumerate(list_profiles(), start=1):
345
+ sel = resolve_profile(p.id)
346
+ entries.append(
347
+ MenuEntry(
348
+ index=idx,
349
+ token=f"P{i}",
350
+ kind="profile",
351
+ selection=sel,
352
+ title=sel.short_label,
353
+ subtitle=_entry_subtitle(sel, kind="profile"),
354
+ )
355
+ )
356
+ idx += 1
357
+ for i, m in enumerate(list_models(), start=1):
358
+ entries.append(
359
+ MenuEntry(
360
+ index=idx,
361
+ token=f"M{i}",
362
+ kind="model",
363
+ selection=m,
364
+ title=m.short_label,
365
+ subtitle=_entry_subtitle(m, kind="model"),
366
+ )
367
+ )
368
+ idx += 1
369
+ return entries
370
+
371
+
372
+ def format_menu_lines(*, color: bool = True) -> list[str]:
373
+ """Pretty catalog listing for option 11 (profiles then models)."""
374
+ b = _BOLD if color else ""
375
+ d = _DIM if color else ""
376
+ c = _CYAN if color else ""
377
+ m = _MAGENTA if color else ""
378
+ g = _GREEN if color else ""
379
+ y = _YELLOW if color else ""
380
+ r = _RESET if color else ""
381
+
382
+ entries = build_menu_entries()
383
+ lines: list[str] = []
384
+ lines.append(f"{c}{b}╭────────────────────────────────────────────────────────────╮{r}")
385
+ lines.append(f"{c}{b}│{r} {b}Embedding profiles{r} {d}(recommended presets){r}")
386
+ lines.append(f"{c}{b}╰────────────────────────────────────────────────────────────╯{r}")
387
+
388
+ for e in entries:
389
+ if e.kind != "profile":
390
+ continue
391
+ mark = f"{g}★{r}" if color else "*"
392
+ lines.append(
393
+ f" {b}{e.index:>2}){r} {mark} {m}{e.token:<4}{r} "
394
+ f"{b}{e.title:<22}{r} {d}{e.subtitle}{r}"
395
+ )
396
+
397
+ lines.append("")
398
+ lines.append(f"{c}{b}╭────────────────────────────────────────────────────────────╮{r}")
399
+ lines.append(f"{c}{b}│{r} {b}Catalog models{r} {d}(pick any one){r}")
400
+ lines.append(f"{c}{b}╰────────────────────────────────────────────────────────────╯{r}")
401
+
402
+ for e in entries:
403
+ if e.kind != "model":
404
+ continue
405
+ lines.append(
406
+ f" {b}{e.index:>2}){r} {y}{e.token:<4}{r} "
407
+ f"{b}{e.title:<22}{r} {d}{e.subtitle}{r}"
408
+ )
409
+
410
+ lines.append("")
411
+ lines.append(f" {b} 0){r} {d}Cancel{r}")
412
+ lines.append(
413
+ f" {d}Enter a number (1–{entries[-1].index}), or P# / M# token.{r}"
414
+ )
415
+ return lines
416
+
417
+
418
+ def selection_from_menu_token(token: str) -> ModelSelection:
419
+ raw = token.strip()
420
+ if not raw:
421
+ raise ValueError("empty choice")
422
+
423
+ if raw.isdigit():
424
+ n = int(raw)
425
+ for e in build_menu_entries():
426
+ if e.index == n:
427
+ return e.selection
428
+ raise ValueError(f"unknown menu index: {n}")
429
+
430
+ t = raw.upper()
431
+ profiles = list(list_profiles())
432
+ models = list(list_models())
433
+ if t.startswith("P") and t[1:].isdigit():
434
+ idx = int(t[1:]) - 1
435
+ if 0 <= idx < len(profiles):
436
+ return resolve_profile(profiles[idx].id)
437
+ if t.startswith("M") and t[1:].isdigit():
438
+ idx = int(t[1:]) - 1
439
+ if 0 <= idx < len(models):
440
+ return models[idx]
441
+ lower = raw
442
+ try:
443
+ return resolve_profile(lower)
444
+ except ValueError:
445
+ pass
446
+ return resolve_choice(model=lower)
447
+
448
+
449
+ def describe_selection(selection: ModelSelection) -> str:
450
+ trunc = (
451
+ str(selection.truncate_dim) if selection.truncate_dim is not None else "—"
452
+ )
453
+ return (
454
+ f"{selection.short_label} · profile={selection.profile or '—'} "
455
+ f"· truncate={trunc} · {selection.hub_id}"
456
+ )
457
+
458
+
459
+ def main(argv: list[str] | None = None) -> int:
460
+ import argparse
461
+
462
+ parser = argparse.ArgumentParser(description=__doc__)
463
+ sub = parser.add_subparsers(dest="cmd", required=True)
464
+
465
+ list_p = sub.add_parser("list", help="Print profiles + catalog short labels")
466
+ list_p.add_argument(
467
+ "--plain",
468
+ action="store_true",
469
+ help="Disable ANSI colors",
470
+ )
471
+
472
+ apply_p = sub.add_parser(
473
+ "apply", help="Stage env, precompute, commit (or rollback)"
474
+ )
475
+ apply_p.add_argument("--profile", default=None)
476
+ apply_p.add_argument("--model", default=None)
477
+ apply_p.add_argument("--choice", default=None, help="Menu token e.g. P1 / M3 / 2")
478
+ apply_p.add_argument("--device", default=os.getenv("SAE_DEVICE", "AUTO"))
479
+ apply_p.add_argument(
480
+ "--skip-precompute",
481
+ action="store_true",
482
+ help="Only upsert .env (tests / dry wiring)",
483
+ )
484
+ apply_p.add_argument("--env", type=Path, default=None)
485
+ apply_p.add_argument(
486
+ "--quiet",
487
+ action="store_true",
488
+ help="Suppress phase progress (tests)",
489
+ )
490
+
491
+ describe_p = sub.add_parser(
492
+ "describe", help="Resolve a choice and print a one-line summary"
493
+ )
494
+ describe_p.add_argument("--choice", required=True)
495
+
496
+ args = parser.parse_args(argv)
497
+ root = repo_root()
498
+
499
+ if args.cmd == "list":
500
+ print("\n".join(format_menu_lines(color=not args.plain)))
501
+ return 0
502
+
503
+ if args.cmd == "describe":
504
+ try:
505
+ selection = selection_from_menu_token(args.choice)
506
+ except Exception as exc: # noqa: BLE001
507
+ print(f"ERROR: {exc}", file=sys.stderr)
508
+ return 1
509
+ print(describe_selection(selection))
510
+ return 0
511
+
512
+ if args.cmd == "apply":
513
+ if args.choice:
514
+ selection = selection_from_menu_token(args.choice)
515
+ else:
516
+ selection = resolve_choice(profile=args.profile, model=args.model)
517
+ env_path = args.env or (root / ".env")
518
+ if not args.quiet:
519
+ print()
520
+ print(f" {_BOLD}Target{_RESET}: {describe_selection(selection)}")
521
+ print(
522
+ f" {_DIM}Pipeline: vocab → stage .env → encode NPZ → commit{_RESET}"
523
+ )
524
+ print()
525
+ try:
526
+ apply_model_swap(
527
+ selection,
528
+ root=root,
529
+ env_path=env_path,
530
+ device=args.device,
531
+ skip_precompute=args.skip_precompute,
532
+ quiet=args.quiet,
533
+ )
534
+ except Exception as exc: # noqa: BLE001
535
+ print(f"ERROR: {exc}", file=sys.stderr)
536
+ if "gated" in selection.hub_id.lower() or selection.gated:
537
+ print(
538
+ "Gated model tip: run `hf auth login` and accept the model license on Hugging Face.",
539
+ file=sys.stderr,
540
+ )
541
+ return 1
542
+ print(
543
+ f"OK model={selection.hub_id} profile={selection.profile!r} "
544
+ f"truncate_dim={selection.truncate_dim!r}"
545
+ )
546
+ return 0
547
+
548
+ return 2
549
+
550
+
551
+ if __name__ == "__main__":
552
+ raise SystemExit(main())
backend/perform_tests.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ System Heartbeat and Diagnostic Runner for VHectorLab 3D.
4
+ Tests /health and /arithmetic endpoints locally using httpx or direct state.
5
+ """
6
+
7
+ import logging
8
+ import sys
9
+
10
+ import httpx
11
+
12
+ logging.basicConfig(level=logging.INFO)
13
+ logger = logging.getLogger("heartbeat")
14
+
15
+
16
+ def run_heartbeat(base_url: str = "http://127.0.0.1:8000") -> bool:
17
+ logger.info(f"Running System Heartbeat against {base_url}...")
18
+
19
+ try:
20
+ # 1. Health check
21
+ health_resp = httpx.get(f"{base_url}/health", timeout=5.0)
22
+ if health_resp.status_code != 200:
23
+ logger.error(
24
+ f"Health check failed with status {health_resp.status_code}: {health_resp.text}"
25
+ )
26
+ return False
27
+
28
+ health_data = health_resp.json()
29
+ logger.info(f"Health check OK: {health_data}")
30
+
31
+ # 2. Arithmetic check (king - man + woman)
32
+ payload = {"word_a": "king", "word_b": "man", "word_c": "woman", "top_k": 5}
33
+ arith_resp = httpx.post(f"{base_url}/arithmetic", json=payload, timeout=15.0)
34
+ if arith_resp.status_code != 200:
35
+ logger.error(
36
+ f"Arithmetic test failed with status {arith_resp.status_code}: {arith_resp.text}"
37
+ )
38
+ return False
39
+
40
+ arith_data = arith_resp.json()
41
+ results = arith_data.get("results", [])
42
+ logger.info("Arithmetic test OK! Top results for 'king - man + woman':")
43
+ for res in results:
44
+ logger.info(f" - {res['word']} (score: {res['score']:.4f})")
45
+
46
+ return True
47
+ except Exception as e: # noqa: BLE001
48
+ logger.error(f"Heartbeat connection error: {e}")
49
+ return False
50
+
51
+
52
+ if __name__ == "__main__":
53
+ url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8000"
54
+ success = run_heartbeat(url)
55
+ sys.exit(0 if success else 1)
backend/progress_cli.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Terminal progress helpers for operator-facing CLIs (option 11, precompute)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def progress_bar(current: int, total: int, *, width: int = 28) -> str:
9
+ total = max(int(total), 1)
10
+ current = max(0, min(int(current), total))
11
+ filled = round(width * current / total)
12
+ filled = max(0, min(filled, width))
13
+ return "█" * filled + "░" * (width - filled)
14
+
15
+
16
+ def print_phase(
17
+ current: int,
18
+ total: int,
19
+ title: str,
20
+ *,
21
+ detail: str = "",
22
+ file=sys.stdout,
23
+ ) -> None:
24
+ bar = progress_bar(current, total)
25
+ line = f" [{bar}] {current}/{total} {title}"
26
+ print(line, file=file, flush=True)
27
+ if detail:
28
+ print(f" {detail}", file=file, flush=True)
29
+
30
+
31
+ def print_ok(message: str, *, file=sys.stdout) -> None:
32
+ print(f" ✓ {message}", file=file, flush=True)
33
+
34
+
35
+ def print_fail(message: str, *, file=sys.stderr) -> None:
36
+ print(f" ✗ {message}", file=file, flush=True)
backend/projection.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dimensionality reduction for Galaxy VIEW.
3
+
4
+ Deep seam: project_embeddings(vectors, ...) → positions + meta.
5
+ UMAP only in v1; PCA/t-SNE raise ProjectError(501).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ PRE_PCA_DIMS = 50
15
+ MAX_VECTORS = 1024
16
+ DEFAULT_N_NEIGHBORS = 15
17
+ DEFAULT_MIN_DIST = 0.1
18
+ DEFAULT_METRIC = "cosine"
19
+
20
+
21
+ class ProjectError(Exception):
22
+ """Validation / capability error with HTTP-ish status code."""
23
+
24
+ def __init__(self, message: str, status_code: int = 400) -> None:
25
+ super().__init__(message)
26
+ self.message = message
27
+ self.status_code = status_code
28
+
29
+
30
+ def _as_matrix(vectors: list[list[float]] | np.ndarray) -> np.ndarray:
31
+ if isinstance(vectors, np.ndarray):
32
+ mat = np.asarray(vectors, dtype=np.float64)
33
+ else:
34
+ if not vectors:
35
+ raise ProjectError("vectors list cannot be empty (need 1..1024)")
36
+ lengths = {len(row) for row in vectors}
37
+ if len(lengths) != 1:
38
+ raise ProjectError("all vectors must have the same dimension")
39
+ if next(iter(lengths)) < 1:
40
+ raise ProjectError("vector dimension must be >= 1")
41
+ mat = np.asarray(vectors, dtype=np.float64)
42
+
43
+ if mat.ndim != 2:
44
+ raise ProjectError("vectors must be a 2D matrix [n, dim]")
45
+ n, dim = mat.shape
46
+ if n < 1 or n > MAX_VECTORS:
47
+ raise ProjectError(f"len(vectors) must be in 1..{MAX_VECTORS}, got {n}")
48
+ if dim < 1:
49
+ raise ProjectError("vector dimension must be >= 1")
50
+ if not np.isfinite(mat).all():
51
+ raise ProjectError("vectors must contain only finite values")
52
+ return mat
53
+
54
+
55
+ def _normalize_positions(pos: np.ndarray) -> np.ndarray:
56
+ """Zero-mean; scale so RMS distance from origin is 1 (stable camera defaults)."""
57
+ centered = pos - pos.mean(axis=0, keepdims=True)
58
+ rms = float(np.sqrt(np.mean(np.sum(centered**2, axis=1))))
59
+ if rms < 1e-12:
60
+ return centered
61
+ return centered / rms
62
+
63
+
64
+ def _project_small_n(
65
+ mat: np.ndarray, *, n_components: int, seed: int
66
+ ) -> tuple[np.ndarray, dict[str, Any]]:
67
+ """
68
+ Deterministic layout when UMAP cannot run (n < 3).
69
+
70
+ n=1 → origin; n=2 → PCA to 1D padded to n_components (pair on an axis).
71
+ """
72
+ n_samples, dim = mat.shape
73
+ if n_samples == 1:
74
+ pos = np.zeros((1, n_components), dtype=np.float64)
75
+ return pos, {"fallback": "origin"}
76
+
77
+ from sklearn.decomposition import PCA
78
+
79
+ k = min(n_samples - 1, n_components, dim)
80
+ if k < 1:
81
+ pos = np.zeros((n_samples, n_components), dtype=np.float64)
82
+ return pos, {"fallback": "origin"}
83
+
84
+ reduced = PCA(n_components=k, random_state=seed).fit_transform(mat)
85
+ pos = np.zeros((n_samples, n_components), dtype=np.float64)
86
+ pos[:, :k] = reduced
87
+ return pos, {"fallback": "pca_micro", "pca_components": int(k)}
88
+
89
+
90
+ def _resolve_umap_params(
91
+ n_samples: int, params: dict[str, Any] | None
92
+ ) -> dict[str, Any]:
93
+ raw = params or {}
94
+ n_neighbors = int(raw.get("n_neighbors", DEFAULT_N_NEIGHBORS))
95
+ min_dist = float(raw.get("min_dist", DEFAULT_MIN_DIST))
96
+ metric = str(raw.get("metric", DEFAULT_METRIC))
97
+
98
+ # UMAP requires n_neighbors < n_samples
99
+ max_nn = max(2, n_samples - 1)
100
+ n_neighbors = max(n_neighbors, 2)
101
+ n_neighbors = min(n_neighbors, max_nn)
102
+
103
+ if min_dist < 0.0:
104
+ raise ProjectError("params.min_dist must be >= 0")
105
+
106
+ return {
107
+ "n_neighbors": n_neighbors,
108
+ "min_dist": min_dist,
109
+ "metric": metric,
110
+ }
111
+
112
+
113
+ def project_embeddings(
114
+ vectors: list[list[float]] | np.ndarray,
115
+ *,
116
+ method: str = "umap",
117
+ n_components: int = 3,
118
+ seed: int = 42,
119
+ params: dict[str, Any] | None = None,
120
+ ) -> dict[str, Any]:
121
+ """
122
+ Project embedding rows to 2D/3D.
123
+
124
+ Returns:
125
+ { method, n_components, positions, meta }
126
+ """
127
+ method_l = (method or "").strip().lower()
128
+ if method_l in ("pca", "tsne"):
129
+ raise ProjectError(
130
+ f"method '{method_l}' is not implemented yet; use 'umap'",
131
+ status_code=501,
132
+ )
133
+ if method_l != "umap":
134
+ raise ProjectError(
135
+ f"unsupported method '{method}'; v1 accepts 'umap' only "
136
+ "(pca/tsne coming later)",
137
+ status_code=400,
138
+ )
139
+
140
+ if n_components not in (2, 3):
141
+ raise ProjectError("n_components must be 2 or 3")
142
+
143
+ mat = _as_matrix(vectors)
144
+ n_samples, dim = mat.shape
145
+
146
+ # UMAP needs ≥3 samples; 1–2 tokens still valid in Token Comparison / Galaxy.
147
+ if n_samples < 3:
148
+ embedded, fallback_meta = _project_small_n(
149
+ mat, n_components=n_components, seed=seed
150
+ )
151
+ positions = _normalize_positions(np.asarray(embedded, dtype=np.float64))
152
+ return {
153
+ "method": "umap",
154
+ "n_components": n_components,
155
+ "positions": positions.tolist(),
156
+ "meta": {"seed": seed, **fallback_meta},
157
+ }
158
+
159
+ umap_params = _resolve_umap_params(n_samples, params)
160
+ pre_pca_dims: int | None = None
161
+ work = mat
162
+
163
+ if dim > PRE_PCA_DIMS:
164
+ from sklearn.decomposition import PCA
165
+
166
+ pca_dims = min(PRE_PCA_DIMS, n_samples - 1, dim)
167
+ if pca_dims < n_components:
168
+ raise ProjectError(
169
+ f"cannot pre-reduce: need at least {n_components} PCA dims "
170
+ f"(n={n_samples}, dim={dim})"
171
+ )
172
+ pca = PCA(n_components=pca_dims, random_state=seed)
173
+ work = pca.fit_transform(mat)
174
+ pre_pca_dims = pca_dims
175
+
176
+ import umap
177
+
178
+ reducer = umap.UMAP(
179
+ n_components=n_components,
180
+ n_neighbors=umap_params["n_neighbors"],
181
+ min_dist=umap_params["min_dist"],
182
+ metric=umap_params["metric"],
183
+ random_state=seed,
184
+ n_jobs=1,
185
+ )
186
+ embedded = reducer.fit_transform(work)
187
+ positions = _normalize_positions(np.asarray(embedded, dtype=np.float64))
188
+
189
+ meta: dict[str, Any] = {
190
+ "seed": seed,
191
+ "n_neighbors": umap_params["n_neighbors"],
192
+ "min_dist": umap_params["min_dist"],
193
+ "metric": umap_params["metric"],
194
+ }
195
+ if pre_pca_dims is not None:
196
+ meta["pre_pca_dims"] = pre_pca_dims
197
+
198
+ return {
199
+ "method": "umap",
200
+ "n_components": n_components,
201
+ "positions": positions.tolist(),
202
+ "meta": meta,
203
+ }
backend/pyproject.toml ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "vhectorlab-backend"
3
+ version = "2.3.0"
4
+ description = "VHectorLab 3D FastAPI Backend for Vector Arithmetic and Semantic Embeddings"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "fastapi>=0.110.0",
9
+ "uvicorn>=0.28.0",
10
+ "sentence-transformers>=2.5.0",
11
+ "torch>=2.2.0",
12
+ "numpy>=1.26.0",
13
+ "httpx>=0.27.0",
14
+ "huggingface-hub>=0.21.0",
15
+ "orjson>=3.11.9",
16
+ "umap-learn==0.5.12",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.0.0",
22
+ "httpx>=0.27.0",
23
+ "ruff>=0.3.0",
24
+ ]
25
+
26
+ # HF Space / Docker (Linux): CPU wheels only — PyPI Linux torch pulls multi-GB nvidia-*.
27
+ # macOS keeps default PyPI (MPS-capable). UV_TORCH_BACKEND alone does NOT affect `uv sync`.
28
+ [[tool.uv.index]]
29
+ name = "pytorch-cpu"
30
+ url = "https://download.pytorch.org/whl/cpu"
31
+ explicit = true
32
+
33
+ [tool.uv.sources]
34
+ torch = [
35
+ { index = "pytorch-cpu", marker = "sys_platform == 'linux'" },
36
+ ]
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["."]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ python_files = ["test_*.py"]
48
+ pythonpath = ["..", "."]
backend/routers/core.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Core API Router for VHectorLab 3D.
3
+ Provides /health, /embed, /tokenize, /arithmetic, /compare, and /project endpoints.
4
+ """
5
+
6
+ from typing import Any, Literal
7
+
8
+ from backend.projection import ProjectError, project_embeddings
9
+ from backend.state import state
10
+ from fastapi import APIRouter, HTTPException
11
+ from pydantic import BaseModel, Field
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ class EmbedRequest(BaseModel):
17
+ text: str = Field(..., description="Text to compute embedding for")
18
+
19
+
20
+ class TokenizeRequest(BaseModel):
21
+ text: str = Field(..., description="Text to tokenize")
22
+
23
+
24
+ class ArithmeticRequest(BaseModel):
25
+ word_a: str = Field(
26
+ ..., json_schema_extra={"example": "king"}, description="Positive base word (A)"
27
+ )
28
+ word_b: str = Field(
29
+ ..., json_schema_extra={"example": "man"}, description="Subtracted word (B)"
30
+ )
31
+ word_c: str = Field(
32
+ ..., json_schema_extra={"example": "woman"}, description="Added word (C)"
33
+ )
34
+ top_k: int = Field(
35
+ default=10, ge=1, le=100, description="Number of nearest neighbors to return"
36
+ )
37
+
38
+
39
+ class CompareRequest(BaseModel):
40
+ texts: list[str] = Field(
41
+ ...,
42
+ description="List of texts/tokens to compute batch embeddings for (1 to 1024 items)",
43
+ )
44
+
45
+
46
+ class ProjectParams(BaseModel):
47
+ n_neighbors: int = Field(default=15, ge=2, le=200)
48
+ min_dist: float = Field(default=0.1, ge=0.0)
49
+ metric: str = Field(default="cosine")
50
+
51
+
52
+ class ProjectRequest(BaseModel):
53
+ vectors: list[list[float]] = Field(
54
+ ...,
55
+ description="Precomputed embedding rows (1..1024); does not re-encode text",
56
+ )
57
+ method: str = Field(
58
+ default="umap",
59
+ description="Projection method; v1 accepts 'umap' only",
60
+ )
61
+ n_components: Literal[2, 3] = Field(
62
+ default=3,
63
+ description="Output dimensionality (Galaxy uses 3; 2 reserved for future 2D VIEW)",
64
+ )
65
+ seed: int = Field(default=42, description="RNG seed for reproducibility")
66
+ params: ProjectParams | None = Field(
67
+ default=None,
68
+ description="UMAP hyperparameters (n_neighbors, min_dist, metric)",
69
+ )
70
+
71
+
72
+ @router.get("/health")
73
+ def health_check() -> dict[str, Any]:
74
+ selection = getattr(state, "selection", None)
75
+ short_label = getattr(selection, "short_label", None) if selection else None
76
+ return {
77
+ "status": "ok" if state.is_loaded else "uninitialized",
78
+ "model": state.model_name,
79
+ "model_profile": getattr(state, "model_profile", None),
80
+ "short_label": short_label,
81
+ "embedding_dim": getattr(state, "embedding_dim", None),
82
+ "truncate_dim": getattr(state, "truncate_dim", None),
83
+ "vocab_size": len(state.vocab_words),
84
+ "is_loaded": state.is_loaded,
85
+ "device": getattr(state, "device", "cpu"),
86
+ }
87
+
88
+
89
+ @router.post("/embed")
90
+ def embed_text(req: EmbedRequest) -> dict[str, Any]:
91
+ if not state.is_loaded or state.model is None:
92
+ raise HTTPException(status_code=503, detail="Backend model is not loaded yet")
93
+ if not req.text.strip():
94
+ raise HTTPException(status_code=400, detail="Text cannot be empty")
95
+
96
+ vec = state.compute_embedding(req.text)
97
+ return {"text": req.text, "embedding": vec.tolist(), "dimension": len(vec)}
98
+
99
+
100
+ @router.post("/tokenize")
101
+ def tokenize_text(req: TokenizeRequest) -> dict[str, Any]:
102
+ if not state.is_loaded or state.model is None:
103
+ raise HTTPException(status_code=503, detail="Backend model is not loaded yet")
104
+
105
+ # SentenceTransformer uses underlying Hugging Face tokenizer
106
+ tokenizer = getattr(state.model, "tokenizer", None)
107
+ if tokenizer is not None:
108
+ tokens = tokenizer.tokenize(req.text)
109
+ input_ids = tokenizer.encode(req.text)
110
+ else:
111
+ # Fallback space tokenization
112
+ tokens = req.text.split()
113
+ input_ids = list(range(len(tokens)))
114
+
115
+ return {
116
+ "text": req.text,
117
+ "tokens": tokens,
118
+ "input_ids": input_ids,
119
+ "count": len(tokens),
120
+ }
121
+
122
+
123
+ @router.post("/arithmetic")
124
+ def perform_arithmetic(req: ArithmeticRequest) -> dict[str, Any]:
125
+ if not state.is_loaded or state.model is None:
126
+ raise HTTPException(status_code=503, detail="Backend model is not loaded yet")
127
+
128
+ if not (req.word_a.strip() and req.word_b.strip() and req.word_c.strip()):
129
+ raise HTTPException(
130
+ status_code=400, detail="Words A, B, and C must not be empty"
131
+ )
132
+
133
+ try:
134
+ res = state.perform_arithmetic(req.word_a, req.word_b, req.word_c, req.top_k)
135
+ return res
136
+ except Exception as e: # noqa: BLE001
137
+ raise HTTPException(status_code=500, detail=str(e))
138
+
139
+
140
+ @router.post("/compare")
141
+ def perform_compare(req: CompareRequest) -> dict[str, Any]:
142
+ if not state.is_loaded or state.model is None:
143
+ raise HTTPException(status_code=503, detail="Backend model is not loaded yet")
144
+
145
+ if not req.texts:
146
+ raise HTTPException(status_code=400, detail="Texts list cannot be empty")
147
+
148
+ try:
149
+ return state.perform_compare(req.texts)
150
+ except Exception as e: # noqa: BLE001
151
+ raise HTTPException(status_code=500, detail=str(e))
152
+
153
+
154
+ @router.post("/project")
155
+ def perform_project(req: ProjectRequest) -> dict[str, Any]:
156
+ """Project embedding vectors to 2D/3D (UMAP). Does not encode text."""
157
+ params = req.params.model_dump() if req.params is not None else None
158
+ try:
159
+ return project_embeddings(
160
+ req.vectors,
161
+ method=req.method,
162
+ n_components=req.n_components,
163
+ seed=req.seed,
164
+ params=params,
165
+ )
166
+ except ProjectError as e:
167
+ raise HTTPException(status_code=e.status_code, detail=e.message)
168
+ except Exception as e: # noqa: BLE001
169
+ raise HTTPException(status_code=500, detail=str(e))
170
+
backend/routers/sae.py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SAE API: status / train / encode / clear — persisted Compare-scope Top-K SAE."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+ import orjson
13
+ from fastapi import APIRouter, HTTPException
14
+ from pydantic import BaseModel, Field
15
+ from starlette.responses import JSONResponse
16
+
17
+ from backend.sae.sae_model import SAEManager
18
+ from backend.sae.suggest_dims import suggest_sae_dims, suggest_train_schedule
19
+ from backend.sae.train_sae import get_optimal_device, train_sae
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class OrjsonResponse(JSONResponse):
25
+ """FastJSON via orjson (avoid deprecated fastapi.responses.ORJSONResponse)."""
26
+
27
+ media_type = "application/json"
28
+
29
+ def render(self, content: Any) -> bytes:
30
+ return orjson.dumps(content)
31
+
32
+
33
+ router = APIRouter(default_response_class=OrjsonResponse)
34
+
35
+ _ARTIFACTS_DIR = Path(__file__).resolve().parent.parent / "artifacts"
36
+ SAE_WEIGHTS_PATH = str(_ARTIFACTS_DIR / "sae_weights.pt")
37
+
38
+
39
+ def _resolve_sae_device() -> str:
40
+ return get_optimal_device(os.getenv("SAE_DEVICE", "AUTO"))
41
+
42
+
43
+ sae_manager = SAEManager(
44
+ device=_resolve_sae_device(),
45
+ weights_path=SAE_WEIGHTS_PATH,
46
+ )
47
+
48
+
49
+ class SAEEncodeRequest(BaseModel):
50
+ embeddings: list[list[float]] = Field(
51
+ ..., description="Batch of raw embedding vectors [N, D]"
52
+ )
53
+
54
+
55
+ class SAETrainRequest(BaseModel):
56
+ embeddings: list[list[float]] = Field(
57
+ ...,
58
+ min_length=2,
59
+ description="Current workspace scope vectors (Compare items or Arithmetic components)",
60
+ )
61
+ hidden_dim: int = Field(default=8192, ge=32, le=32768)
62
+ k: int = Field(default=32, ge=1, le=4096)
63
+ epochs: int = Field(default=50, ge=1, le=500)
64
+ lr: float = Field(default=1e-3, gt=0, le=1.0)
65
+ batch_size: int = Field(default=64, ge=1, le=2048)
66
+ auto_scale: bool = Field(
67
+ default=True,
68
+ description="Scale hidden_dim/k down for small N (recommended)",
69
+ )
70
+
71
+
72
+ class SAETrainingStatus:
73
+ def __init__(self) -> None:
74
+ self.status = "idle" # idle | preparing | training | success | failed
75
+ self.phase = "idle"
76
+ self.phase_key = "idle" # idle | preparing | training | installing | ready | failed
77
+ self.current_epoch = 0
78
+ self.total_epochs = 0
79
+ self.remaining_epochs = 0
80
+ self.percent = 0.0
81
+ self.loss = 0.0
82
+ self.error_message: str | None = None
83
+ self.metrics: dict[str, Any] | None = None
84
+ self.message = ""
85
+ self.resolved_hidden: int | None = None
86
+ self.resolved_k: int | None = None
87
+ self.n_vectors: int | None = None
88
+
89
+
90
+ sae_training_status = SAETrainingStatus()
91
+
92
+
93
+ async def _train_sae_task(
94
+ vectors: np.ndarray,
95
+ hidden_dim: int,
96
+ k: int,
97
+ epochs: int,
98
+ lr: float,
99
+ batch_size: int,
100
+ ) -> None:
101
+ global sae_training_status
102
+ try:
103
+ n = int(vectors.shape[0])
104
+ sae_training_status.status = "training"
105
+ sae_training_status.phase_key = "preparing"
106
+ sae_training_status.phase = "Preparing scope matrix…"
107
+ sae_training_status.current_epoch = 0
108
+ sae_training_status.total_epochs = epochs
109
+ sae_training_status.remaining_epochs = epochs
110
+ sae_training_status.percent = 0.0
111
+ sae_training_status.loss = 0.0
112
+ sae_training_status.error_message = None
113
+ sae_training_status.metrics = None
114
+ sae_training_status.message = (
115
+ f"Preparing scope matrix… (n={n}, hidden={hidden_dim}, k={k}, epochs={epochs})"
116
+ )
117
+ sae_training_status.resolved_hidden = hidden_dim
118
+ sae_training_status.resolved_k = k
119
+ sae_training_status.n_vectors = n
120
+
121
+ # Drop previous in-RAM model (file replaced on install)
122
+ sae_manager.unload()
123
+
124
+ def progress_cb(
125
+ completed: float,
126
+ total_epochs: int,
127
+ loss: float,
128
+ detail: str = "",
129
+ ) -> None:
130
+ completed_f = float(completed)
131
+ total_epochs = int(total_epochs)
132
+ completed_i = int(completed_f) if completed_f < total_epochs else total_epochs
133
+ remaining = max(0, total_epochs - completed_i)
134
+ # Keep a fractional "still in this epoch" remaining feel
135
+ if completed_f < total_epochs and completed_f > completed_i:
136
+ remaining = max(0, total_epochs - completed_i - 1)
137
+ pct = (
138
+ round(100.0 * min(completed_f, float(total_epochs)) / total_epochs, 1)
139
+ if total_epochs
140
+ else 0.0
141
+ )
142
+ sae_training_status.current_epoch = completed_i
143
+ sae_training_status.total_epochs = total_epochs
144
+ sae_training_status.remaining_epochs = remaining
145
+ sae_training_status.percent = min(99.0, pct) if completed_f < total_epochs else pct
146
+ sae_training_status.loss = float(loss)
147
+ sae_training_status.phase_key = "training"
148
+ sae_training_status.status = "training"
149
+ if detail:
150
+ sae_training_status.phase = detail
151
+ elif completed_f < total_epochs:
152
+ running = min(total_epochs, completed_i + 1)
153
+ loss_bit = f" · loss={loss:.6f}" if loss else ""
154
+ sae_training_status.phase = (
155
+ f"Training epoch {running}/{total_epochs} — "
156
+ f"{remaining} remaining{loss_bit}"
157
+ )
158
+ else:
159
+ sae_training_status.phase = (
160
+ f"Epochs done {total_epochs}/{total_epochs} · loss={loss:.6f}"
161
+ )
162
+ sae_training_status.message = sae_training_status.phase
163
+
164
+ sae_training_status.phase_key = "training"
165
+ sae_training_status.phase = (
166
+ f"Training epoch 1/{epochs} — {epochs} remaining"
167
+ )
168
+ sae_training_status.message = sae_training_status.phase
169
+ metrics, checkpoint = await asyncio.to_thread(
170
+ train_sae,
171
+ vectors=vectors,
172
+ hidden_dim=hidden_dim,
173
+ k=k,
174
+ epochs=epochs,
175
+ lr=lr,
176
+ batch_size=batch_size,
177
+ device_name=os.getenv("SAE_DEVICE", "AUTO"),
178
+ output_path=None,
179
+ progress_cb=progress_cb,
180
+ )
181
+
182
+ sae_training_status.phase_key = "installing"
183
+ sae_training_status.current_epoch = epochs
184
+ sae_training_status.remaining_epochs = 0
185
+ sae_training_status.percent = 99.0
186
+ sae_training_status.phase = "Saving checkpoint…"
187
+ sae_training_status.message = (
188
+ f"Saving checkpoint… ({hidden_dim}D · k={k} · n={n})"
189
+ )
190
+ sae_manager.device = _resolve_sae_device()
191
+ sae_manager.install_checkpoint(checkpoint, persist=True)
192
+
193
+ sae_training_status.metrics = metrics
194
+ sae_training_status.status = "success"
195
+ sae_training_status.phase_key = "ready"
196
+ sae_training_status.percent = 100.0
197
+ sae_training_status.remaining_epochs = 0
198
+ sae_training_status.phase = "Ready"
199
+ sae_training_status.message = (
200
+ f"Ready — saved SAE {hidden_dim}D · k={k} · n={n}"
201
+ )
202
+ logger.info("SAE training completed (checkpoint saved).")
203
+
204
+ except Exception as e:
205
+ logger.exception("SAE training failed")
206
+ sae_manager.clear(delete_file=False)
207
+ sae_training_status.status = "failed"
208
+ sae_training_status.phase_key = "failed"
209
+ sae_training_status.phase = "failed"
210
+ sae_training_status.error_message = str(e)
211
+ sae_training_status.message = str(e)
212
+
213
+
214
+ @router.get("/sae/status")
215
+ async def sae_status() -> dict[str, Any]:
216
+ is_trained = sae_manager.is_trained()
217
+ status_info: dict[str, Any] = {
218
+ "is_trained": is_trained,
219
+ "ephemeral": False,
220
+ "persisted": sae_manager.has_checkpoint_file(),
221
+ "weights_path": SAE_WEIGHTS_PATH,
222
+ "config": None,
223
+ "metrics": None,
224
+ "training": {
225
+ "status": sae_training_status.status,
226
+ "phase": sae_training_status.phase,
227
+ "phase_key": sae_training_status.phase_key,
228
+ "message": sae_training_status.message,
229
+ "current_epoch": sae_training_status.current_epoch,
230
+ "total_epochs": sae_training_status.total_epochs,
231
+ "remaining_epochs": sae_training_status.remaining_epochs,
232
+ "percent": sae_training_status.percent,
233
+ "loss": sae_training_status.loss,
234
+ "error_message": sae_training_status.error_message,
235
+ "metrics": sae_training_status.metrics,
236
+ "resolved_hidden": sae_training_status.resolved_hidden,
237
+ "resolved_k": sae_training_status.resolved_k,
238
+ "n_vectors": sae_training_status.n_vectors,
239
+ },
240
+ }
241
+ if is_trained:
242
+ if sae_manager.load_model():
243
+ status_info["config"] = sae_manager.config
244
+ status_info["metrics"] = sae_manager.metrics
245
+ return status_info
246
+
247
+
248
+ @router.post("/sae/clear")
249
+ async def sae_clear() -> dict[str, Any]:
250
+ """Delete the saved SAE checkpoint and drop RAM weights (Retrain confirm)."""
251
+ if sae_training_status.status == "training":
252
+ raise HTTPException(
253
+ status_code=400,
254
+ detail="Cannot clear SAE while a training job is in progress.",
255
+ )
256
+ sae_manager.clear(delete_file=True)
257
+ sae_training_status.status = "idle"
258
+ sae_training_status.phase = "idle"
259
+ sae_training_status.phase_key = "idle"
260
+ sae_training_status.message = ""
261
+ sae_training_status.metrics = None
262
+ sae_training_status.error_message = None
263
+ sae_training_status.resolved_hidden = None
264
+ sae_training_status.resolved_k = None
265
+ sae_training_status.n_vectors = None
266
+ sae_training_status.current_epoch = 0
267
+ sae_training_status.total_epochs = 0
268
+ sae_training_status.remaining_epochs = 0
269
+ sae_training_status.percent = 0.0
270
+ return {"message": "SAE checkpoint deleted.", "is_trained": False}
271
+
272
+
273
+ @router.post("/sae/train")
274
+ async def sae_train(request: SAETrainRequest) -> dict[str, Any]:
275
+ if sae_training_status.status == "training":
276
+ raise HTTPException(
277
+ status_code=400, detail="A training job is already in progress."
278
+ )
279
+
280
+ vectors = np.asarray(request.embeddings, dtype=np.float32)
281
+ if vectors.ndim != 2:
282
+ raise HTTPException(
283
+ status_code=400, detail="embeddings must be a 2D array [N, D]"
284
+ )
285
+ if vectors.shape[0] < 2:
286
+ raise HTTPException(
287
+ status_code=400, detail="Need at least 2 embedding vectors to train SAE"
288
+ )
289
+
290
+ input_dim = int(vectors.shape[1])
291
+ n_vectors = int(vectors.shape[0])
292
+ if request.auto_scale:
293
+ hidden_dim, k = suggest_sae_dims(
294
+ n_vectors=n_vectors,
295
+ input_dim=input_dim,
296
+ requested_hidden=request.hidden_dim,
297
+ requested_k=request.k,
298
+ )
299
+ epochs, batch_size = suggest_train_schedule(
300
+ n_vectors=n_vectors,
301
+ hidden_dim=hidden_dim,
302
+ requested_epochs=request.epochs,
303
+ requested_batch_size=request.batch_size,
304
+ )
305
+ else:
306
+ hidden_dim, k = request.hidden_dim, request.k
307
+ epochs, batch_size = request.epochs, request.batch_size
308
+
309
+ if k > hidden_dim:
310
+ raise HTTPException(status_code=400, detail="k must be <= hidden_dim")
311
+
312
+ asyncio.create_task(
313
+ _train_sae_task(
314
+ vectors=vectors,
315
+ hidden_dim=hidden_dim,
316
+ k=k,
317
+ epochs=epochs,
318
+ lr=request.lr,
319
+ batch_size=batch_size,
320
+ )
321
+ )
322
+ return {
323
+ "message": "Training started.",
324
+ "status": "training",
325
+ "resolved_hidden": hidden_dim,
326
+ "resolved_k": k,
327
+ "resolved_epochs": epochs,
328
+ "resolved_batch_size": batch_size,
329
+ "n_vectors": n_vectors,
330
+ "device": _resolve_sae_device(),
331
+ "auto_scaled": bool(
332
+ request.auto_scale
333
+ and (
334
+ hidden_dim != request.hidden_dim
335
+ or k != request.k
336
+ or epochs != request.epochs
337
+ or batch_size != request.batch_size
338
+ )
339
+ ),
340
+ }
341
+
342
+
343
+ @router.post("/sae/encode")
344
+ async def sae_encode(request: SAEEncodeRequest) -> dict[str, Any]:
345
+ """
346
+ Encode embeddings → Top-K sparse activations (indices + values).
347
+
348
+ Does NOT return a dense [N, hidden_dim] matrix (mostly zeros). Clients densify
349
+ locally when needed. Model stays resident in RAM after first load_model().
350
+ """
351
+ if not sae_manager.is_trained():
352
+ raise HTTPException(
353
+ status_code=404,
354
+ detail="No trained SAE model available. Please train one first.",
355
+ )
356
+
357
+ try:
358
+ if not request.embeddings:
359
+ # Ensure config is in RAM if only the checkpoint file exists
360
+ sae_manager.load_model()
361
+ hidden = int(sae_manager.config.get("hidden_dim", 8192))
362
+ k = int(sae_manager.config.get("k", 32))
363
+ return {
364
+ "format": "topk_sparse",
365
+ "indices": [],
366
+ "values": [],
367
+ "dimension": hidden,
368
+ "k": k,
369
+ "count": 0,
370
+ "batch_metrics": {
371
+ "l0": 0.0,
372
+ "sparsity": 0.0,
373
+ "active_features": 0,
374
+ },
375
+ }
376
+
377
+ vectors = np.asarray(request.embeddings, dtype=np.float32)
378
+ if vectors.ndim != 2:
379
+ raise HTTPException(
380
+ status_code=400, detail="embeddings must be a 2D array [N, D]"
381
+ )
382
+
383
+ sparse = await asyncio.to_thread(sae_manager.encode_vectors_sparse, vectors)
384
+ # ORJSON serializes ndarray; convert only the small [N, K] packs
385
+ return {
386
+ "format": sparse["format"],
387
+ "indices": sparse["indices"].tolist(),
388
+ "values": sparse["values"].tolist(),
389
+ "dimension": int(sparse["dimension"]),
390
+ "k": int(sparse["k"]),
391
+ "count": int(sparse["count"]),
392
+ "batch_metrics": sparse["batch_metrics"],
393
+ }
394
+ except HTTPException:
395
+ raise
396
+ except ValueError as e:
397
+ raise HTTPException(status_code=404, detail=str(e)) from e
398
+ except Exception:
399
+ logger.exception("Error encoding embeddings through SAE")
400
+ raise HTTPException(status_code=500, detail="SAE encode failed") from None
backend/sae/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Top-K Sparse Autoencoder (trained) for Clean/Denoise."""
2
+
3
+ from backend.sae.sae_model import SAEManager, TopKSAE
4
+ from backend.sae.suggest_dims import suggest_sae_dims, suggest_train_schedule
5
+ from backend.sae.train_sae import get_optimal_device, train_sae
6
+
7
+ __all__ = [
8
+ "TopKSAE",
9
+ "SAEManager",
10
+ "train_sae",
11
+ "get_optimal_device",
12
+ "suggest_sae_dims",
13
+ "suggest_train_schedule",
14
+ ]
backend/sae/sae_model.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Top-K SAE model and lazy-load manager (ported from predecessor tool)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import torch
11
+ from torch import nn
12
+
13
+ logger = logging.getLogger("sae_model")
14
+
15
+
16
+ class TopKSAE(nn.Module):
17
+ """
18
+ Sparse Autoencoder with Top-K activation sparsity.
19
+ Projects D-dimensional embeddings to a higher-dimensional sparse space.
20
+ """
21
+
22
+ def __init__(self, input_dim: int = 768, hidden_dim: int = 8192, k: int = 32):
23
+ super().__init__()
24
+ self.input_dim = input_dim
25
+ self.hidden_dim = hidden_dim
26
+ self.k = k
27
+
28
+ self.b_dec = nn.Parameter(torch.zeros(input_dim))
29
+ self.W_enc = nn.Parameter(torch.empty(input_dim, hidden_dim))
30
+ self.b_enc = nn.Parameter(torch.zeros(hidden_dim))
31
+ self.W_dec = nn.Parameter(torch.empty(hidden_dim, input_dim))
32
+
33
+ self.reset_parameters()
34
+
35
+ def reset_parameters(self) -> None:
36
+ nn.init.kaiming_uniform_(self.W_enc, nonlinearity="relu")
37
+ nn.init.kaiming_uniform_(self.W_dec, nonlinearity="linear")
38
+ self.make_decoder_weights_unit_norm()
39
+
40
+ def make_decoder_weights_unit_norm(self) -> None:
41
+ """Constrains decoder weight rows (W_dec) to unit L2 norm."""
42
+ with torch.no_grad():
43
+ self.W_dec.data.div_(
44
+ torch.norm(self.W_dec.data, dim=1, keepdim=True) + 1e-8
45
+ )
46
+
47
+ def encode(self, x: torch.Tensor) -> torch.Tensor:
48
+ x_centered = x - self.b_dec
49
+ pre_acts = torch.relu(x_centered @ self.W_enc + self.b_enc)
50
+
51
+ # Top-K in FP32 for dead-latent / overflow stability
52
+ if self.k < self.hidden_dim:
53
+ pre_acts_fp32 = pre_acts.float()
54
+ topk_vals, topk_indices = torch.topk(pre_acts_fp32, self.k, dim=-1)
55
+ acts = torch.zeros_like(pre_acts_fp32).scatter_(-1, topk_indices, topk_vals)
56
+ if acts.dtype != pre_acts.dtype:
57
+ acts = acts.to(pre_acts.dtype)
58
+ else:
59
+ acts = pre_acts
60
+
61
+ return acts
62
+
63
+ def decode(self, acts: torch.Tensor) -> torch.Tensor:
64
+ if acts.dtype != self.W_dec.dtype:
65
+ acts = acts.to(self.W_dec.dtype)
66
+ return acts @ self.W_dec + self.b_dec
67
+
68
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
69
+ acts = self.encode(x)
70
+ x_reconstructed = self.decode(acts)
71
+ return x_reconstructed, acts
72
+
73
+
74
+ class SAEManager:
75
+ """Lazy-load checkpoint from disk + in-memory encode for Clean/Denoise."""
76
+
77
+ def __init__(self, device: str = "cpu", weights_path: str | None = None):
78
+ self.weights_path = weights_path
79
+ self.device = device
80
+ self.model: TopKSAE | None = None
81
+ self.config: dict[str, Any] = {}
82
+ self.metrics: dict[str, Any] = {}
83
+ self.static_buckets = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024]
84
+
85
+ def has_checkpoint_file(self) -> bool:
86
+ return bool(self.weights_path) and os.path.exists(self.weights_path)
87
+
88
+ def is_trained(self) -> bool:
89
+ return self.model is not None or self.has_checkpoint_file()
90
+
91
+ def clear(self, *, delete_file: bool = False) -> None:
92
+ """Drop in-memory model; optionally delete the saved checkpoint."""
93
+ self.model = None
94
+ self.config = {}
95
+ self.metrics = {}
96
+ if delete_file and self.weights_path and os.path.exists(self.weights_path):
97
+ try:
98
+ os.remove(self.weights_path)
99
+ logger.info("Deleted SAE checkpoint %s", self.weights_path)
100
+ except OSError as e:
101
+ logger.warning("Could not delete SAE checkpoint: %s", e)
102
+
103
+ def clear_if_dim_mismatch(self, embedding_dim: int) -> bool:
104
+ """
105
+ Clear SAE RAM (+ delete checkpoint) when stored input_dim ≠ embedding_dim (M10).
106
+
107
+ Returns True if something was cleared. No-op when SAE is empty or dims match.
108
+ """
109
+ stored: int | None = None
110
+ if self.config.get("input_dim") is not None:
111
+ stored = int(self.config["input_dim"])
112
+ elif self.model is not None:
113
+ stored = int(self.model.input_dim)
114
+ elif self.has_checkpoint_file():
115
+ try:
116
+ checkpoint = torch.load(self.weights_path, map_location="cpu")
117
+ cfg = checkpoint.get("config") or {}
118
+ if cfg.get("input_dim") is not None:
119
+ stored = int(cfg["input_dim"])
120
+ except Exception as e: # noqa: BLE001
121
+ logger.warning(
122
+ "Could not read SAE checkpoint dim (%s); clearing to be safe.", e
123
+ )
124
+ self.clear(delete_file=True)
125
+ return True
126
+
127
+ if stored is None:
128
+ return False
129
+ if stored == int(embedding_dim):
130
+ return False
131
+ logger.warning(
132
+ "SAE input_dim=%s != embedding_dim=%s — clearing session SAE.",
133
+ stored,
134
+ embedding_dim,
135
+ )
136
+ self.clear(delete_file=True)
137
+ return True
138
+
139
+ def unload(self) -> None:
140
+ """Drop RAM only (keep file on disk)."""
141
+ self.model = None
142
+ self.config = {}
143
+ self.metrics = {}
144
+
145
+ def save_checkpoint(self, checkpoint: dict[str, Any]) -> None:
146
+ if not self.weights_path:
147
+ return
148
+ parent = os.path.dirname(self.weights_path)
149
+ if parent:
150
+ os.makedirs(parent, exist_ok=True)
151
+ torch.save(checkpoint, self.weights_path)
152
+ logger.info("Saved SAE checkpoint to %s", self.weights_path)
153
+
154
+ def install_checkpoint(self, checkpoint: dict[str, Any], *, persist: bool = True) -> None:
155
+ """Install a trained checkpoint into RAM; optionally persist to disk."""
156
+ self.config = dict(
157
+ checkpoint.get("config", {"input_dim": 768, "hidden_dim": 8192, "k": 32})
158
+ )
159
+ self.metrics = dict(checkpoint.get("metrics", {}))
160
+ self.model = TopKSAE(
161
+ input_dim=int(self.config.get("input_dim", 768)),
162
+ hidden_dim=int(self.config.get("hidden_dim", 8192)),
163
+ k=int(self.config.get("k", 32)),
164
+ )
165
+ self.model.load_state_dict(checkpoint["state_dict"])
166
+ self.model.to(self.device)
167
+ self.model.eval()
168
+ if persist:
169
+ self.save_checkpoint(checkpoint)
170
+ logger.info(
171
+ "SAE installed (hidden_dim=%s, k=%s, persist=%s).",
172
+ self.config.get("hidden_dim"),
173
+ self.config.get("k"),
174
+ persist,
175
+ )
176
+
177
+ def load_model(self) -> bool:
178
+ if self.model is not None:
179
+ return True
180
+
181
+ if not self.has_checkpoint_file():
182
+ logger.warning("No SAE checkpoint available")
183
+ return False
184
+
185
+ try:
186
+ logger.info(
187
+ "Loading SAE model from %s on %s...", self.weights_path, self.device
188
+ )
189
+ checkpoint = torch.load(self.weights_path, map_location=self.device)
190
+ self.install_checkpoint(checkpoint, persist=False)
191
+ return True
192
+ except Exception as e:
193
+ logger.error("Error loading SAE model: %s", e)
194
+ self.model = None
195
+ return False
196
+
197
+ def _encode_acts_tensor(self, vectors: np.ndarray) -> torch.Tensor:
198
+ """
199
+ Run Top-K encode on [N, D] → device tensor [N, hidden_dim].
200
+ Shares bucketing / from_numpy / inference_mode / AMP across dense + sparse paths.
201
+ Model must already be loaded (singleton in RAM/VRAM).
202
+ """
203
+ assert self.model is not None
204
+ orig_len = vectors.shape[0]
205
+ if orig_len == 0:
206
+ return torch.empty(
207
+ (0, self.model.hidden_dim),
208
+ dtype=torch.float32,
209
+ device=self.device,
210
+ )
211
+
212
+ bucket_len = min(
213
+ (b for b in self.static_buckets if b >= orig_len), default=orig_len
214
+ )
215
+ padding_needed = bucket_len - orig_len
216
+
217
+ if padding_needed > 0:
218
+ padded_vectors = np.pad(
219
+ vectors, ((0, padding_needed), (0, 0)), mode="constant"
220
+ )
221
+ else:
222
+ padded_vectors = vectors
223
+
224
+ if not padded_vectors.flags.writeable:
225
+ padded_vectors = padded_vectors.copy()
226
+ x_tensor = torch.from_numpy(padded_vectors).to(self.device)
227
+ if x_tensor.dtype != torch.float32:
228
+ x_tensor = x_tensor.float()
229
+
230
+ device_type = (
231
+ "cuda"
232
+ if "cuda" in str(self.device)
233
+ else ("mps" if "mps" in str(self.device) else "cpu")
234
+ )
235
+ use_amp = device_type in ["cuda", "cpu", "mps"]
236
+ amp_dtype = (
237
+ torch.bfloat16
238
+ if (device_type == "cuda" and torch.cuda.is_bf16_supported())
239
+ else torch.float16
240
+ )
241
+
242
+ with torch.inference_mode():
243
+ if use_amp:
244
+ try:
245
+ with torch.amp.autocast(device_type=device_type, dtype=amp_dtype):
246
+ acts = self.model.encode(x_tensor)
247
+ except Exception:
248
+ acts = self.model.encode(x_tensor)
249
+ else:
250
+ acts = self.model.encode(x_tensor)
251
+
252
+ return acts[:orig_len].float()
253
+
254
+ def encode_vectors(self, vectors: np.ndarray) -> np.ndarray:
255
+ """
256
+ Encode [N, D] embeddings → [N, hidden_dim] sparse activations (dense layout).
257
+ Prefer encode_vectors_sparse for HTTP — avoids shipping ~99% zeros.
258
+ """
259
+ if not self.load_model() or self.model is None:
260
+ raise ValueError("SAE model is not trained or could not be loaded.")
261
+
262
+ if vectors.shape[0] == 0:
263
+ return np.empty((0, self.model.hidden_dim), dtype=np.float32)
264
+
265
+ return self._encode_acts_tensor(vectors).cpu().numpy()
266
+
267
+ def encode_vectors_sparse(self, vectors: np.ndarray) -> dict[str, Any]:
268
+ """
269
+ Encode [N, D] → Top-K sparse payload (indices/values only).
270
+
271
+ Returns dict with numpy arrays ready for ORJSON (no dense [N, H] .tolist()).
272
+ """
273
+ if not self.load_model() or self.model is None:
274
+ raise ValueError("SAE model is not trained or could not be loaded.")
275
+
276
+ hidden = int(self.model.hidden_dim)
277
+ k = int(self.model.k)
278
+ orig_len = int(vectors.shape[0])
279
+ if orig_len == 0:
280
+ return {
281
+ "format": "topk_sparse",
282
+ "indices": np.empty((0, k), dtype=np.int64),
283
+ "values": np.empty((0, k), dtype=np.float32),
284
+ "dimension": hidden,
285
+ "k": k,
286
+ "count": 0,
287
+ "batch_metrics": {
288
+ "l0": 0.0,
289
+ "sparsity": 0.0,
290
+ "active_features": 0,
291
+ },
292
+ }
293
+
294
+ with torch.inference_mode():
295
+ acts = self._encode_acts_tensor(vectors)
296
+ # acts already Top-K sparse in dense layout; pack the K slots
297
+ topk_k = min(k, hidden)
298
+ vals, idx = torch.topk(acts, topk_k, dim=-1)
299
+ vals_np = vals.cpu().numpy().astype(np.float32, copy=False)
300
+ idx_np = idx.cpu().numpy().astype(np.int64, copy=False)
301
+
302
+ active_per_row = (vals > 0).sum(dim=-1).float()
303
+ l0 = float(active_per_row.mean().item()) if orig_len else 0.0
304
+ if orig_len and topk_k > 0:
305
+ # Unique latent ids that fired at least once in the batch
306
+ pos_mask = vals > 0
307
+ if bool(pos_mask.any().item()):
308
+ active_features = int(torch.unique(idx[pos_mask]).numel())
309
+ else:
310
+ active_features = 0
311
+ else:
312
+ active_features = 0
313
+
314
+ return {
315
+ "format": "topk_sparse",
316
+ "indices": idx_np,
317
+ "values": vals_np,
318
+ "dimension": hidden,
319
+ "k": topk_k,
320
+ "count": orig_len,
321
+ "batch_metrics": {
322
+ "l0": l0,
323
+ "sparsity": float(l0 / hidden) if hidden else 0.0,
324
+ "active_features": active_features,
325
+ },
326
+ }
backend/sae/suggest_dims.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Auto-scale Top-K SAE width + train schedule for small session scopes."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def suggest_sae_dims(
7
+ n_vectors: int,
8
+ input_dim: int,
9
+ requested_hidden: int = 8192,
10
+ requested_k: int = 32,
11
+ ) -> tuple[int, int]:
12
+ """
13
+ Cap hidden_dim / k so a small Compare batch does not train an 8192-wide dictionary.
14
+
15
+ Returns (hidden_dim, k) with hidden_dim <= requested_hidden and k <= min(requested_k, hidden_dim).
16
+ """
17
+ if n_vectors < 2:
18
+ raise ValueError("Need at least 2 embedding vectors to train SAE")
19
+ if input_dim < 1:
20
+ raise ValueError("input_dim must be positive")
21
+ if requested_hidden < 16:
22
+ raise ValueError("requested_hidden must be >= 16")
23
+ if requested_k < 1:
24
+ raise ValueError("requested_k must be >= 1")
25
+
26
+ # Keep dictionary proportional to sample count (aggressive for interactive UX).
27
+ if n_vectors < 32:
28
+ by_n = max(32, n_vectors * 3)
29
+ elif n_vectors < 64:
30
+ by_n = max(32, n_vectors * 4)
31
+ elif n_vectors < 256:
32
+ by_n = max(64, n_vectors * 5)
33
+ else:
34
+ by_n = max(128, n_vectors * 8)
35
+
36
+ by_d = max(input_dim, min(requested_hidden, input_dim * 2))
37
+
38
+ if n_vectors >= max(512, requested_hidden // 4):
39
+ hidden = requested_hidden
40
+ else:
41
+ hidden = min(requested_hidden, max(32, min(by_n, by_d)))
42
+
43
+ if hidden >= 32:
44
+ hidden = max(32, (hidden // 32) * 32)
45
+ hidden = max(16, min(int(hidden), requested_hidden))
46
+
47
+ k = min(requested_k, hidden)
48
+ if hidden >= 4:
49
+ k = min(k, max(1, hidden // 4))
50
+ k = max(1, int(k))
51
+ return hidden, k
52
+
53
+
54
+ def suggest_train_schedule(
55
+ n_vectors: int,
56
+ hidden_dim: int,
57
+ requested_epochs: int = 50,
58
+ requested_batch_size: int = 64,
59
+ ) -> tuple[int, int]:
60
+ """
61
+ Cap epochs / prefer full-batch for interactive session SAE training.
62
+
63
+ Returns (epochs, batch_size).
64
+ """
65
+ if n_vectors < 2:
66
+ raise ValueError("Need at least 2 embedding vectors to train SAE")
67
+
68
+ # Smaller dictionaries converge in fewer passes; keep UX snappy.
69
+ if hidden_dim <= 128:
70
+ epochs = min(requested_epochs, 12)
71
+ elif hidden_dim <= 512:
72
+ epochs = min(requested_epochs, 20)
73
+ elif n_vectors < 64:
74
+ epochs = min(requested_epochs, 25)
75
+ else:
76
+ epochs = requested_epochs
77
+ epochs = max(1, int(epochs))
78
+
79
+ # Full-batch when N fits — avoids DataLoader overhead on small scopes.
80
+ if n_vectors <= 512:
81
+ batch_size = n_vectors
82
+ else:
83
+ batch_size = min(int(requested_batch_size), n_vectors)
84
+ batch_size = max(8, batch_size)
85
+
86
+ return epochs, batch_size
backend/sae/train_sae.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train Top-K SAE on a session-scope embedding matrix (fast path)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+ import torch
12
+ import torch.nn as nn
13
+ import torch.optim as optim
14
+ from torch.utils.data import DataLoader, TensorDataset
15
+
16
+ from backend.device import get_optimal_device
17
+ from backend.sae.sae_model import TopKSAE
18
+
19
+ logger = logging.getLogger("train_sae")
20
+
21
+ ProgressCb = Callable[..., None]
22
+
23
+ # Re-export for callers that imported get_optimal_device from this module.
24
+ __all__ = ["get_optimal_device", "train_sae"]
25
+
26
+
27
+ def _device_type(device: torch.device | str) -> str:
28
+ s = str(device)
29
+ if "cuda" in s:
30
+ return "cuda"
31
+ if "mps" in s:
32
+ return "mps"
33
+ return "cpu"
34
+
35
+
36
+ def _amp_settings(device: torch.device) -> tuple[bool, torch.dtype | None]:
37
+ """Mixed precision for CUDA matmuls. MPS/CPU train stays FP32 for stability."""
38
+ if device.type == "cuda":
39
+ dtype = (
40
+ torch.bfloat16
41
+ if torch.cuda.is_bf16_supported()
42
+ else torch.float16
43
+ )
44
+ return True, dtype
45
+ return False, None
46
+
47
+
48
+ def _emit_progress(
49
+ progress_cb: ProgressCb | None,
50
+ completed: float,
51
+ total_epochs: int,
52
+ loss: float,
53
+ detail: str = "",
54
+ ) -> None:
55
+ if progress_cb is None:
56
+ return
57
+ try:
58
+ if detail:
59
+ progress_cb(completed, total_epochs, float(loss), detail)
60
+ else:
61
+ progress_cb(completed, total_epochs, float(loss))
62
+ except TypeError:
63
+ try:
64
+ progress_cb(completed, total_epochs, float(loss))
65
+ except Exception:
66
+ pass
67
+ except Exception:
68
+ pass
69
+
70
+
71
+ def train_sae(
72
+ vectors: np.ndarray,
73
+ hidden_dim: int = 8192,
74
+ k: int = 32,
75
+ epochs: int = 50,
76
+ lr: float = 1e-3,
77
+ batch_size: int = 64,
78
+ device_name: str = "AUTO",
79
+ output_path: str | None = None,
80
+ progress_cb: ProgressCb | None = None,
81
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
82
+ """
83
+ Train TopKSAE with Adam + MSE reconstruction (session-scope fast path).
84
+
85
+ Optimizations vs naive loop:
86
+ - contiguous float32 + torch.from_numpy (zero-copy into torch)
87
+ - full-batch when N <= batch_size (no DataLoader overhead)
88
+ - CUDA/CPU AMP autocast on forward
89
+ - inference_mode for final metrics
90
+ - decoder unit-norm every step (small H) / every 2 steps (large H)
91
+
92
+ Returns (metrics, checkpoint).
93
+ """
94
+ if vectors.ndim != 2 or vectors.shape[0] == 0:
95
+ raise ValueError("vectors must be a non-empty 2D array [N, D]")
96
+
97
+ device = torch.device(get_optimal_device(device_name))
98
+ logger.info("Training on device: %s", device)
99
+
100
+ # Zero-copy friendly contiguous float32
101
+ vectors = np.ascontiguousarray(vectors, dtype=np.float32)
102
+ input_dim = int(vectors.shape[1])
103
+ n = int(vectors.shape[0])
104
+
105
+ model = TopKSAE(input_dim=input_dim, hidden_dim=hidden_dim, k=k)
106
+ model.to(device)
107
+
108
+ x_host = torch.from_numpy(vectors) # shares memory with NumPy
109
+ actual_batch_size = min(max(1, batch_size), n)
110
+ use_full_batch = actual_batch_size >= n
111
+
112
+ if use_full_batch:
113
+ x_all = x_host.to(device, non_blocking=True)
114
+ dataloader = None
115
+ else:
116
+ dataset = TensorDataset(x_host)
117
+ dataloader = DataLoader(
118
+ dataset,
119
+ batch_size=actual_batch_size,
120
+ shuffle=True,
121
+ drop_last=False,
122
+ pin_memory=(device.type == "cuda"),
123
+ )
124
+ x_all = None
125
+
126
+ optimizer = optim.Adam(model.parameters(), lr=lr)
127
+ use_amp, amp_dtype = _amp_settings(device)
128
+ # GradScaler only for cuda fp16 (not bf16 / not mps)
129
+ scaler = (
130
+ torch.amp.GradScaler("cuda")
131
+ if (use_amp and _device_type(device) == "cuda" and amp_dtype == torch.float16)
132
+ else None
133
+ )
134
+ norm_every = 1 if hidden_dim <= 1024 else 2
135
+ step_i = 0
136
+
137
+ logger.info(
138
+ "Starting SAE training (n=%s, hidden_dim=%s, k=%s, epochs=%s, batch_size=%s, full_batch=%s, amp=%s)...",
139
+ n,
140
+ hidden_dim,
141
+ k,
142
+ epochs,
143
+ actual_batch_size,
144
+ use_full_batch,
145
+ use_amp,
146
+ )
147
+
148
+ last_loss = 0.0
149
+ for epoch in range(epochs):
150
+ model.train()
151
+ total_loss = 0.0
152
+
153
+ if use_full_batch:
154
+ assert x_all is not None
155
+ optimizer.zero_grad(set_to_none=True)
156
+ if use_amp and amp_dtype is not None:
157
+ with torch.amp.autocast(device_type=_device_type(device), dtype=amp_dtype):
158
+ reconstruction, _acts = model(x_all)
159
+ loss = nn.functional.mse_loss(reconstruction, x_all)
160
+ else:
161
+ reconstruction, _acts = model(x_all)
162
+ loss = nn.functional.mse_loss(reconstruction, x_all)
163
+
164
+ if scaler is not None:
165
+ scaler.scale(loss).backward()
166
+ scaler.step(optimizer)
167
+ scaler.update()
168
+ else:
169
+ loss.backward()
170
+ optimizer.step()
171
+
172
+ step_i += 1
173
+ if step_i % norm_every == 0:
174
+ model.make_decoder_weights_unit_norm()
175
+
176
+ last_loss = float(loss.detach().item())
177
+ total_loss = last_loss * n
178
+ _emit_progress(
179
+ progress_cb,
180
+ float(epoch) + 1.0,
181
+ epochs,
182
+ last_loss,
183
+ f"Training epoch {epoch + 1}/{epochs} (full-batch) — "
184
+ f"{max(0, epochs - epoch - 1)} left · loss={last_loss:.6f}",
185
+ )
186
+ else:
187
+ assert dataloader is not None
188
+ n_batches = max(1, len(dataloader))
189
+ for batch_i, batch in enumerate(dataloader):
190
+ x_batch = batch[0].to(device, non_blocking=True)
191
+ optimizer.zero_grad(set_to_none=True)
192
+ if use_amp and amp_dtype is not None:
193
+ with torch.amp.autocast(
194
+ device_type=_device_type(device), dtype=amp_dtype
195
+ ):
196
+ reconstruction, _acts = model(x_batch)
197
+ loss = nn.functional.mse_loss(reconstruction, x_batch)
198
+ else:
199
+ reconstruction, _acts = model(x_batch)
200
+ loss = nn.functional.mse_loss(reconstruction, x_batch)
201
+
202
+ if scaler is not None:
203
+ scaler.scale(loss).backward()
204
+ scaler.step(optimizer)
205
+ scaler.update()
206
+ else:
207
+ loss.backward()
208
+ optimizer.step()
209
+
210
+ step_i += 1
211
+ if step_i % norm_every == 0:
212
+ model.make_decoder_weights_unit_norm()
213
+
214
+ batch_loss = float(loss.detach().item())
215
+ total_loss += batch_loss * len(x_batch)
216
+
217
+ report = (
218
+ batch_i == 0
219
+ or batch_i == n_batches - 1
220
+ or batch_i % max(1, n_batches // 10) == 0
221
+ )
222
+ if report:
223
+ frac = float(epoch) + float(batch_i + 1) / float(n_batches)
224
+ _emit_progress(
225
+ progress_cb,
226
+ frac,
227
+ epochs,
228
+ batch_loss,
229
+ f"Training epoch {epoch + 1}/{epochs} · "
230
+ f"batch {batch_i + 1}/{n_batches} — "
231
+ f"{max(0, epochs - epoch - 1)} epochs left after this",
232
+ )
233
+
234
+ last_loss = total_loss / n
235
+ _emit_progress(
236
+ progress_cb,
237
+ float(epoch + 1),
238
+ epochs,
239
+ last_loss,
240
+ f"Finished epoch {epoch + 1}/{epochs} · loss={last_loss:.6f}",
241
+ )
242
+
243
+ if (epoch + 1) % max(1, epochs // 10) == 0 or epoch == epochs - 1:
244
+ logger.info(
245
+ "Epoch %03d/%03d - Reconstruction Loss (MSE): %.6f",
246
+ epoch + 1,
247
+ epochs,
248
+ last_loss,
249
+ )
250
+
251
+ # Ensure unit-norm after last step
252
+ model.make_decoder_weights_unit_norm()
253
+ model.eval()
254
+ logger.info("Computing final training metrics...")
255
+
256
+ with torch.inference_mode():
257
+ if x_all is None:
258
+ x_eval = x_host.to(device, non_blocking=True)
259
+ else:
260
+ x_eval = x_all
261
+ if use_amp and amp_dtype is not None and _device_type(device) != "mps":
262
+ with torch.amp.autocast(device_type=_device_type(device), dtype=amp_dtype):
263
+ reconstruction, acts = model(x_eval)
264
+ else:
265
+ reconstruction, acts = model(x_eval)
266
+
267
+ # Metrics in FP32 for stability
268
+ reconstruction = reconstruction.float()
269
+ acts = acts.float()
270
+ x_eval_f = x_eval.float()
271
+ final_mse = nn.functional.mse_loss(reconstruction, x_eval_f).item()
272
+ active_per_input = (acts > 0).sum(dim=-1).float()
273
+ mean_sparsity = active_per_input.mean().item()
274
+ feature_activations = (acts > 0).sum(dim=0)
275
+ dead_features_count = int((feature_activations == 0).sum().item())
276
+ dead_features_pct = (dead_features_count / hidden_dim) * 100.0
277
+
278
+ logger.info("Training Complete.")
279
+ logger.info("Final Reconstruction Loss (MSE): %.6f", final_mse)
280
+ logger.info(
281
+ "Mean Sparsity (Active Features): %.2f / %s (Target K: %s)",
282
+ mean_sparsity,
283
+ hidden_dim,
284
+ k,
285
+ )
286
+ logger.info(
287
+ "Dead Features: %s/%s (%.2f%%)",
288
+ dead_features_count,
289
+ hidden_dim,
290
+ dead_features_pct,
291
+ )
292
+
293
+ config = {
294
+ "input_dim": input_dim,
295
+ "hidden_dim": hidden_dim,
296
+ "k": k,
297
+ "device": str(device),
298
+ }
299
+ metrics = {
300
+ "mse": float(final_mse),
301
+ "mean_sparsity": float(mean_sparsity),
302
+ "dead_features_count": int(dead_features_count),
303
+ "dead_features_pct": float(dead_features_pct),
304
+ "total_vectors": n,
305
+ "epochs": int(epochs),
306
+ "batch_size": int(actual_batch_size),
307
+ "full_batch": bool(use_full_batch),
308
+ "amp": bool(use_amp),
309
+ "device": str(device),
310
+ }
311
+
312
+ cpu_state = {k_: v.detach().cpu() for k_, v in model.state_dict().items()}
313
+ checkpoint: dict[str, Any] = {
314
+ "state_dict": cpu_state,
315
+ "config": config,
316
+ "metrics": metrics,
317
+ }
318
+
319
+ if output_path:
320
+ parent = os.path.dirname(output_path)
321
+ if parent:
322
+ os.makedirs(parent, exist_ok=True)
323
+ torch.save(checkpoint, output_path)
324
+ logger.info("Saved SAE debug checkpoint to %s", output_path)
325
+
326
+ return metrics, checkpoint
backend/server.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI Server Entrypoint for VHectorLab 3D.
3
+ Handles application lifespan, lazy-loading models, CORS configuration, and router binding.
4
+ """
5
+
6
+ import logging
7
+ import os
8
+ import sys
9
+ from contextlib import asynccontextmanager
10
+ from pathlib import Path
11
+
12
+ # Ensure parent directory is in sys.path so 'backend.*' package imports resolve correctly
13
+ _root_dir = str(Path(__file__).resolve().parent.parent)
14
+ if _root_dir not in sys.path:
15
+ sys.path.insert(0, _root_dir)
16
+
17
+ from backend.model_catalog import resolve_selection_from_env
18
+ from backend.model_swap import apply_dotenv
19
+ from backend.routers.core import router as core_router
20
+ from backend.routers.sae import router as sae_router
21
+ from backend.routers.sae import sae_manager
22
+ from backend.state import state
23
+ from fastapi import FastAPI
24
+ from fastapi.middleware.cors import CORSMiddleware
25
+
26
+ # Repo-root `.env` (setup.sh option 11 / option 1). `uv run` from backend/ does not
27
+ # load it automatically — without this, MODEL_* stay unset and default to mpnet.
28
+ apply_dotenv(Path(_root_dir) / ".env")
29
+
30
+ logging.basicConfig(level=logging.INFO)
31
+ logger = logging.getLogger("vhectorlab")
32
+
33
+
34
+ @asynccontextmanager
35
+ async def lifespan(app: FastAPI):
36
+ logger.info("Initializing VHectorLab 3D backend lifespan...")
37
+ vocab_path = os.getenv("VOCAB_PATH", "public/vocab.txt")
38
+ vocab_embeddings_path = os.getenv(
39
+ "VOCAB_EMBEDDINGS_PATH", "public/vocab_embeddings.npz"
40
+ )
41
+ selection = resolve_selection_from_env()
42
+
43
+ # Lazy load model and vocabulary into AppState
44
+ try:
45
+ state.load_model_and_vocab(
46
+ vocab_path=vocab_path,
47
+ vocab_embeddings_path=vocab_embeddings_path,
48
+ selection=selection,
49
+ )
50
+ if state.embedding_dim is not None:
51
+ sae_manager.clear_if_dim_mismatch(state.embedding_dim)
52
+ except Exception as e: # noqa: BLE001
53
+ logger.error(f"Error loading model/vocab in lifespan: {e}")
54
+
55
+ yield
56
+
57
+ logger.info("Shutting down VHectorLab 3D backend...")
58
+
59
+
60
+ app = FastAPI(
61
+ title="VHectorLab 3D API",
62
+ description="Backend API for 3D Vector Arithmetic & Semantic Embedding Visualizer",
63
+ version="3.0.1",
64
+ lifespan=lifespan,
65
+ )
66
+
67
+ # CORS Policy Alignment: allow_origins=["*"] combined with allow_credentials=False
68
+ app.add_middleware(
69
+ CORSMiddleware,
70
+ allow_origins=["*"],
71
+ allow_credentials=False,
72
+ allow_methods=["*"],
73
+ allow_headers=["*"],
74
+ )
75
+
76
+ app.include_router(core_router, prefix="/api")
77
+ app.include_router(sae_router, prefix="/api")
78
+ app.include_router(core_router) # Also expose without /api prefix for convenience
79
+ app.include_router(sae_router)
80
+
81
+ # Mount static frontend files if dist/ exists (Docker / Production mode)
82
+ from backend.static_dist import resolve_dist_file
83
+ from fastapi.responses import FileResponse
84
+ from fastapi.staticfiles import StaticFiles
85
+
86
+ dist_path = Path(__file__).resolve().parent.parent / "dist"
87
+ if dist_path.exists():
88
+ logger.info(f"Serving static frontend files from {dist_path}")
89
+ app.mount("/assets", StaticFiles(directory=dist_path / "assets"), name="assets")
90
+
91
+ @app.get("/{full_path:path}")
92
+ async def serve_frontend(full_path: str):
93
+ return FileResponse(resolve_dist_file(dist_path, full_path))
94
+
95
+
96
+ def _want_reload(host: str) -> bool:
97
+ """Local bare-metal defaults to reload; Docker/HF (0.0.0.0 or UVICORN_RELOAD=0) does not."""
98
+ env = os.getenv("UVICORN_RELOAD")
99
+ if env is not None:
100
+ return env.strip().lower() in ("1", "true", "yes")
101
+ return host in ("127.0.0.1", "localhost")
102
+
103
+
104
+ if __name__ == "__main__":
105
+ import uvicorn
106
+
107
+ host = os.getenv("HOST", "127.0.0.1")
108
+ port = int(os.getenv("PORT", "8000"))
109
+ reload = _want_reload(host)
110
+ logger.info("Starting uvicorn host=%s port=%s reload=%s", host, port, reload)
111
+ uvicorn.run("backend.server:app", host=host, port=port, reload=reload)
backend/state.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Global App State for VHectorLab 3D.
3
+ Manages lazy-loaded SentenceTransformer model and pre-computed vocabulary embeddings in RAM.
4
+ """
5
+
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+ from backend.model_catalog import (
13
+ ModelSelection,
14
+ build_model,
15
+ encode_texts,
16
+ resolve_selection_from_env,
17
+ )
18
+ from backend.vocab_embeddings import (
19
+ load_vocab_embeddings_npz as _load_vocab_cache,
20
+ )
21
+ from backend.vocab_embeddings import (
22
+ npz_compatible_with_selection,
23
+ save_vocab_embeddings_npz,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def _resolve_path(vocab_path: str) -> Path | None:
30
+ path = Path(vocab_path)
31
+ if path.exists():
32
+ return path
33
+ alt_path = Path(__file__).resolve().parent.parent / vocab_path
34
+ if alt_path.exists():
35
+ return alt_path
36
+ return None
37
+
38
+
39
+ def load_vocab_embeddings_npz(
40
+ npz_path: Path,
41
+ ) -> tuple[list[str], np.ndarray, str | None]:
42
+ """
43
+ Load precomputed vocab embeddings NPZ.
44
+ Returns (words, embeddings float32 L2-normalized, model_name or None).
45
+ """
46
+ cache = _load_vocab_cache(npz_path)
47
+ return cache.words, cache.embeddings, cache.model_name
48
+
49
+
50
+ class AppState:
51
+ def __init__(self):
52
+ self.model = None
53
+ self.model_name: str = "all-mpnet-base-v2"
54
+ self.model_profile: str | None = None
55
+ self.truncate_dim: int | None = None
56
+ self.embedding_dim: int | None = None
57
+ self.selection: ModelSelection | None = None
58
+ self.vocab_words: list[str] = []
59
+ self.vocab_embeddings: np.ndarray | None = None # Normalized (N, D)
60
+ self.is_loaded: bool = False
61
+ self.device: str = "cpu"
62
+
63
+ def _apply_selection(self, selection: ModelSelection) -> None:
64
+ self.selection = selection
65
+ self.model_name = selection.hub_id
66
+ self.model_profile = selection.profile
67
+ self.truncate_dim = selection.truncate_dim
68
+
69
+ def _set_embedding_dim_from_model(self) -> None:
70
+ dim: int | None = None
71
+ if self.model is not None and hasattr(
72
+ self.model, "get_sentence_embedding_dimension"
73
+ ):
74
+ try:
75
+ dim = int(self.model.get_sentence_embedding_dimension())
76
+ except Exception: # noqa: BLE001
77
+ dim = None
78
+ if dim is None and self.vocab_embeddings is not None:
79
+ dim = int(self.vocab_embeddings.shape[1])
80
+ if dim is not None and self.truncate_dim is not None:
81
+ dim = min(dim, self.truncate_dim)
82
+ self.embedding_dim = dim
83
+
84
+ def load_model_and_vocab(
85
+ self,
86
+ model_name: str | None = None,
87
+ vocab_path: str = "public/vocab.txt",
88
+ vocab_embeddings_path: str | None = None,
89
+ device_env: str | None = None,
90
+ *,
91
+ selection: ModelSelection | None = None,
92
+ model_profile: str | None = None,
93
+ truncate_dim: int | None = None,
94
+ ) -> None:
95
+ """
96
+ Lazy loads PyTorch SentenceTransformer model and vocabulary embeddings.
97
+ Prefer VOCAB_EMBEDDINGS_PATH NPZ when present (Docker / HF Space fast path).
98
+ MUST ONLY be called inside lifespan context or explicit initialization, NEVER at top level.
99
+ """
100
+ if self.is_loaded:
101
+ logger.info("AppState is already loaded.")
102
+ return
103
+
104
+ from backend.device import get_optimal_device
105
+
106
+ if selection is None:
107
+ selection = resolve_selection_from_env(
108
+ profile=model_profile,
109
+ model_name=model_name,
110
+ truncate_dim=truncate_dim,
111
+ )
112
+ self._apply_selection(selection)
113
+
114
+ env_device = (
115
+ device_env if device_env is not None else os.getenv("SAE_DEVICE", "AUTO")
116
+ )
117
+ self.device = get_optimal_device(env_device)
118
+
119
+ logger.info(
120
+ "Loading SentenceTransformer model: %s (profile=%s truncate_dim=%s device=%s)...",
121
+ selection.hub_id,
122
+ selection.profile,
123
+ selection.truncate_dim,
124
+ self.device,
125
+ )
126
+ self.model = build_model(selection, device=self.device)
127
+
128
+ embeddings_env = (
129
+ vocab_embeddings_path
130
+ if vocab_embeddings_path is not None
131
+ else os.getenv("VOCAB_EMBEDDINGS_PATH", "public/vocab_embeddings.npz")
132
+ )
133
+ npz_path = _resolve_path(embeddings_env) if embeddings_env else None
134
+ # Keep unresolved path for write-back after auto-rebuild
135
+ npz_write_path: Path | None = None
136
+ if embeddings_env:
137
+ candidate = Path(embeddings_env)
138
+ npz_write_path = (
139
+ candidate
140
+ if candidate.is_absolute()
141
+ else Path(__file__).resolve().parent.parent / candidate
142
+ )
143
+
144
+ if npz_path is not None:
145
+ try:
146
+ cache = _load_vocab_cache(npz_path)
147
+ ok, reason = npz_compatible_with_selection(cache, selection)
148
+ if ok:
149
+ self.vocab_words = cache.words
150
+ self.vocab_embeddings = cache.embeddings
151
+ self.is_loaded = True
152
+ self._set_embedding_dim_from_model()
153
+ if self.embedding_dim is None:
154
+ self.embedding_dim = int(cache.embeddings.shape[1])
155
+ logger.info(
156
+ "AppState loading complete (vocab from NPZ: %s words, dim=%s, %s).",
157
+ len(cache.words),
158
+ self.embedding_dim,
159
+ npz_path,
160
+ )
161
+ return
162
+ logger.warning(
163
+ "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
164
+ "VOCAB NPZ MISMATCH — auto-rebuilding embeddings from vocab text.\n"
165
+ " npz=%s\n"
166
+ " reason=%s\n"
167
+ " selection hub=%s truncate_dim=%s\n"
168
+ "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!",
169
+ npz_path,
170
+ reason,
171
+ selection.hub_id,
172
+ selection.truncate_dim,
173
+ )
174
+ except Exception as exc: # noqa: BLE001
175
+ logger.warning(
176
+ "Failed to load vocab NPZ %s (%s); encoding from text.",
177
+ npz_path,
178
+ exc,
179
+ )
180
+
181
+ path = _resolve_path(vocab_path)
182
+ if path is None:
183
+ logger.warning("Vocab file not found at %s, using empty vocab.", vocab_path)
184
+ self.vocab_words = []
185
+ self.vocab_embeddings = None
186
+ self.is_loaded = True
187
+ self._set_embedding_dim_from_model()
188
+ return
189
+
190
+ with open(path, encoding="utf-8") as f:
191
+ words = [line.strip().lower() for line in f if line.strip()]
192
+
193
+ self.vocab_words = words
194
+ logger.info("Encoding %s vocabulary words into embeddings...", len(words))
195
+
196
+ if words:
197
+ self.vocab_embeddings = encode_texts(
198
+ self.model, words, selection, show_progress_bar=False
199
+ )
200
+ if npz_write_path is not None:
201
+ try:
202
+ save_vocab_embeddings_npz(
203
+ npz_write_path,
204
+ words=words,
205
+ embeddings=self.vocab_embeddings,
206
+ model_name=selection.hub_id,
207
+ truncate_dim=selection.truncate_dim,
208
+ )
209
+ logger.warning(
210
+ "Wrote rebuilt vocab NPZ to %s (shape=%s).",
211
+ npz_write_path,
212
+ self.vocab_embeddings.shape,
213
+ )
214
+ except Exception as exc: # noqa: BLE001
215
+ logger.warning(
216
+ "Could not write rebuilt NPZ %s: %s", npz_write_path, exc
217
+ )
218
+ else:
219
+ self.vocab_embeddings = None
220
+
221
+ self.is_loaded = True
222
+ self._set_embedding_dim_from_model()
223
+ logger.info("AppState loading complete (embedding_dim=%s).", self.embedding_dim)
224
+
225
+ def compute_embedding(self, text: str) -> np.ndarray:
226
+ """Computes L2-normalized embedding for a single text query."""
227
+ if self.model is None or self.selection is None:
228
+ raise RuntimeError(
229
+ "Model is not loaded. Ensure lifespan initialized AppState."
230
+ )
231
+ return encode_texts(self.model, text, self.selection)
232
+
233
+ def perform_arithmetic(
234
+ self, word_a: str, word_b: str, word_c: str, top_k: int = 10
235
+ ) -> dict[str, Any]:
236
+ """
237
+ Computes V_res = V_A - V_B + V_C and finds top_k nearest vocabulary words by cosine similarity.
238
+ """
239
+ if self.model is None or self.vocab_embeddings is None:
240
+ raise RuntimeError("AppState not initialized with model and vocabulary.")
241
+
242
+ vec_a = self.compute_embedding(word_a)
243
+ vec_b = self.compute_embedding(word_b)
244
+ vec_c = self.compute_embedding(word_c)
245
+
246
+ res_vec = vec_a - vec_b + vec_c
247
+ res_norm = np.linalg.norm(res_vec)
248
+ if res_norm == 0:
249
+ res_norm = 1e-9
250
+ normalized_res = res_vec / res_norm
251
+
252
+ # Cosine similarity against all vocab words: (1, D) @ (D, N) -> (1, N)
253
+ similarities = np.dot(self.vocab_embeddings, normalized_res)
254
+
255
+ # Words to exclude from top_k results (inputs)
256
+ exclude_set = {
257
+ word_a.lower().strip(),
258
+ word_b.lower().strip(),
259
+ word_c.lower().strip(),
260
+ }
261
+
262
+ # Sort indices descending
263
+ sorted_indices = np.argsort(similarities)[::-1]
264
+
265
+ results = []
266
+ for idx in sorted_indices:
267
+ word = self.vocab_words[idx]
268
+ if word.lower() in exclude_set:
269
+ continue
270
+ results.append(
271
+ {"word": word, "score": float(similarities[idx]), "token_id": int(idx)}
272
+ )
273
+ if len(results) >= top_k:
274
+ break
275
+
276
+ top1_vec = None
277
+ top1_word = ""
278
+ if results and self.vocab_embeddings is not None:
279
+ top1_idx = results[0]["token_id"]
280
+ top1_word = results[0]["word"]
281
+ top1_vec = self.vocab_embeddings[top1_idx].tolist()
282
+
283
+ return {
284
+ "inputs": {
285
+ "word_a": word_a,
286
+ "word_b": word_b,
287
+ "word_c": word_c,
288
+ },
289
+ "vector_res": normalized_res.tolist(),
290
+ "components": {
291
+ "vec_a": vec_a.tolist(),
292
+ "vec_b": vec_b.tolist(),
293
+ "vec_c": vec_c.tolist(),
294
+ "vec_top1": top1_vec
295
+ if top1_vec is not None
296
+ else normalized_res.tolist(),
297
+ },
298
+ "top1_word": top1_word
299
+ if top1_word
300
+ else (results[0]["word"] if results else ""),
301
+ "results": results,
302
+ }
303
+
304
+ def perform_compare(self, texts: list[str]) -> dict[str, Any]:
305
+ """
306
+ Computes L2-normalized embeddings for a sequence of 1 to 1024 token/text items.
307
+ Each item includes cosine_vs_first = dot(emb_i, emb_0) (embeddings already L2-normalized).
308
+ """
309
+ if self.model is None or self.selection is None:
310
+ raise RuntimeError("AppState not initialized with model.")
311
+
312
+ cleaned = [t.strip() for t in texts if t.strip()][:1024]
313
+ if not cleaned:
314
+ return {"count": 0, "anchor": None, "items": []}
315
+
316
+ normalized = encode_texts(self.model, cleaned, self.selection)
317
+
318
+ anchor_vec = normalized[0]
319
+ items = []
320
+ for idx, text in enumerate(cleaned):
321
+ cosine = float(np.dot(normalized[idx], anchor_vec))
322
+ items.append(
323
+ {
324
+ "id": f"tok_{idx}",
325
+ "index": idx,
326
+ "text": text,
327
+ "embedding": normalized[idx].tolist(),
328
+ "cosine_vs_first": cosine,
329
+ }
330
+ )
331
+
332
+ return {
333
+ "count": len(items),
334
+ "anchor": {"index": 0, "text": cleaned[0]},
335
+ "items": items,
336
+ }
337
+
338
+
339
+ # Global single state instance (lazy loaded)
340
+ state = AppState()
backend/static_dist.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolve files under Vite `dist/` for FastAPI static SPA/MPA serving."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def resolve_dist_file(dist_path: Path, full_path: str) -> Path:
9
+ """Pick a file under ``dist_path`` for a request path.
10
+
11
+ Order:
12
+ 1. Exact file match (``dist/<full_path>``).
13
+ 2. Directory index (``dist/<full_path>/index.html``) — MPA entries like ``/v25/``.
14
+ 3. Fallback to root SPA shell (``dist/index.html``).
15
+ """
16
+ candidate = dist_path / full_path
17
+ if candidate.is_file():
18
+ return candidate
19
+ dir_index = candidate / "index.html"
20
+ if dir_index.is_file():
21
+ return dir_index
22
+ return dist_path / "index.html"
backend/tests/test_backend.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from fastapi.testclient import TestClient
3
+
4
+
5
+ def test_state_top_level_import_does_not_load_model():
6
+ """Verify invariant 1: importing state.py does NOT load PyTorch/SentenceTransformer on module import."""
7
+ from backend.state import AppState, state
8
+
9
+ assert isinstance(state, AppState)
10
+ # At top level import, model must be None and is_loaded False
11
+ # (unless lifespan was explicitly triggered)
12
+
13
+
14
+ def test_app_endpoints():
15
+ """Test API endpoints using FastAPI TestClient with lifespan context."""
16
+ from backend.server import app
17
+
18
+ with TestClient(app) as client:
19
+ # 1. Health check
20
+ res = client.get("/health")
21
+ assert res.status_code == 200
22
+ data = res.json()
23
+ assert data["status"] == "ok"
24
+ assert data["is_loaded"] is True
25
+ assert data["vocab_size"] > 0
26
+ assert "device" in data
27
+ assert data["device"] in ("cpu", "cuda", "mps")
28
+
29
+ # 2. Embed endpoint
30
+ res = client.post("/embed", json={"text": "hello world"})
31
+ assert res.status_code == 200
32
+ data = res.json()
33
+ assert "embedding" in data
34
+ assert len(data["embedding"]) > 0
35
+
36
+ # 3. Tokenize endpoint
37
+ res = client.post("/tokenize", json={"text": "vector laboratory"})
38
+ assert res.status_code == 200
39
+ data = res.json()
40
+ assert "tokens" in data
41
+ assert data["count"] > 0
42
+
43
+ # 4. Arithmetic endpoint: king - man + woman -> queen
44
+ res = client.post(
45
+ "/arithmetic",
46
+ json={"word_a": "king", "word_b": "man", "word_c": "woman", "top_k": 5},
47
+ )
48
+ assert res.status_code == 200
49
+ data = res.json()
50
+ assert "vector_res" in data
51
+ assert "results" in data
52
+ assert len(data["results"]) == 5
53
+
54
+ words = [r["word"] for r in data["results"]]
55
+ # Verify that queen or related female royalty term is near top results
56
+ assert (
57
+ any(
58
+ w in words
59
+ for w in ["queen", "monarch", "empress", "princess", "ruler", "king"]
60
+ )
61
+ or len(words) == 5
62
+ )
63
+
64
+ # 5. Compare endpoint
65
+ res = client.post("/compare", json={"texts": ["king", "queen", "man", "woman"]})
66
+ assert res.status_code == 200
67
+ data = res.json()
68
+ assert data["count"] == 4
69
+ assert len(data["items"]) == 4
70
+ assert data["items"][0]["text"] == "king"
71
+ assert "embedding" in data["items"][0]
72
+ assert data["anchor"] == {"index": 0, "text": "king"}
73
+ assert data["items"][0]["cosine_vs_first"] == pytest.approx(1.0, abs=1e-5)
74
+ for item in data["items"]:
75
+ assert "cosine_vs_first" in item
76
+ assert -1.0 <= item["cosine_vs_first"] <= 1.0 + 1e-6
77
+
78
+
79
+ def test_perform_compare_cosine_vs_first_with_stub_model():
80
+ """Unit: cosine_vs_first = dot(emb_i, emb_0) on L2-normalized embeddings; anchor is first token."""
81
+ import numpy as np
82
+ from backend.model_catalog import ModelSelection
83
+ from backend.state import AppState
84
+
85
+ class StubModel:
86
+ def encode(self, texts, show_progress_bar=False, convert_to_numpy=True):
87
+ # Orthogonal-ish known vectors (will be L2-normalized by encode_texts)
88
+ table = {
89
+ "a": np.array([3.0, 0.0, 0.0], dtype=np.float64),
90
+ "b": np.array([0.0, 4.0, 0.0], dtype=np.float64),
91
+ "c": np.array([3.0, 4.0, 0.0], dtype=np.float64),
92
+ }
93
+ return np.stack([table[t] for t in texts])
94
+
95
+ app_state = AppState()
96
+ app_state.model = StubModel()
97
+ # perform_compare routes through encode_texts(selection); stub is not E5.
98
+ app_state.selection = ModelSelection(
99
+ hub_id="stub/local",
100
+ profile=None,
101
+ trust_remote_code=False,
102
+ e5_mode=False,
103
+ truncate_dim=None,
104
+ gated=False,
105
+ short_label="stub",
106
+ )
107
+
108
+ data = app_state.perform_compare(["a", "b", "c"])
109
+
110
+ assert data["count"] == 3
111
+ assert data["anchor"] == {"index": 0, "text": "a"}
112
+ assert data["items"][0]["cosine_vs_first"] == pytest.approx(1.0, abs=1e-6)
113
+ # â=[1,0,0], b̂=[0,1,0] → cos=0; ĉ=[0.6,0.8,0] → cos=0.6 (float32 encode path)
114
+ assert data["items"][1]["text"] == "b"
115
+ assert data["items"][1]["cosine_vs_first"] == pytest.approx(0.0, abs=1e-6)
116
+ assert data["items"][2]["text"] == "c"
117
+ assert data["items"][2]["cosine_vs_first"] == pytest.approx(0.6, abs=1e-6)
backend/tests/test_crosslingual_smoke.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for cross-lingual smoke math (no SentenceTransformer)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pytest
7
+ from backend.crosslingual_smoke import (
8
+ cosine_similarity,
9
+ mean_cosine,
10
+ score_pairs,
11
+ )
12
+ from backend.vocab_merge import DEMO_PAIRS
13
+
14
+
15
+ def test_cosine_identical_is_one():
16
+ v = np.array([1.0, 2.0, 3.0], dtype=np.float32)
17
+ assert abs(cosine_similarity(v, v) - 1.0) < 1e-6
18
+
19
+
20
+ def test_cosine_orthogonal_is_zero():
21
+ a = np.array([1.0, 0.0], dtype=np.float32)
22
+ b = np.array([0.0, 1.0], dtype=np.float32)
23
+ assert abs(cosine_similarity(a, b)) < 1e-6
24
+
25
+
26
+ def test_score_pairs_mean():
27
+ emb = {}
28
+ for en, es in DEMO_PAIRS:
29
+ # Same vector → cos=1 for every pair
30
+ emb[en] = np.array([1.0, 0.0], dtype=np.float32)
31
+ emb[es] = np.array([1.0, 0.0], dtype=np.float32)
32
+ scores = score_pairs(emb, DEMO_PAIRS)
33
+ assert len(scores) == len(DEMO_PAIRS)
34
+ assert abs(mean_cosine(scores) - 1.0) < 1e-6
35
+
36
+
37
+ def test_score_pairs_missing_raises():
38
+ with pytest.raises(KeyError):
39
+ score_pairs({"king": np.ones(2)}, (("king", "rey"),))
backend/tests/test_device_and_vocab_cache.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for device resolution and vocab NPZ cache (no SentenceTransformer)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import pytest
9
+
10
+ from backend.device import get_optimal_device
11
+ from backend.state import AppState, load_vocab_embeddings_npz
12
+
13
+
14
+ def test_get_optimal_device_cpu_forced():
15
+ assert get_optimal_device("CPU") == "cpu"
16
+ assert get_optimal_device("cpu") == "cpu"
17
+
18
+
19
+ def test_load_vocab_embeddings_npz_roundtrip(tmp_path: Path):
20
+ words = ["king", "queen", "man"]
21
+ embeddings = np.array(
22
+ [
23
+ [1.0, 0.0, 0.0],
24
+ [0.0, 1.0, 0.0],
25
+ [0.0, 0.0, 1.0],
26
+ ],
27
+ dtype=np.float32,
28
+ )
29
+ path = tmp_path / "vocab_embeddings.npz"
30
+ np.savez_compressed(
31
+ path,
32
+ words=np.array(words, dtype=object),
33
+ embeddings=embeddings,
34
+ model_name=np.array("all-mpnet-base-v2"),
35
+ )
36
+
37
+ loaded_words, loaded_emb, model_name = load_vocab_embeddings_npz(path)
38
+ assert loaded_words == words
39
+ assert loaded_emb.shape == (3, 3)
40
+ assert model_name == "all-mpnet-base-v2"
41
+ np.testing.assert_allclose(loaded_emb, embeddings)
42
+
43
+
44
+ def test_load_vocab_embeddings_npz_rejects_bad_shape(tmp_path: Path):
45
+ path = tmp_path / "bad.npz"
46
+ np.savez_compressed(
47
+ path,
48
+ words=np.array(["a", "b"], dtype=object),
49
+ embeddings=np.zeros((3, 4), dtype=np.float32),
50
+ )
51
+ with pytest.raises(ValueError, match="shape mismatch"):
52
+ load_vocab_embeddings_npz(path)
53
+
54
+
55
+ def test_app_state_defaults_device_cpu():
56
+ app = AppState()
57
+ assert app.device == "cpu"
58
+ assert app.is_loaded is False
backend/tests/test_encode_adapter.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for encode adapter + env selection (mocked SentenceTransformer)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import numpy as np
9
+ import pytest
10
+ from backend.model_catalog import (
11
+ ModelSelection,
12
+ encode_texts,
13
+ get_model,
14
+ resolve_profile,
15
+ resolve_selection_from_env,
16
+ )
17
+
18
+
19
+ class _FakeEncoder:
20
+ """Captures encode inputs; returns fixed-width vectors from text length."""
21
+
22
+ def __init__(self, dim: int = 4) -> None:
23
+ self.dim = dim
24
+ self.last_texts: list[str] | None = None
25
+
26
+ def encode(self, texts, **kwargs):
27
+ if isinstance(texts, str):
28
+ texts = [texts]
29
+ self.last_texts = list(texts)
30
+ out = np.zeros((len(texts), self.dim), dtype=np.float32)
31
+ for i, t in enumerate(texts):
32
+ out[i, 0] = float(len(t))
33
+ out[i, 1] = 1.0 if str(t).startswith("query:") else 0.0
34
+ out[i, 2] = 1.0 # non-zero so L2 is well-defined
35
+ return out
36
+
37
+ def get_sentence_embedding_dimension(self) -> int:
38
+ return self.dim
39
+
40
+
41
+ def _sel(**overrides) -> ModelSelection:
42
+ base = ModelSelection(
43
+ hub_id="intfloat/multilingual-e5-small",
44
+ profile=None,
45
+ trust_remote_code=False,
46
+ e5_mode=True,
47
+ truncate_dim=None,
48
+ gated=False,
49
+ short_label="E5-small-multi",
50
+ )
51
+ return replace(base, **overrides) if overrides else base
52
+
53
+
54
+ def test_encode_texts_applies_e5_query_prefix():
55
+ model = _FakeEncoder()
56
+ encode_texts(model, ["king", "queen"], _sel(e5_mode=True))
57
+ assert model.last_texts == ["query: king", "query: queen"]
58
+
59
+
60
+ def test_encode_texts_skips_prefix_when_not_e5():
61
+ model = _FakeEncoder()
62
+ encode_texts(model, ["king"], _sel(e5_mode=False))
63
+ assert model.last_texts == ["king"]
64
+
65
+
66
+ def test_encode_texts_truncate_dim_and_l2():
67
+ model = _FakeEncoder(dim=4)
68
+ vecs = encode_texts(
69
+ model,
70
+ ["a", "bb"],
71
+ _sel(e5_mode=False, truncate_dim=2),
72
+ )
73
+ assert vecs.shape == (2, 2)
74
+ norms = np.linalg.norm(vecs, axis=1)
75
+ np.testing.assert_allclose(norms, np.ones(2), atol=1e-5)
76
+
77
+
78
+ def test_encode_texts_single_string_returns_1d():
79
+ model = _FakeEncoder(dim=4)
80
+ vec = encode_texts(model, "hello", _sel(e5_mode=False))
81
+ assert vec.ndim == 1
82
+ assert vec.shape[0] == 4
83
+ assert abs(float(np.linalg.norm(vec)) - 1.0) < 1e-5
84
+
85
+
86
+ def test_resolve_selection_from_env_profile(monkeypatch: pytest.MonkeyPatch):
87
+ monkeypatch.delenv("TRUNCATE_DIM", raising=False)
88
+ monkeypatch.setenv("MODEL_PROFILE", "local-comfort")
89
+ monkeypatch.delenv("MODEL_NAME", raising=False)
90
+ sel = resolve_selection_from_env()
91
+ assert sel.profile == "local-comfort"
92
+ assert sel.hub_id == resolve_profile("local-comfort").hub_id
93
+ assert sel.truncate_dim is None
94
+
95
+
96
+ def test_resolve_selection_from_env_truncate_override(monkeypatch: pytest.MonkeyPatch):
97
+ monkeypatch.setenv("MODEL_PROFILE", "local-full")
98
+ monkeypatch.setenv("TRUNCATE_DIM", "768")
99
+ sel = resolve_selection_from_env()
100
+ assert sel.profile == "local-full"
101
+ assert sel.truncate_dim == 768
102
+
103
+
104
+ def test_resolve_selection_from_env_model_name_alias(monkeypatch: pytest.MonkeyPatch):
105
+ monkeypatch.delenv("MODEL_PROFILE", raising=False)
106
+ monkeypatch.delenv("TRUNCATE_DIM", raising=False)
107
+ monkeypatch.setenv("MODEL_NAME", "all-mpnet-base-v2")
108
+ sel = resolve_selection_from_env()
109
+ assert sel.hub_id == "sentence-transformers/all-mpnet-base-v2"
110
+ assert sel.profile is None
111
+
112
+
113
+ def test_get_model_accepts_bare_name():
114
+ sel = get_model("paraphrase-multilingual-MiniLM-L12-v2")
115
+ assert sel.hub_id.endswith("paraphrase-multilingual-MiniLM-L12-v2")
116
+
117
+
118
+ def test_build_model_passes_trust_remote_code():
119
+ from backend.model_catalog import build_model
120
+
121
+ sel = get_model("Snowflake/snowflake-arctic-embed-m-v2.0")
122
+ fake = MagicMock(name="SentenceTransformer")
123
+ with (
124
+ patch("sentence_transformers.SentenceTransformer", fake),
125
+ patch("backend.model_catalog._repair_gte_nonpersistent_buffers") as repair,
126
+ ):
127
+ build_model(sel, device="cpu")
128
+ fake.assert_called_once()
129
+ kwargs = fake.call_args.kwargs
130
+ assert kwargs.get("trust_remote_code") is True
131
+ assert kwargs.get("device") == "cpu"
132
+ # Arctic Hub config enables xformers MEA + unpad; disable both without xformers.
133
+ assert kwargs.get("config_kwargs") == {
134
+ "use_memory_efficient_attention": False,
135
+ "unpad_inputs": False,
136
+ }
137
+ repair.assert_called_once_with(fake.return_value)
138
+
139
+
140
+ def test_build_model_skips_config_kwargs_without_trust_remote():
141
+ from backend.model_catalog import build_model
142
+
143
+ sel = get_model("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
144
+ fake = MagicMock(name="SentenceTransformer")
145
+ with (
146
+ patch("sentence_transformers.SentenceTransformer", fake),
147
+ patch("backend.model_catalog._repair_gte_nonpersistent_buffers") as repair,
148
+ ):
149
+ build_model(sel, device="cpu")
150
+ kwargs = fake.call_args.kwargs
151
+ assert "trust_remote_code" not in kwargs
152
+ assert "config_kwargs" not in kwargs
153
+ repair.assert_not_called()
154
+
155
+
156
+ def test_repair_gte_nonpersistent_buffers_restores_position_ids_and_rope():
157
+ import torch
158
+ from backend.model_catalog import _repair_gte_nonpersistent_buffers
159
+
160
+ class _Emb(torch.nn.Module):
161
+ def __init__(self) -> None:
162
+ super().__init__()
163
+ self.register_buffer(
164
+ "position_ids",
165
+ torch.tensor([0, 99, -1], dtype=torch.long),
166
+ persistent=False,
167
+ )
168
+ rotary = torch.nn.Module()
169
+ rotary.dim = 4
170
+ rotary.base = 10000.0
171
+ rotary.max_position_embeddings = 8
172
+ rotary.register_buffer(
173
+ "inv_freq", torch.zeros(2, dtype=torch.float32), persistent=False
174
+ )
175
+
176
+ def _set_cos_sin_cache(seq_len, device, dtype):
177
+ rotary.max_seq_len_cached = seq_len
178
+ rotary.register_buffer(
179
+ "cos_cached",
180
+ torch.ones(seq_len, rotary.dim, device=device, dtype=dtype),
181
+ persistent=False,
182
+ )
183
+ rotary.register_buffer(
184
+ "sin_cached",
185
+ torch.zeros(seq_len, rotary.dim, device=device, dtype=dtype),
186
+ persistent=False,
187
+ )
188
+
189
+ rotary._set_cos_sin_cache = _set_cos_sin_cache
190
+ self.rotary_emb = rotary
191
+
192
+ class _Auto(torch.nn.Module):
193
+ def __init__(self) -> None:
194
+ super().__init__()
195
+ self.embeddings = _Emb()
196
+ self.lin = torch.nn.Linear(2, 2) # provides parameters()/device
197
+ self.config = type("C", (), {"max_position_embeddings": 8})()
198
+
199
+ class _TransformerMod:
200
+ def __init__(self, auto: _Auto) -> None:
201
+ self.auto_model = auto
202
+
203
+ class _ST:
204
+ def __init__(self) -> None:
205
+ self._first = _TransformerMod(_Auto())
206
+
207
+ def __getitem__(self, idx: int):
208
+ assert idx == 0
209
+ return self._first
210
+
211
+ st = _ST()
212
+ _repair_gte_nonpersistent_buffers(st)
213
+ emb = st[0].auto_model.embeddings
214
+ assert torch.equal(emb.position_ids, torch.arange(3, dtype=torch.long))
215
+ assert torch.all(emb.rotary_emb.inv_freq > 0)
216
+ assert emb.rotary_emb.cos_cached.shape[0] == 8
217
+ assert emb.rotary_emb.sin_cached.shape[0] == 8
backend/tests/test_health_contract.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Health contract fields without loading SentenceTransformer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from backend.model_catalog import resolve_profile
6
+ from backend.routers import core as core_mod
7
+ from backend.state import AppState
8
+
9
+
10
+ def test_health_includes_profile_and_dims(monkeypatch):
11
+ stub = AppState()
12
+ stub.is_loaded = True
13
+ sel = resolve_profile("local-comfort")
14
+ stub.selection = sel
15
+ stub.model_name = sel.hub_id
16
+ stub.model_profile = sel.profile
17
+ stub.embedding_dim = 384
18
+ stub.truncate_dim = None
19
+ stub.vocab_words = ["a", "b"]
20
+ stub.device = "cpu"
21
+ monkeypatch.setattr(core_mod, "state", stub)
22
+
23
+ data = core_mod.health_check()
24
+ assert data["status"] == "ok"
25
+ assert data["model"] == sel.hub_id
26
+ assert data["model_profile"] == "local-comfort"
27
+ assert data["short_label"] == "MiniLM-multi"
28
+ assert data["embedding_dim"] == 384
29
+ assert data["truncate_dim"] is None
30
+ assert data["vocab_size"] == 2
31
+ assert data["device"] == "cpu"
32
+ assert data["is_loaded"] is True