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…
⎇ 607fbbc3494f5b1f0f9908d26f32be0b9c264a39
scripts/test-llama-cpp.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
atuq §6.7: entra el motor de inferencia local — y SOURCE_DATE_EPOCH le había apagado el AVX2
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())