sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ 0ca88fdcd5e0bafe5b5e3444c2c568c88424128c
scripts/test-atuq-archivo-semantico.py
Historia
Quién tocó qué
crudo
atuq §6.3.ter: preguntarle al archivo por lo que DECÍA, no por las palabras exactas
1#!/usr/bin/env python3
2"""¿Se le puede preguntar al archivo personal por lo que DECÍA? (SDD 26 §6.3, mitad semántica)
3
4 python3 scripts/test-atuq-archivo-semantico.py # con el modelo de la imagen
5 python3 scripts/test-atuq-archivo-semantico.py --negative-control # sin modelo: lo DICE
6
7── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
8El navegador archiva tres páginas de temas distintos —eso es el §6.3 literal, que ya existía— y
9después se le pregunta al host **desde dentro de la jaula y por el cable de verdad** (marco de 4
10bytes), con los paths de PRODUCCIÓN. La respuesta tiene que ordenar por parecido: la página del
11tema preguntado primero.
12
13Y se pregunta DOS cosas distintas, que ganan páginas distintas. Con una sola pregunta, un ranking
14constante —o uno que siempre devuelve la más reciente— pasaría el test sin haber entendido nada.
15
16── POR QUÉ EL HOST SE INVOCA A MANO Y NO POR LA EXTENSIÓN ────────────────────────────────────
17Porque así se comprueba lo que de verdad importa del archivo: **que quedó en disco y se consulta
18sin el navegador**. Es el mismo camino que usa `scripts/test-atuq-archivo.py` para la búsqueda
19literal.
20
21REQUISITOS: rootfs de `atuq-nested.sh`, y `atuq` + `puriy-costura` + `llama-cpp` +
22`ia-modelo-embeddings` sellados.
23"""
24import http.server
25import json
26import os
27import shutil
28import struct
29import subprocess
30import sys
31import tempfile
32import threading
33
34ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
35RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
36ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
37HAMMER = os.path.join(ROOT, "target/release/takana")
38STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
39MODELO_EN_IMAGEN = "/usr/share/takana/ia/embeddings.gguf"
40
41# Tres páginas de temas bien separados, y dos preguntas que NO comparten palabras con el texto de
42# su página: si compartieran, la literal también acertaría y esto no probaría nada semántico.
43PAGINAS = {
44 "/gato.html": ("El felino en el tejado",
45 "Un minino atigrado dormía sobre las tejas calientes toda la siesta."),
46 "/red.html": ("Cerrar la salida de una aplicación",
47 "Reglas de egress por cgroup, denegar por defecto y permitir sólo lo declarado."),
48 "/pan.html": ("Masa madre",
49 "Harina, agua y sal; fermentación lenta durante la noche y horno muy caliente."),
50}
51PREGUNTAS = [
52 ("¿dónde se echó a dormir el gato?", "/gato.html"),
53 ("cómo bloqueo internet a un programa", "/red.html"),
54]
55
56
57def fatal(msg):
58 print(f"✗ {msg}", file=sys.stderr)
59 raise SystemExit(1)
60
61
62def artefacto(receta, nombre, escotilla=None):
63 forzado = os.environ.get(escotilla) if escotilla else None
64 if forzado:
65 if not os.path.isdir(forzado):
66 fatal(f"{escotilla}={forzado} no existe")
67 print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
68 return forzado
69 r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
70 cwd=ROOT, capture_output=True, text=True)
71 if r.returncode != 0:
72 fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
73 h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
74 d = os.path.join(STORE, f"{h}-{nombre}")
75 if not os.path.isdir(d):
76 fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
77 if not os.listdir(d):
78 fatal(f"{nombre} b3:{h[:8]} está VACÍO: eso no es un artefacto, es un nombre")
79 return d
80
81
82class Servidor(http.server.SimpleHTTPRequestHandler):
83 def log_message(self, *a):
84 pass
85
86 def do_GET(self):
87 ruta = self.path.split("?")[0]
88 if ruta in PAGINAS:
89 titulo, texto = PAGINAS[ruta]
90 datos = (f'<!doctype html><html><head><meta charset="utf-8"><title>{titulo}</title>'
91 f"</head><body><h1>{titulo}</h1><p>{texto}</p></body></html>").encode()
92 elif ruta == "/ir.html":
93 # Una página que pasea por las tres: el guión de contenido archiva cada una al cargar.
94 saltos = "".join(f'<iframe src="{r}" width="10" height="10"></iframe>' for r in PAGINAS)
95 datos = (f'<!doctype html><html><head><meta charset="utf-8"></head><body>{saltos}'
96 "</body></html>").encode()
97 else:
98 datos = b"<html><body>nada</body></html>"
99 self.send_response(200)
100 self.send_header("Content-Type", "text/html; charset=utf-8")
101 self.send_header("Content-Length", str(len(datos)))
102 self.send_header("Cache-Control", "no-store")
103 self.end_headers()
104 self.wfile.write(datos)
105
106
107def puerto_libre():
108 import socket
109 s = socket.socket()
110 s.bind(("127.0.0.1", 0))
111 p = s.getsockname()[1]
112 s.close()
113 return p
114
115
116def marco(obj):
117 """El cable de native messaging: 4 bytes little-endian y el cuerpo."""
118 cuerpo = json.dumps(obj).encode()
119 return struct.pack("<I", len(cuerpo)) + cuerpo
120
121
122def correr(capas, salida, guion):
123 subprocess.run([
124 "bwrap",
125 "--overlay-src", ALPINE, "--overlay-src", RFS, *sum((["--overlay-src", c] for c in capas), []),
126 "--tmp-overlay", "/",
127 "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
128 "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
129 "--bind", salida, "/salida",
130 "--uid", "0", "--gid", "0",
131 "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
132 "--setenv", "XDG_RUNTIME_DIR", "/run",
133 "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
134 "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
135 "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
136 "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
137 "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
138 "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
139 "sh", "-c", guion,
140 ], check=False)
141
142
143def main():
144 negativo = "--negative-control" in sys.argv[1:]
145 if negativo:
146 print("== MODO CONTROL NEGATIVO: sin modelo de embeddings; tiene que DECIRLO")
147 if not os.path.isdir(RFS):
148 fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`")
149 atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
150 costura = artefacto("puriy-costura", "puriy-costura")
151 llama = artefacto("llama-cpp", "llama-cpp", "LLAMA_DIR")
152 modelo = artefacto("ia-modelo-embeddings", "ia-modelo-embeddings")
153 print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}"
154 f"\n {os.path.basename(llama)}\n {os.path.basename(modelo)}")
155
156 puerto = puerto_libre()
157 servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor)
158 threading.Thread(target=servidor.serve_forever, daemon=True).start()
159
160 tmp = tempfile.mkdtemp(prefix="atuq-archivo-sem-")
161 salida = os.path.join(tmp, "salida")
162 os.makedirs(os.path.join(salida, "perfil"))
163 try:
164 with open(os.path.join(salida, "perfil", "user.js"), "w") as fh:
165 fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
166 fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
167
168 # Las preguntas se escriben ya enmarcadas: así el guión de adentro sólo tiene que
169 # volcarlas al binario, sin hacer aritmética de bytes en `sh`.
170 for i, (q, _) in enumerate(PREGUNTAS):
171 with open(os.path.join(salida, f"pregunta-{i}.bin"), "wb") as fh:
172 fh.write(marco({"verb": "archive.ask", "query": q}))
173
174 borrar = f"rm -f {MODELO_EN_IMAGEN}" if negativo else "true"
175 guion = f"""
176 mkdir -p /root /salida/hogar
177 [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
178 export HOME=/salida/hogar
179 {borrar}
180 timeout 90 /usr/bin/atuq --profile /salida/perfil --no-remote --headless \
181 'http://127.0.0.1:{puerto}/ir.html' > /salida/consola.log 2>&1
182 # Y ahora, SIN navegador: el archivo quedó en disco y se le pregunta por el cable.
183 E=/salida/hogar/.local/state/puriy-costura
184 for i in 0 1; do
185 /usr/bin/puriy-costura --state $E < /salida/pregunta-$i.bin > /salida/respuesta-$i.bin 2>/salida/err-$i.txt
186 done
187 exit 0
188 """
189 capas = [atuq, costura, llama] + ([] if negativo else [modelo])
190 # En el control negativo el modelo NO se monta: `rm -f` sobre una capa de sólo lectura
191 # borraría el fichero de la capa temporal, pero montar y borrar es contar dos veces.
192 correr(capas if not negativo else [atuq, costura, llama], salida, guion)
193
194 log = open(os.path.join(salida, "consola.log"), errors="replace").read()
195 lineas = [l[len("ARCHIVO "):] for l in log.splitlines() if l.startswith("ARCHIVO ")]
196 archivadas = [l for l in lineas if l.startswith("ARCHIVADA ")]
197 for l in archivadas:
198 print(" " + l)
199 if len(archivadas) < 3:
200 destino = os.path.join(ROOT, "work", "atuq-archivo-sem.log")
201 os.makedirs(os.path.dirname(destino), exist_ok=True)
202 shutil.copy(os.path.join(salida, "consola.log"), destino)
203 fatal(f"se archivaron {len(archivadas)} páginas de 3 — log en {destino}")
204
205 for i, (q, esperada) in enumerate(PREGUNTAS):
206 crudo = open(os.path.join(salida, f"respuesta-{i}.bin"), "rb").read()
207 if len(crudo) < 4:
208 err = open(os.path.join(salida, f"err-{i}.txt"), errors="replace").read()
209 fatal(f"el host no contestó a «{q}»: {err.strip()[:300]}")
210 n = struct.unpack("<I", crudo[:4])[0]
211 r = json.loads(crudo[4:4 + n])
212 if negativo:
213 if r.get("ok"):
214 fatal("sin modelo CONTESTÓ igual: eso es inventar un orden")
215 if "no hay modelo de embeddings" not in (r.get("error") or ""):
216 fatal(f"la causa no nombra lo que falta: {r.get('error')}")
217 print(f"\n RECHAZO {r['error']}")
218 print("\n✓ control negativo: sin modelo lo dice, nombrando lo que falta")
219 print("✓ y no inventa un orden, que es el único fallo inaceptable en una búsqueda")
220 return 0
221 if not r.get("ok"):
222 fatal(f"la pregunta «{q}» falló: {r.get('error')}")
223 m = r.get("matches") or []
224 if not m:
225 fatal(f"«{q}» no devolvió ninguna coincidencia sobre {r.get('archived')} páginas")
226 print(f"\n «{q}» → indexadas {r['indexed']}/{r['archived']}")
227 for c in m:
228 print(f" {c['score']:.4f} {c['page']['title']}")
229 if esperada not in m[0]["page"]["url"]:
230 fatal(f"«{q}» puso primera a {m[0]['page']['url']} y esperaba {esperada}"
231 " — el orden no sigue al SIGNIFICADO")
232
233 print("\n✓ el archivo contesta por PARECIDO: cada pregunta pone primera a su página")
234 print("✓ y son dos preguntas con ganadores distintos — un ranking constante no pasaría")
235 print("✓ todo sin navegador: el archivo quedó en disco y se consulta por el cable")
236 return 0
237 finally:
238 servidor.shutdown()
239 shutil.rmtree(tmp, ignore_errors=True)
240
241
242sys.exit(main())