gioser

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…

TOML 1465Shell 173Rust 96Markdown 943319 Definiciones

⎇ 0ca88fdcd5e0bafe5b5e3444c2c568c88424128c scripts/test-atuq-archivo-semantico.py Python · 242 líneas · 11.5 KB Historia Quién tocó qué crudo

atuq §6.3.ter: preguntarle al archivo por lo que DECÍA, no por las palabras exactas 0ca88fdcd · Sergio ·

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())