tawasuyu

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 1569Shell 203Rust 109Markdown 993728 Definiciones

⎇ c16db9a212fe4949bdce5374de96ed1ceffa47da scripts/test-llama-cpp.py Python · 263 líneas · 12.7 KB Historia Quién tocó qué crudo

atuq §6.7: entra el motor de inferencia local — y SOURCE_DATE_EPOCH le había apagado el AVX2 4190c7b43 · Sergio ·

1#!/usr/bin/env python3
2"""Guardián de `recipes/llama-cpp.toml` — el motor de inferencia local del §6.7 del SDD 26.
3
4Mide CUATRO afirmaciones sobre el ARTEFACTO VIGENTE, y ninguna se deduce de que el build haya
5salido 0:
6
7  1. ISA        el binario trae AVX2/FMA/SSE4.2 de verdad.
8  2. hermético  estáticamente enlazado, sin un solo NEEDED.
9  3. inferencia una pasada real contra un modelo pineado por sha256 ⇒ salen tokens.
10  4. embeddings `llama-server --embedding` + `POST /v1/embeddings` ⇒ sale un vector, y además:
11                  · el MISMO texto dos veces da el MISMO vector (si no, no hay nada determinista),
12                  · dos textos DISTINTOS dan vectores DISTINTOS (si no, es un stub de ceros).
13                El punto 4 es el que le importa al §6.3: la mitad semántica del archivo personal
14                se apoya EXACTAMENTE en ese endpoint.
15
16⚠ EL CONTROL NEGATIVO DEL PUNTO 1 NO ES HIPOTÉTICO, ESTÁ MEDIDO. El PRIMER sello de esta receta
17(`b3:e3c62185`) llevaba sólo `-DGGML_NATIVE=OFF`, selló, corrió, contestó `--version` — y tenía
18**cero** `%ymm`, **cero** `vfmadd` y **cero** `roundps` en 1.634.770 líneas de `objdump -d`. La causa
19está en la cabecera de la receta: el sandbox exporta `SOURCE_DATE_EPOCH`, ggml lee esa variable para
20decidir `GGML_NATIVE_DEFAULT` y termina apagando `INS_ENB`. O sea: la variable que existe para que
21el build REPRODUZCA apagaba todas las instrucciones vectoriales del motor, sin un aviso.
22
23El control negativo VIVO es `--sin-modelo`: las mismas sondas contra un modelo que no existe tienen
24que FALLAR. Sin eso, un arnés roto («no contestó») se lee igual que un motor que anda.
25
26El modelo de prueba es `stories260K` (1,1 MB, 260 mil parámetros — el mismo que usa el CI de
27llama.cpp). Se pinea por **sha256**, que es la identidad; la URL no lo es (ADR 0013) y por eso se
28puede servir desde donde sea. No es un modelo útil: es un modelo REAL, que es lo que hace falta para
29ejercitar la cadena entera. El modelo de producción es su propia unidad de trabajo.
30
31    scripts/test-llama-cpp.py
32    scripts/test-llama-cpp.py --sin-modelo     # control negativo: TIENE que fallar
33"""
34import argparse
35import hashlib
36import json
37import os
38import re
39import subprocess
40import sys
41import time
42import urllib.request
43from pathlib import Path
44
45RAIZ = Path(__file__).resolve().parent.parent
46RECETA = RAIZ / "recipes/llama-cpp.toml"
47TAKANA = RAIZ / "target/release/takana"
48
49MODELO_SHA = "270cba1bd5109f42d03350f60406024560464db173c0e387d91f0426d3bd256d"
50MODELO_URL = "https://huggingface.co/ggml-org/models/resolve/main/tinyllamas/stories260K.gguf"
51MODELO_CACHE = RAIZ / "work/llama-modelos" / f"{MODELO_SHA[:16]}-stories260K.gguf"
52
53fallos = []
54
55
56def ok(msg):
57    print(f"  \033[32m✓\033[0m {msg}")
58
59
60def mal(msg):
61    print(f"  \033[31m✗\033[0m {msg}")
62    fallos.append(msg)
63
64
65def artefacto():
66    """El artefacto VIGENTE, resuelto por `takana hash` — nunca por `ls store/*`.
67
68    Un `ls` agarra el primero que empiece igual, y el corpus es compartido: el de al lado puede ser
69    de un hash viejo que ya nadie construye. Pasó con `gmp` en el guardián del foco, y el síntoma
70    fue una librería que no relocaba.
71    """
72    if os.environ.get("LLAMA_DIR"):
73        d = Path(os.environ["LLAMA_DIR"])
74        print(f"\033[33m⚠ LLAMA_DIR: corriendo contra {d} A SABIENDAS — el resultado NO se puede\033[0m")
75        print("\033[33m  citar como «el llama-cpp de hoy pasa».\033[0m")
76        return d
77    h = subprocess.run([str(TAKANA), "--store", str(RAIZ / "store"), "hash", str(RECETA)],
78                       capture_output=True, text=True, cwd=RAIZ)
79    if h.returncode != 0:
80        sys.exit(f"no se pudo hashear la receta: {h.stderr.strip()}")
81    d = RAIZ / "store" / f"{h.stdout.strip().removeprefix('b3:')}-llama-cpp"
82    if not d.is_dir():
83        sys.exit(f"el artefacto vigente NO está en el store: {d.name}\n"
84                 f"  construilo:  flock -o work/.farm-build.lock ./target/release/takana "
85                 f"--store ./store build recipes/llama-cpp.toml")
86    return d
87
88
89def bajar_modelo():
90    if MODELO_CACHE.is_file():
91        got = hashlib.sha256(MODELO_CACHE.read_bytes()).hexdigest()
92        if got == MODELO_SHA:
93            return MODELO_CACHE
94        MODELO_CACHE.unlink()
95    MODELO_CACHE.parent.mkdir(parents=True, exist_ok=True)
96    print(f"  · bajando el modelo de prueba ({MODELO_URL.rsplit('/', 1)[-1]}, 1,1 MB)…")
97    tmp = MODELO_CACHE.with_suffix(".incoming")
98    with urllib.request.urlopen(MODELO_URL, timeout=120) as r, open(tmp, "wb") as f:
99        f.write(r.read())
100    got = hashlib.sha256(tmp.read_bytes()).hexdigest()
101    if got != MODELO_SHA:
102        tmp.unlink()
103        sys.exit(f"el modelo bajado NO es el pineado:\n  esperado {MODELO_SHA}\n  obtenido {got}")
104    tmp.rename(MODELO_CACHE)
105    return MODELO_CACHE
106
107
108# ── 1. ISA ────────────────────────────────────────────────────────────────────────────────────
109def probar_isa(art):
110    print("\n\033[1m1. ISA — ¿el motor trae instrucciones vectoriales?\033[0m")
111    lib = art / "usr/lib/libggml-cpu.a"
112    if not lib.is_file():
113        return mal("no hay libggml-cpu.a en el artefacto")
114    d = subprocess.run(["objdump", "-d", str(lib)], capture_output=True, text=True)
115    if d.returncode != 0:
116        return mal(f"objdump falló sobre libggml-cpu.a: {d.stderr.strip()[:120]}")
117    for patron, quien in (("%ymm", "AVX2 (registros de 256 bits)"),
118                          ("vfmadd", "FMA"),
119                          ("roundps", "SSE4.2")):
120        n = d.stdout.count(patron)
121        (ok if n else mal)(f"{quien}: {n} apariciones de `{patron}`"
122                           + ("" if n else "  ← INS_ENB quedó apagado (ver la cabecera)"))
123
124
125# ── 2. hermético ──────────────────────────────────────────────────────────────────────────────
126def probar_hermetico(art):
127    print("\n\033[1m2. Hermético — ¿el binario pide algo de afuera?\033[0m")
128    for nombre in ("llama-server", "llama-cli"):
129        b = art / "usr/bin" / nombre
130        if not b.is_file():
131            mal(f"{nombre} no está en el artefacto")
132            continue
133        r = subprocess.run(["readelf", "-d", str(b)], capture_output=True, text=True)
134        needed = re.findall(r"\(NEEDED\).*\[(.+?)\]", r.stdout)
135        (ok if not needed else mal)(
136            f"{nombre}: {'sin NEEDED (estático)' if not needed else 'pide ' + ', '.join(needed)}")
137
138
139# ── 3. inferencia ─────────────────────────────────────────────────────────────────────────────
140def probar_inferencia(art, modelo):
141    print("\n\033[1m3. Inferencia — ¿sale un token de un modelo de verdad?\033[0m")
142    r = subprocess.run([str(art / "usr/bin/llama-cli"), "-m", str(modelo),
143                        "-p", "Once upon a time", "-n", "24", "--no-warmup",
144                        "--single-turn", "--seed", "1"],
145                       capture_output=True, text=True, timeout=180)
146    if r.returncode != 0:
147        return mal(f"llama-cli salió {r.returncode}: {r.stderr.strip().splitlines()[-1:] or ''}")
148    # La salida del turno viene después del prompt echoado; nos alcanza con que haya texto nuevo.
149    cuerpo = r.stdout.split("Once upon a time", 1)[-1]
150    generado = "".join(l for l in cuerpo.splitlines()
151                       if l.strip() and not l.startswith(("[", ">", "Exiting")))
152    (ok if len(generado.strip()) >= 10 else mal)(
153        f"generó {len(generado.strip())} caracteres: {generado.strip()[:70]!r}")
154
155
156# ── 4. embeddings ─────────────────────────────────────────────────────────────────────────────
157def vector(puerto, texto):
158    req = urllib.request.Request(
159        f"http://127.0.0.1:{puerto}/v1/embeddings",
160        data=json.dumps({"input": texto, "model": "prueba"}).encode(),
161        headers={"Content-Type": "application/json"})
162    with urllib.request.urlopen(req, timeout=60) as r:
163        return json.load(r)["data"][0]["embedding"]
164
165
166def probar_embeddings(art, modelo):
167    print("\n\033[1m4. Embeddings — el endpoint del que cuelga la mitad semántica del §6.3\033[0m")
168    puerto = 18131
169    srv = subprocess.Popen(
170        # ⚠ `--pooling mean` NO es un gusto, y la razón conviene saberla ANTES de pinear el
171        # modelo de producción: un GGUF declara su tipo de pooling en los metadatos, y los modelos
172        # de EMBEDDING de verdad (e5, bge, nomic) lo traen. `stories260K` es un LM causal de
173        # juguete y no lo trae, así que llama.cpp cae en `none` y el endpoint compatible con OpenAI
174        # contesta **400: "Pooling type \'none\' is not OAI compatible"** — no un vector vacío, no
175        # un 500: un 400 que parece un error del cliente. Se fuerza `mean`, que es lo que hace
176        # cualquier motor de embeddings sobre un LM causal.
177        [str(art / "usr/bin/llama-server"), "-m", str(modelo), "--embedding",
178         "--pooling", "mean",
179         "--host", "127.0.0.1", "--port", str(puerto), "-c", "512", "--no-warmup"],
180        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
181    try:
182        listo = False
183        for _ in range(120):
184            if srv.poll() is not None:
185                break
186            # ⚠ `/health` contesta MIENTRAS carga, con 503 y `{"status":"loading model"}`. Tomar
187            # «contestó» por «listo» manda la primera consulta contra un servidor a medio levantar
188            # y devuelve un 503 que se lee como «el endpoint no existe». Se espera el `ok`.
189            try:
190                with urllib.request.urlopen(f"http://127.0.0.1:{puerto}/health", timeout=2) as r:
191                    if json.load(r).get("status") == "ok":
192                        listo = True
193                        break
194            except Exception:
195                pass
196            time.sleep(0.5)
197        if not listo:
198            return mal(f"llama-server no levantó (salió {srv.poll()})")
199        ok(f"llama-server --embedding escucha en :{puerto}")
200
201        a1 = vector(puerto, "el zorro salta sobre el perro")
202        a2 = vector(puerto, "el zorro salta sobre el perro")
203        b1 = vector(puerto, "la reunión de contabilidad es el martes")
204
205        (ok if len(a1) > 8 else mal)(f"devuelve un vector de {len(a1)} dimensiones")
206        (ok if a1 == a2 else mal)(
207            "el MISMO texto da el MISMO vector" if a1 == a2
208            else "el mismo texto dio DOS vectores distintos — no es determinista")
209        (ok if a1 != b1 else mal)(
210            "dos textos DISTINTOS dan vectores distintos" if a1 != b1
211            else "dos textos distintos dieron el MISMO vector — es un stub, no un modelo")
212        if any(x != 0.0 for x in a1):
213            ok("el vector no es todo ceros")
214        else:
215            mal("el vector es TODO CEROS — pasaría cualquier prueba de «¿hay un vector?»")
216    finally:
217        srv.terminate()
218        try:
219            srv.wait(timeout=10)
220        except subprocess.TimeoutExpired:
221            srv.kill()
222
223
224def main():
225    p = argparse.ArgumentParser()
226    p.add_argument("--sin-modelo", action="store_true",
227                   help="control negativo: las sondas 3 y 4 corren contra un modelo inexistente "
228                        "y TIENEN que fallar")
229    a = p.parse_args()
230
231    art = artefacto()
232    print(f"\033[1martefacto:\033[0m {art.name}")
233
234    if a.sin_modelo:
235        print("\n\033[33m── CONTROL NEGATIVO: modelo inexistente; lo correcto es que FALLE ──\033[0m")
236        modelo = RAIZ / "work/llama-modelos/NO-EXISTE.gguf"
237        probar_inferencia(art, modelo)
238        probar_embeddings(art, modelo)
239        if fallos:
240            print(f"\n\033[32m✓ CONTROL NEGATIVO OK\033[0m — {len(fallos)} sondas fallaron, "
241                  "como tenían que fallar. El arnés mide algo.")
242            return 0
243        print("\n\033[31m✗ CONTROL NEGATIVO ROTO\033[0m — todo pasó SIN modelo: el arnés no mide nada.")
244        return 1
245
246    probar_isa(art)
247    probar_hermetico(art)
248    modelo = bajar_modelo()
249    probar_inferencia(art, modelo)
250    probar_embeddings(art, modelo)
251
252    print()
253    if fallos:
254        print(f"\033[31m✗ {len(fallos)} fallo(s)\033[0m")
255        for f in fallos:
256            print(f"    · {f}")
257        return 1
258    print("\033[32m✓ el motor de inferencia local del corpus infiere y vectoriza\033[0m")
259    return 0
260
261
262if __name__ == "__main__":
263    sys.exit(main())