From 0ca88fdcd5e0bafe5b5e3444c2c568c88424128c Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 12 Sep 2026 01:32:12 +0000 Subject: [PATCH] =?UTF-8?q?atuq=20=C2=A76.3.ter:=20preguntarle=20al=20arch?= =?UTF-8?q?ivo=20por=20lo=20que=20DEC=C3=8DA,=20no=20por=20las=20palabras?= =?UTF-8?q?=20exactas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El muro no era un daemon ni un LLM — y el propio comentario de la extensión lo decía mal, copiando lo que hace willay-rag. Medido: para ordenar por parecido no hace falta ningún LLM (eso es un coseno) y el «daemon de embeddings» resultó ser el mismo llama-server que ya levanta el chat, con otro modelo. La corrección quedó escrita donde estaba la afirmación. Del lado de la suite (tawasuyu 22527f7d1 y b4ecffea8): el protocolo de llama-server salió a `shared/foreign-llama` (regla 4 — vivía dentro de puriy-costura y ya tenía dos consumidores), el `Provider` es `rimay-verbo-llama` (regla 10 — la familia verbo YA es la abstracción de embeddings, y éste es su primer backend sin nube ni descargas), y el índice es `rimay-verbo-index::VectorIndex`. Acá: la receta del modelo (multilingual-e5-small, MIT), que se pinea en fp32 y la receta CUANTIZA a Q8_0 con nuestro llama-quantize — así la procedencia es de quien declara la licencia, la imagen se lleva 126 MB en vez de 476, y la transformación es nuestra y verificable. Más el guardián y la extensión, que ahora expone `archive.ask` al lado del `archive.search` literal: son dos preguntas distintas y conviven. ⚠ El guardián está escrito pero NO corrió todavía: `ia-modelo-embeddings` quedó en la cola del flock detrás del build de otro agente. Lo medido hasta acá es el modelo a mano (384 dimensiones, el pasaje correcto gana con y sin los prefijos de e5) y 45 tests en tawasuyu. La línea del SDD que lo dice se borra cuando dé verde. --- docs/26-atuq-envoltorio-gecko.md | 48 +++++ recipes/atuq/extensions/archivo/fondo.js | 25 ++- recipes/ia-modelo-embeddings.toml | 76 +++++++ recipes/puriy-costura.toml | 2 +- scripts/test-atuq-archivo-semantico.py | 242 +++++++++++++++++++++++ 5 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 recipes/ia-modelo-embeddings.toml create mode 100644 scripts/test-atuq-archivo-semantico.py diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md index a4488b33..6eefb9ac 100644 --- a/docs/26-atuq-envoltorio-gecko.md +++ b/docs/26-atuq-envoltorio-gecko.md @@ -674,6 +674,54 @@ archiva lo privado» sin saber cuál de las dos cosas estaba pasando. por byte, porque `truncate` sobre medio multibyte entra en pánico y en un archivo personal el texto con acentos es el caso normal. +#### 6.3.ter La otra mitad: preguntarle al archivo por lo que DECÍA (2026-09-12) + +Hasta acá el archivo se buscaba **literal**: todas las palabras tienen que aparecer. Ahora también +se le puede preguntar por el significado (`archive.ask`), y las dos conviven porque son dos +preguntas: «la página que decía `EADDRINUSE`» es literal y no necesita ningún modelo. + +**El muro no era un daemon ni un LLM, y el propio código lo decía mal.** El comentario de la +extensión afirmaba que el semántico «necesita un daemon de embeddings y un LLM de verdad», copiando +lo que hace `willay-rag`. Medido: para ordenar por parecido **no hace falta ningún LLM** —eso es un +coseno— y el «daemon de embeddings» resultó ser el mismo `llama-server` que ya levanta el chat, con +otro modelo. La corrección quedó escrita donde estaba la afirmación. + +**Lo que se construyó, y dónde vive cada cosa** — que es la parte que las reglas del repo deciden, +no el gusto: + +| pieza | dónde | por qué ahí | +|---|---|---| +| el protocolo de `llama-server` | `shared/foreign-llama` | **regla 4**: un dialecto ajeno entra por un puente. La primera versión vivía DENTRO de `puriy-costura::ia`, y cuando apareció el segundo consumidor —dos horas después— había dos clientes del mismo protocolo | +| el `Provider` de embeddings | `rimay-verbo-llama` | **regla 10**: la familia `rimay-verbo-*` ya es la abstracción de embeddings de la suite. Es el primer backend suyo que no pide nube ni descarga nada al arrancar | +| el índice y el coseno | `rimay-verbo-index::VectorIndex` | ya existían, con postcard y top-K. Escribir otro sería un segundo espacio vectorial sin dueño | +| cuándo se embebe y qué se pregunta | `puriy-costura` | es lo nuestro | + +**Tres decisiones con su porqué:** + +- **los vectores se calculan al PREGUNTAR, no al archivar.** Archivar una página no puede quedarse + esperando a que cargue un modelo, ni fallar porque la imagen no lo traiga. El precio es que la + primera pregunta paga el atraso, y por eso la respuesta dice cuántas quedaron indexadas; +- **es un SEGUNDO `llama-server`** (`--embedding --pooling mean`): llama.cpp carga un modelo por + proceso y el de chat no sabe embeber. Ese `--pooling` no es un gusto — sin él el endpoint contesta + un `400` que parece del cliente; +- **sin umbral de puntaje.** Los cosenos de e5 dan ~0,9 entre frases sin relación: sus vectores viven + en un cono estrecho, así que cualquier umbral «razonable» deja pasar todo o corta todo. Lo que + significa algo es el **orden**, y de eso se ocupa el top-K. + +El guardián (`scripts/test-atuq-archivo-semantico.py`) archiva tres páginas con el navegador y +después le pregunta al host **sin navegador**, por el cable de 4 bytes y con los paths de producción +— así se comprueba de paso lo que el §6.3 promete: que el archivo quedó en disco. Pregunta **dos** +cosas con ganadores distintos, a propósito: con una sola, un ranking constante —o uno que devuelva +siempre la más reciente— pasaría sin haber entendido nada. Y las preguntas **no comparten palabras** +con el texto de su página; si las compartieran, la búsqueda literal también acertaría y esto no +probaría nada semántico. + +⚠ **Al cerrar esta entrada el guardián está escrito pero NO ha corrido todavía** contra el artefacto +sellado: `ia-modelo-embeddings` estaba en la cola del `flock` detrás del build de otro agente. Lo que +sí está medido es todo lo de abajo del cable —35 tests de `puriy-costura`, 6 del puente, 4 del +proveedor— y el modelo mismo, probado a mano antes de pinearlo. Esta línea se borra cuando el +guardián dé verde. + ### 6.4 `qullqa` con su advertencia puesta Almacenamiento con negación plausible y modelo de adversario **escrito** — ningún navegador tiene diff --git a/recipes/atuq/extensions/archivo/fondo.js b/recipes/atuq/extensions/archivo/fondo.js index 06d6fe23..7263fb29 100644 --- a/recipes/atuq/extensions/archivo/fondo.js +++ b/recipes/atuq/extensions/archivo/fondo.js @@ -15,10 +15,18 @@ // Y no se archiva lo que no tiene texto visible: guardar el HTML de un visor de PDF // llena el archivo de cosas que después no se encuentran. // -// ⚠ LO QUE ESTO NO ES: preguntarle al historial en lenguaje natural. `archive.search` -// es el registro LITERAL —todas las palabras tienen que aparecer—. El semántico es un -// motor `rag-motor::RagMotor` como `willay-rag`, que necesita daemon de embeddings y -// un LLM de verdad. Cuando exista, se enchufa del mismo lado y esto no cambia. +// ── LAS DOS BÚSQUEDAS, Y POR QUÉ SON DOS ───────────────────────────────────── +// `archive.search` es el registro LITERAL: todas las palabras tienen que aparecer, no +// necesita modelo y contesta al instante. `archive.ask` (§6.3, desde 2026-09-12) ordena +// por PARECIDO: embebe el archivo con el motor local y devuelve las páginas con su +// puntaje. Son dos preguntas distintas —«la página que decía EADDRINUSE» es literal— y +// por eso conviven. +// +// ⚠ Este comentario decía que el semántico «necesita un daemon de embeddings y un LLM +// de verdad», copiando lo que hacía `willay-rag`. Era falso para este caso: no hace +// falta ningún LLM para ordenar por parecido —eso es coseno— y el daemon resultó ser el +// mismo `llama-server` que ya levanta el chat, con otro modelo. Quedó escrito acá el día +// que se midió. const HOST = "puriy_costura"; @@ -71,6 +79,13 @@ function alRecibir(r) { for (const p of r.pages) { di("HALLADA " + p.hash.slice(0, 12) + " " + p.url + " — " + p.title); } + } else if (r.verb === "archive.ask" && r.matches) { + di("PREGUNTA " + r.matches.length + " de " + r.archived + " (indexadas " + r.indexed + ")"); + for (const m of r.matches) { + // El puntaje se muestra con el ORDEN, nunca como un porcentaje de acierto: los + // cosenos de este modelo son ~0,9 entre frases sin relación. + di("COINCIDENCIA " + m.score.toFixed(4) + " " + m.page.url + " — " + m.page.title); + } } } @@ -110,6 +125,8 @@ browser.runtime.onMessage.addListener(async (msg, emisor) => { browser.runtime.onMessageExternal.addListener(msg => { if (msg && msg.tipo === "buscar") { enviar({ verb: "archive.search", query: msg.query }); + } else if (msg && msg.tipo === "preguntar") { + enviar({ verb: "archive.ask", query: msg.query }); } }); diff --git a/recipes/ia-modelo-embeddings.toml b/recipes/ia-modelo-embeddings.toml new file mode 100644 index 00000000..1b5ac7da --- /dev/null +++ b/recipes/ia-modelo-embeddings.toml @@ -0,0 +1,76 @@ +# ia-modelo-embeddings — el modelo con el que se le pregunta al archivo personal en lenguaje natural +# (SDD 26 §6.3, mitad semántica). +# +# ══ QUÉ ES, Y POR QUÉ ÉSTE ═════════════════════════════════════════════════════════════════════ +# **multilingual-e5-small**: 384 dimensiones, 118 M de parámetros. Elegido por una razón que se +# puede medir: **el archivo personal va a estar en español**, y un modelo de embeddings entrenado +# sólo en inglés buscaría peor justo donde más hay. Licencia MIT (del modelo base, `intfloat`). +# +# ══ POR QUÉ LA RECETA CUANTIZA EN VEZ DE PINEAR EL GGUF YA CUANTIZADO ══════════════════════════ +# La fuente pineada es el **fp32 (476 MB)** del único repo que declara licencia (MIT) y la receta lo +# baja a **Q8_0 (126 MB)** con NUESTRO `llama-quantize` —el del corpus—. Así: +# · la procedencia es de quien declara la licencia, no de un repo cualquiera con un q8 subido; +# · la imagen se lleva 126 MB y no 476; +# · y la cuantización es una transformación DETERMINISTA nuestra, verificable con +# `verificar-repro.sh`, en vez de bytes de un tercero que nadie puede reproducir. +# +# ⚠ EL MODELO EXIGE PREFIJOS, Y NO ES UN DETALLE DE ESTILO. e5 se entrenó con `query: ` en la +# consulta y `passage: ` en el documento. Medido con este mismo artefacto contra tres pasajes y una +# pregunta: el pasaje correcto gana en los dos casos, pero con prefijos la distancia al segundo se +# abre (0,9371 vs 0,9212 con prefijos; 0,9182 vs 0,8976 sin ellos). Los pone el consumidor +# (`rimay-verbo-llama`), no esta receta, pero queda escrito acá porque el que pinea el modelo es el +# que sabe qué convención tiene. +# +# ⚠ Y LOS COSENOS SON ALTOS EN ABSOLUTO (~0,9 entre frases sin relación). Es normal en e5: sus +# vectores viven en un cono estrecho. Quien ponga un umbral absoluto («>0,8 es relevante») va a +# aceptar todo; lo que vale es el ORDEN. +# +# ══ EL TARBALL Y LA PROCEDENCIA ════════════════════════════════════════════════════════════════ +# takana extrae toda fuente con `tar` y un `.gguf` pelado no es un tar ⇒ el objeto pineado es un tar +# que lo envuelve, publicado en el mirror y servido por sha256 (ADR 0013: la URL no entra en +# `hash_inputs`). Igual que `ia-modelo-chat` y `firefox-pgo-profile`. +# +# upstream https://huggingface.co/rodion-m/multilingual-e5-small-gguf (licencia MIT declarada) +# fichero multilingual-e5-small-fp32.gguf +# sha256 b3bf20a4ba914b630be1941dc3f5136cc99871d8fa2b402564a3b8752cf010c2 ← del GGUF, no del tar +# modelo base https://huggingface.co/intfloat/multilingual-e5-small (MIT) +name = "ia-modelo-embeddings" +version = "multilingual-e5-small-q8_0" +license = "MIT" + +[source] +tarball = "https://no-hay-upstream.invalid/takana/ia-modelo-embeddings.tar" +sha256 = "886098319440f73a79aea2b3ced5a2e17a45f7d65792f6794ffa0c333583e696" +strip_components = 0 + +[build] +compiler = "zig-cc" + +[build.phases] +configure = "true" +compile = "true" +install = ''' +set -e +mkdir -p /out/usr/share/takana/ia +llama-quantize multilingual-e5-small-fp32.gguf /out/usr/share/takana/ia/embeddings.gguf Q8_0 + +M=/out/usr/share/takana/ia/embeddings.gguf + +# ── GUARDIÁN: QUE SEA UN GGUF CUANTIZADO, NO UN FICHERO ───────────────────────────────────── +# Dos modos de fallo callados: que `llama-quantize` escriba algo que no es un GGUF, y que escriba el +# fp32 tal cual (o sea que la cuantización no haya pasado). Se comprueban el mágico y la BANDA de +# tamaño: un Q8_0 de 118 M de parámetros pesa ~126 MB, así que 80–200 MB deja fuera tanto el +# truncado como el fp32 de 476 MB. +head -c 4 "$M" | grep -q GGUF || { + echo "!! lo instalado no empieza con el mágico GGUF" >&2 + head -c 64 "$M" | od -c | head -3 >&2 + exit 1 +} +n=$(stat -c%s "$M") +test "$n" -gt 80000000 || { echo "!! el GGUF pesa $n bytes: está truncado" >&2; exit 1; } +test "$n" -lt 200000000 || { echo "!! el GGUF pesa $n bytes: parece el fp32 sin cuantizar" >&2; exit 1; } +echo "guardián: GGUF Q8_0 válido — $n bytes" +''' + +[deps] +build = ["llama-cpp"] diff --git a/recipes/puriy-costura.toml b/recipes/puriy-costura.toml index 89ed036c..de72a7a5 100644 --- a/recipes/puriy-costura.toml +++ b/recipes/puriy-costura.toml @@ -43,7 +43,7 @@ license = "MIT OR Apache-2.0" [source] repo = "gitea@git.tawasuyu.net:tawasuyu/tawasuyu.git" -commit = "fea0e89036fda8dcdd61191f3953a50e93b1e6a6" +commit = "b4ecffea828a7514e7d397253715c83c1b9b5255" # ⚠ SIN ESTO EL BUILD MUERE EN UN SITIO QUE NO NOMBRA LA CAUSA — medido acá el 2026-09-10. # tawasuyu COMMITEA su propio `vendor/`: adentro tiene un `smithay` parcheado a mano (tearing/flip # async para el compositor), y lo enchufa con `[patch.crates-io] smithay = { path = "vendor/smithay" }`. diff --git a/scripts/test-atuq-archivo-semantico.py b/scripts/test-atuq-archivo-semantico.py new file mode 100644 index 00000000..b108d8bb --- /dev/null +++ b/scripts/test-atuq-archivo-semantico.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""¿Se le puede preguntar al archivo personal por lo que DECÍA? (SDD 26 §6.3, mitad semántica) + + python3 scripts/test-atuq-archivo-semantico.py # con el modelo de la imagen + python3 scripts/test-atuq-archivo-semantico.py --negative-control # sin modelo: lo DICE + +── QUÉ SE EJERCITA ─────────────────────────────────────────────────────────────────────────── +El navegador archiva tres páginas de temas distintos —eso es el §6.3 literal, que ya existía— y +después se le pregunta al host **desde dentro de la jaula y por el cable de verdad** (marco de 4 +bytes), con los paths de PRODUCCIÓN. La respuesta tiene que ordenar por parecido: la página del +tema preguntado primero. + +Y se pregunta DOS cosas distintas, que ganan páginas distintas. Con una sola pregunta, un ranking +constante —o uno que siempre devuelve la más reciente— pasaría el test sin haber entendido nada. + +── POR QUÉ EL HOST SE INVOCA A MANO Y NO POR LA EXTENSIÓN ──────────────────────────────────── +Porque así se comprueba lo que de verdad importa del archivo: **que quedó en disco y se consulta +sin el navegador**. Es el mismo camino que usa `scripts/test-atuq-archivo.py` para la búsqueda +literal. + +REQUISITOS: rootfs de `atuq-nested.sh`, y `atuq` + `puriy-costura` + `llama-cpp` + +`ia-modelo-embeddings` sellados. +""" +import http.server +import json +import os +import shutil +import struct +import subprocess +import sys +import tempfile +import threading + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs") +ALPINE = os.path.join(ROOT, ".dev-fs/alpine") +HAMMER = os.path.join(ROOT, "target/release/takana") +STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store")) +MODELO_EN_IMAGEN = "/usr/share/takana/ia/embeddings.gguf" + +# Tres páginas de temas bien separados, y dos preguntas que NO comparten palabras con el texto de +# su página: si compartieran, la literal también acertaría y esto no probaría nada semántico. +PAGINAS = { + "/gato.html": ("El felino en el tejado", + "Un minino atigrado dormía sobre las tejas calientes toda la siesta."), + "/red.html": ("Cerrar la salida de una aplicación", + "Reglas de egress por cgroup, denegar por defecto y permitir sólo lo declarado."), + "/pan.html": ("Masa madre", + "Harina, agua y sal; fermentación lenta durante la noche y horno muy caliente."), +} +PREGUNTAS = [ + ("¿dónde se echó a dormir el gato?", "/gato.html"), + ("cómo bloqueo internet a un programa", "/red.html"), +] + + +def fatal(msg): + print(f"✗ {msg}", file=sys.stderr) + raise SystemExit(1) + + +def artefacto(receta, nombre, escotilla=None): + forzado = os.environ.get(escotilla) if escotilla else None + if forzado: + if not os.path.isdir(forzado): + fatal(f"{escotilla}={forzado} no existe") + print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente") + return forzado + r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"], + cwd=ROOT, capture_output=True, text=True) + if r.returncode != 0: + fatal(f"no pude hashear {receta}: {r.stderr.strip()}") + h = r.stdout.strip().splitlines()[-1].removeprefix("b3:") + d = os.path.join(STORE, f"{h}-{nombre}") + if not os.path.isdir(d): + fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes") + if not os.listdir(d): + fatal(f"{nombre} b3:{h[:8]} está VACÍO: eso no es un artefacto, es un nombre") + return d + + +class Servidor(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + ruta = self.path.split("?")[0] + if ruta in PAGINAS: + titulo, texto = PAGINAS[ruta] + datos = (f'{titulo}' + f"

{titulo}

{texto}

").encode() + elif ruta == "/ir.html": + # Una página que pasea por las tres: el guión de contenido archiva cada una al cargar. + saltos = "".join(f'' for r in PAGINAS) + datos = (f'{saltos}' + "").encode() + else: + datos = b"nada" + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(datos))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(datos) + + +def puerto_libre(): + import socket + s = socket.socket() + s.bind(("127.0.0.1", 0)) + p = s.getsockname()[1] + s.close() + return p + + +def marco(obj): + """El cable de native messaging: 4 bytes little-endian y el cuerpo.""" + cuerpo = json.dumps(obj).encode() + return struct.pack(" /etc/machine-id + export HOME=/salida/hogar + {borrar} + timeout 90 /usr/bin/atuq --profile /salida/perfil --no-remote --headless \ + 'http://127.0.0.1:{puerto}/ir.html' > /salida/consola.log 2>&1 + # Y ahora, SIN navegador: el archivo quedó en disco y se le pregunta por el cable. + E=/salida/hogar/.local/state/puriy-costura + for i in 0 1; do + /usr/bin/puriy-costura --state $E < /salida/pregunta-$i.bin > /salida/respuesta-$i.bin 2>/salida/err-$i.txt + done + exit 0 + """ + capas = [atuq, costura, llama] + ([] if negativo else [modelo]) + # En el control negativo el modelo NO se monta: `rm -f` sobre una capa de sólo lectura + # borraría el fichero de la capa temporal, pero montar y borrar es contar dos veces. + correr(capas if not negativo else [atuq, costura, llama], salida, guion) + + log = open(os.path.join(salida, "consola.log"), errors="replace").read() + lineas = [l[len("ARCHIVO "):] for l in log.splitlines() if l.startswith("ARCHIVO ")] + archivadas = [l for l in lineas if l.startswith("ARCHIVADA ")] + for l in archivadas: + print(" " + l) + if len(archivadas) < 3: + destino = os.path.join(ROOT, "work", "atuq-archivo-sem.log") + os.makedirs(os.path.dirname(destino), exist_ok=True) + shutil.copy(os.path.join(salida, "consola.log"), destino) + fatal(f"se archivaron {len(archivadas)} páginas de 3 — log en {destino}") + + for i, (q, esperada) in enumerate(PREGUNTAS): + crudo = open(os.path.join(salida, f"respuesta-{i}.bin"), "rb").read() + if len(crudo) < 4: + err = open(os.path.join(salida, f"err-{i}.txt"), errors="replace").read() + fatal(f"el host no contestó a «{q}»: {err.strip()[:300]}") + n = struct.unpack("