23v commited on
Commit
b4320f8
·
verified ·
1 Parent(s): 8a8aeec

Upload app_deepseek.py

Browse files
Files changed (1) hide show
  1. app_deepseek.py +222 -439
app_deepseek.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
  gijl/agentic-thinker-v1 -- multi-loop dataset generator (single-file app.py)
3
- v8, worker "deepseek" (DeepSeek-V4-Flash-0731): llama.cpp backend + multi-loop + verified, source-grouped, auditable
4
  output, + genuine agent-task trajectories + five specialized workers (glm/minimax/deepseek/kimi/
5
  qwen) concurrent collection, now with real per-model output-compatibility handling. Sibling files
6
  run the identical pipeline -- see WHAT CHANGED IN v7 and WHAT CHANGED IN v8.
@@ -310,17 +310,27 @@ architecture to mature (GIJL_LLAMA_SERVER is unset by default, so a later run au
310
  whatever "latest" is by then -- no file change needed), or point GIJL_LLAMA_SERVER (already supported,
311
  see ensure_llama_server()) at a self-built binary from an experimental third-party CPU-capable fork --
312
  unofficial, so verify it yourself before trusting it with real data.
 
 
 
 
 
 
 
 
 
 
 
313
  FOUR sibling files (glm/minimax/deepseek/kimi/qwen minus this one) run the same pipeline, each specialized
314
  differently -- see WHAT CHANGED IN v7 and WHAT CHANGED IN v8 at the top of this file for the full table and the
315
- evidence behind each choice. all five write into the SAME dataset concurrently -- see MODEL_TAG and the v6 note above for how that stays race-free. The chat template + tokenizer come from the
316
- MODEL_ID repo (rendered locally), the weights come from the GGUF repo. ONE llama-server process, ONE generation
317
  stream (parallel slots = 1), context 12288 tokens by default (GIJL_CTX). The tool-call XML convention below
318
  (<atem:...>) is taught IN THE PROMPT (see build_scout_prompt/build_agent_task_prompt), not a trained-in format of
319
  any one model, so it is unchanged across workers -- but it is still worth a GIJL_SMOKE=1 run per worker before a
320
  long session, to confirm this model actually follows it turn after turn.
321
 
322
- HARDWARE (llama.cpp backend): NVIDIA GPU via a CUDA build, otherwise CPU. llama-server's --fit (default) fits layers
323
- to the free VRAM; when the whole model fits, every layer is offloaded (-ngl 999). TPU is not supported by llama.cpp.
324
  (The old transformers backend's TPU notes: device_map="auto" did NOT place the model on the TPU -- the log showed
325
  "no hf_device_map" -- and generation stalled in XLA compilation.)
326
 
@@ -366,8 +376,8 @@ Before running:
366
  Stack Exchange key (label must match SECRET_NAMES below).
367
  3. Optional env: GIJL_ONLY_LOOPS=judgment_dpo,sft_helpful (restrict loops),
368
  GIJL_TARGET_REPO=user/repo, GIJL_WORK_DIR=/some/dir, GIJL_MODEL_ID=..., GIJL_CTX=12288,
369
- GIJL_GGUF_QUANT=UD-Q4_K_XL, GIJL_LLAMA_SERVER=/path/to/your/llama-server, GIJL_REBUILD_LLAMA=1,
370
- GIJL_CACHE_LLAMA_BINARY=0, GIJL_ALLOW_CPU_FALLBACK=1, GIJL_REASONING_GEN=low, GIJL_REASONING_SCOUT=medium
371
  """
372
 
373
  import os
@@ -539,21 +549,13 @@ TARGET_DATASET_REPO = os.environ.get("GIJL_TARGET_REPO", "gijl/agentic-thinker-v
539
  SEARCH_LANGUAGE_CYCLE = ["arabic", "english"]
540
 
541
  # --- inference backend -------------------------------------------------------
542
- # "llamacpp" (default): llama-server + GGUF, on GPU (CUDA build) or CPU. llama.cpp cannot use TPU.
 
543
  # "hf": the old transformers path (only kept for reference; on TPU it produced ~3 chars/s and hung).
544
- BACKEND = os.environ.get("GIJL_BACKEND", "llamacpp").strip().lower()
545
- GGUF_REPO = os.environ.get("GIJL_GGUF_REPO", "unsloth/DeepSeek-V4-Flash-0731-GGUF")
546
- GGUF_QUANT = os.environ.get("GIJL_GGUF_QUANT", "UD-Q2_K_XL") # v7: pinned explicitly -- FIXED from v6's generic "Q6_K" default, which is not a real tag in this repo's quant ladder -- confirmed 96.8GB (unsloth's published quant-measurement table); UD-IQ3_XXS, the next step up, is 104.2GB and does not fit
547
- # choose_quant()/_memory_budget_gb() still run underneath this as a
548
- # safety net (step down further on a smaller box); GIJL_GGUF_QUANT
549
- # overrides this default at launch time.
550
- LLAMA_CTX = int(os.environ.get("GIJL_CTX", "12288")) # tokens; KV cache is ~2-4GB at this size
551
- LLAMA_LOAD_TIMEOUT_S = 1800
552
- LLAMA_BIN_DIR = os.path.join(WORK_DIR, "bin") # CUDA build cache (synced to the hub, see CACHE_LLAMA_BINARY)
553
- LLAMA_CPU_DIR = os.path.join(WORK_DIR, "llama_cpu") # prebuilt CPU release (not synced: quick to re-download)
554
- LLAMA_BUILD_ROOT = os.environ.get("GIJL_LLAMA_BUILD_DIR", "/tmp/gijl_llama_build")
555
- CACHE_LLAMA_BINARY = os.environ.get("GIJL_CACHE_LLAMA_BINARY", "1") != "0"
556
- LLAMA_BUILD_TIMEOUT_S = 45 * 60
557
  # Meta's recommended sampling is temperature 1.0 / top_p 0.95 / top_k 64. A slightly lower temperature
558
  # suits structured-JSON tasks; top_p/top_k stay as recommended.
559
  TOP_P, TOP_K = 0.95, 64
@@ -1326,12 +1328,9 @@ class ModelClient:
1326
 
1327
 
1328
  # ---------------------------------------------------------------------------
1329
- # 5b. llama.cpp backend (DEFAULT): CUDA build on GPU, prebuilt binary on CPU.
1330
- # llama.cpp cannot use a TPU. Behavior below was checked against a real
1331
- # llama-server (build b10964) running a tiny GGUF: /completion SSE format,
1332
- # /tokenize double-BOS, HTTP 400 on context overflow, cancel-on-disconnect.
1333
- # There are NO official Linux CUDA binaries (only Windows CUDA), so the GPU
1334
- # path builds from source (cached in the hub repo under bin/).
1335
  # ---------------------------------------------------------------------------
1336
  def _run(cmd, timeout=60, env=None):
1337
  try:
@@ -1399,279 +1398,94 @@ def _memory_budget_gb(acc: dict) -> float:
1399
  return (acc["ram_gb"] or 24.0) * 0.75 - 2.0
1400
 
1401
 
1402
- # --- GGUF selection / download ---------------------------------------------------
1403
- _QUANT_RE = re.compile(r"-((?:UD-)?(?:IQ|Q)\d[A-Za-z0-9_]*|BF16|F16|F32|MXFP4[A-Za-z0-9_]*|NVFP4[A-Za-z0-9_]*)$", re.IGNORECASE)
1404
- _SHARD_RE = re.compile(r"-\d{5}-of-\d{5}$")
1405
-
1406
-
1407
- def _group_gguf_files(siblings) -> dict:
1408
- """siblings: iterable of (filename, size_bytes) -> {QUANT_TAG: {"files": [...], "size_gb": float}}.
1409
- Skips vision projectors and speculative-decoding drafters; joins split shards under one tag."""
1410
- out = {}
1411
- for name, size in siblings:
1412
- base = os.path.basename(name)
1413
- low = base.lower()
1414
- if not low.endswith(".gguf") or any(x in low for x in ("mmproj", "dflash", "assistant", "draft")):
1415
- continue
1416
- m = _QUANT_RE.search(_SHARD_RE.sub("", base[:-5]))
1417
- if not m:
1418
- continue
1419
- g = out.setdefault(m.group(1).upper(), {"files": [], "size_gb": 0.0})
1420
- g["files"].append(name)
1421
- g["size_gb"] += (size or 0) / 1e9
1422
- for g in out.values():
1423
- g["files"].sort()
1424
- return out
1425
-
1426
-
1427
- def choose_quant(groups: dict, budget_gb: float, want: str, explicit: bool):
1428
- """-> (tag, reason). An explicitly requested quant is always honored; the default one steps down if it
1429
- does not fit the memory budget."""
1430
- if not groups:
1431
- raise RuntimeError(f"no usable .gguf files found in {GGUF_REPO}")
1432
- want_u = (want or "").upper()
1433
- if want_u in groups and (explicit or groups[want_u]["size_gb"] <= budget_gb):
1434
- return want_u, "requested"
1435
- fitting = [t for t, g in groups.items() if g["size_gb"] <= budget_gb]
1436
- if want_u in groups:
1437
- smaller = [t for t in fitting if groups[t]["size_gb"] < groups[want_u]["size_gb"]]
1438
- if smaller:
1439
- return max(smaller, key=lambda t: groups[t]["size_gb"]), f"{want_u} ({groups[want_u]['size_gb']:.1f}GB) exceeds the {budget_gb:.1f}GB budget"
1440
- elif fitting:
1441
- return max(fitting, key=lambda t: groups[t]["size_gb"]), f"{want_u or 'requested quant'} not in the repo"
1442
- return min(groups, key=lambda t: groups[t]["size_gb"]), "nothing fits the budget: smallest quant, expect partial offload"
1443
 
1444
 
1445
  def download_gguf(acc: dict):
1446
- """-> (path_to_first_shard, {"quant","size_gb"}). Stored in the default HF cache (NOT /kaggle/working,
1447
- which is capped at ~20GB of saved output)."""
1448
- from huggingface_hub import HfApi, hf_hub_download
1449
  token = os.environ.get("HF_TOKEN")
1450
  explicit_file = os.environ.get("GIJL_GGUF_FILE")
1451
  if explicit_file:
1452
  path = hf_hub_download(GGUF_REPO, explicit_file, token=token)
1453
  return path, {"quant": explicit_file, "size_gb": os.path.getsize(path) / 1e9}
1454
- try:
1455
- info = HfApi(token=token).model_info(GGUF_REPO, files_metadata=True)
1456
- groups = _group_gguf_files((s.rfilename, getattr(s, "size", 0)) for s in info.siblings)
1457
- except Exception as e: # noqa: BLE001 -- fall back to the naming scheme from the model docs
1458
- # v6: GGUF_REPO now differs per worker (glm/minimax/deepseek) -- guess the base name from the repo id
1459
- # itself (unsloth's own convention: "<org>/<Model-Name>-GGUF" -> "<Model-Name>-<QUANT>.gguf") instead of
1460
- # a name hardcoded for one specific model.
1461
- guess = GGUF_REPO.rsplit("/", 1)[-1].removesuffix("-GGUF")
1462
- print(f"[llama] could not list {GGUF_REPO} ({_redact(str(e))[:120]}); assuming {guess}-{GGUF_QUANT}.gguf", flush=True)
1463
- groups = {GGUF_QUANT.upper(): {"files": [f"{guess}-{GGUF_QUANT}.gguf"], "size_gb": 0.0}}
1464
- budget = _memory_budget_gb(acc)
1465
- tag, why = choose_quant(groups, budget, GGUF_QUANT, "GIJL_GGUF_QUANT" in os.environ)
1466
- files = groups[tag]["files"]
1467
- print(f"[llama] model: {GGUF_REPO} quant={tag} ({groups[tag]['size_gb']:.1f}GB, budget {budget:.1f}GB) -- {why}", flush=True)
1468
  t0 = time.time()
1469
- paths = [hf_hub_download(GGUF_REPO, f, token=token) for f in files]
1470
- print(f"[llama] GGUF ready in {time.time() - t0:.0f}s: {paths[0]}", flush=True)
1471
- return paths[0], {"quant": tag, "size_gb": groups[tag]["size_gb"] or os.path.getsize(paths[0]) / 1e9}
1472
-
1473
-
1474
- # --- getting a llama-server binary ---------------------------------------------------
1475
- def _llama_env(lib_dir):
1476
- env = os.environ.copy()
1477
- if lib_dir:
1478
- env["LD_LIBRARY_PATH"] = lib_dir + (":" + env["LD_LIBRARY_PATH"] if env.get("LD_LIBRARY_PATH") else "")
1479
- return env
1480
 
1481
 
1482
- def _binary_ok(binary: str, lib_dir, need_cuda: bool) -> bool:
1483
- env = _llama_env(lib_dir)
1484
- rc, out, err = _run([binary, "--version"], 90, env)
1485
- if rc != 0:
1486
- return False
1487
- if need_cuda:
1488
- rc, out, err = _run([binary, "--list-devices"], 90, env)
1489
- return rc == 0 and "cuda" in (out + err).lower()
1490
- return True
1491
-
1492
-
1493
- def _cuda_archs(acc: dict) -> list:
1494
- archs = []
1495
- for g in acc["gpus"]:
1496
- m = re.fullmatch(r"(\d+)\.(\d+)", (g.get("cc") or "").strip())
1497
- if m and (m.group(1) + m.group(2)) not in archs:
1498
- archs.append(m.group(1) + m.group(2))
1499
- return archs or ["native"]
1500
-
1501
-
1502
- def _find_nvcc():
1503
- found = shutil.which("nvcc")
1504
- if found:
1505
- return found
1506
- for cand in sorted(glob.glob("/usr/local/cuda*/bin/nvcc"), reverse=True):
1507
- return cand
1508
- return None
1509
 
1510
 
1511
- def build_llama_cuda(archs: list):
1512
- """Build llama-server from source with CUDA (same recipe as the Unsloth guide + a single target arch).
1513
- Returns the path of the built binary, or None.
1514
-
1515
- -DGGML_CUDA_NO_VMM=ON is essential on Kaggle: without it ggml-cuda links CUDA::cuda_driver (libcuda.so), and the
1516
- Kaggle image has the toolkit but NOT the libcuda.so stub, so CMake never creates that target and configure fails
1517
- with "Target ggml-cuda links to CUDA::cuda_driver but the target was not found" (ggml-cuda/CMakeLists.txt
1518
- lines 168-172; seen in a real T4x2 log). NO_VMM only drops the CUDA virtual-memory memory pool. A second attempt
1519
- also switches NCCL off, in case NCCL is what breaks the build/link."""
1520
- nvcc = _find_nvcc()
1521
- if not nvcc:
1522
- print("[llama][build] nvcc (CUDA toolkit) not found -- cannot build a CUDA llama-server here.", flush=True)
1523
- return None
1524
- cmake = shutil.which("cmake")
1525
- if not cmake:
1526
- _pip("install", "-q", "cmake", "ninja")
1527
- cmake = shutil.which("cmake")
1528
- if not cmake or not shutil.which("git"):
1529
- print("[llama][build] cmake/git missing -- cannot build.", flush=True)
1530
- return None
1531
- src, build = os.path.join(LLAMA_BUILD_ROOT, "llama.cpp"), os.path.join(LLAMA_BUILD_ROOT, "llama.cpp", "build")
1532
- log = os.path.join(LLAMA_BUILD_ROOT, "build.log")
1533
- env = os.environ.copy()
1534
- env["CUDACXX"] = nvcc
1535
- env["PATH"] = os.path.dirname(nvcc) + os.pathsep + env.get("PATH", "")
1536
  t0 = time.time()
1537
- if not os.path.isdir(os.path.join(src, ".git")):
1538
- os.makedirs(LLAMA_BUILD_ROOT, exist_ok=True)
1539
- if _run_logged(["git", "clone", "--depth", "1", "https://github.com/ggml-org/llama.cpp", src], env, log, "llama-git", 600) != 0:
1540
- print(f"[llama][build] git clone failed:\n{_tail_file(log, 15)}", flush=True)
1541
- return None
1542
- toolkit_root = os.path.dirname(os.path.dirname(nvcc))
1543
- base = [cmake, "-S", src, "-B", build, "-DGGML_CUDA=ON", f"-DCMAKE_CUDA_ARCHITECTURES={';'.join(archs)}",
1544
- "-DCMAKE_BUILD_TYPE=Release", "-DBUILD_SHARED_LIBS=OFF", "-DLLAMA_CURL=OFF", "-DLLAMA_BUILD_TESTS=OFF",
1545
- "-DGGML_CUDA_NO_VMM=ON"]
1546
- if os.path.isfile(os.path.join(toolkit_root, "include", "cuda_runtime.h")):
1547
- base.append(f"-DCUDAToolkit_ROOT={toolkit_root}")
1548
  jobs = str(max(2, os.cpu_count() or 4))
1549
- for label, extra in (("CUDA, NO_VMM", []), ("CUDA, NO_VMM, NCCL off", ["-DGGML_CUDA_NCCL=OFF"])):
1550
- shutil.rmtree(build, ignore_errors=True) # a failed configure leaves a poisoned CMake cache
1551
- print(f"[llama][build] attempt: {label} (archs {archs}); ~10-20 min once, then cached.", flush=True)
1552
- if _run_logged(base + extra, env, log, "llama-cmake", 900) != 0:
1553
- print(f"[llama][build] cmake configure failed ({label}):\n{_tail_file(log, 25)}", flush=True)
1554
- continue
1555
- if _run_logged([cmake, "--build", build, "--config", "Release", "-j", jobs, "--target", "llama-server"],
1556
- env, log, "llama-build", LLAMA_BUILD_TIMEOUT_S) != 0:
1557
- print(f"[llama][build] build failed ({label}):\n{_tail_file(log, 25)}", flush=True)
1558
- continue
1559
- binary = os.path.join(build, "bin", "llama-server")
1560
- if os.path.isfile(binary):
1561
- print(f"[llama][build] done in {time.time() - t0:.0f}s ({label})", flush=True)
1562
- return binary
1563
- return None
1564
-
1565
-
1566
- def download_llama_cpu_release():
1567
- """Prebuilt CPU llama-server from GitHub releases. The 'latest' release only carries nightly-tag.txt, which
1568
- names the tag holding the real binaries (llama-<tag>-bin-ubuntu-x64.tar.gz)."""
1569
- import requests
1570
- arch = "arm64" if platform.machine().lower() in ("aarch64", "arm64") else "x64"
1571
- base = "https://github.com/ggml-org/llama.cpp/releases"
1572
- hdr = {"User-Agent": _CRAWLER_UA}
1573
- pinned_tag = os.environ.get("GIJL_LLAMA_CPU_TAG", "").strip()
1574
- if pinned_tag:
1575
- # v8: bisecting knob, opt-in only (default behavior below is unchanged) -- added because DeepSeek-V4-Flash
1576
- # crashed llama-server on CPU with GGML_ASSERT(*cur_backend_id != -1) in ggml_backend_sched_split_graph
1577
- # during context/graph setup, a signature consistent with "this architecture's CPU backend coverage in
1578
- # llama.cpp is still incomplete" rather than anything wrong in this file -- DeepSeek-V4 support was still
1579
- # WIP/fork-based in llama.cpp as of its own GitHub support thread. "latest" always means whatever today's
1580
- # CI build is, so there is no way to test a different one without this. EXCLUSIVE on purpose (no silent
1581
- # fallback to latest): the point is to know for certain which build actually ran.
1582
- print(f"[llama] GIJL_LLAMA_CPU_TAG={pinned_tag} set -- using this exact release instead of latest", flush=True)
1583
- tags = [pinned_tag]
1584
- else:
1585
- tags = []
1586
- try:
1587
- latest = requests.get(f"{base}/latest", timeout=30, headers=hdr).url.rstrip("/").rsplit("/", 1)[-1]
1588
- nt = requests.get(f"{base}/download/{latest}/nightly-tag.txt", timeout=30, headers=hdr)
1589
- if nt.status_code == 200 and nt.text.strip():
1590
- tags.append(nt.text.strip().split()[0])
1591
- tags.append(latest)
1592
- except requests.RequestException as e:
1593
- raise RuntimeError(f"cannot reach GitHub releases: {_redact(str(e))[:200]}")
1594
- last_err = "no candidate tag"
1595
- for tag in tags:
1596
- dest = os.path.join(LLAMA_CPU_DIR, tag)
1597
- found = glob.glob(os.path.join(dest, "**", "llama-server"), recursive=True)
1598
- if found:
1599
- return found[0]
1600
- url = f"{base}/download/{tag}/llama-{tag}-bin-ubuntu-{arch}.tar.gz"
1601
- os.makedirs(dest, exist_ok=True)
1602
- archive = os.path.join(dest, "release.tar.gz")
1603
- try:
1604
- print(f"[llama] downloading CPU build {tag} ...", flush=True)
1605
- with requests.get(url, stream=True, timeout=(20, 120), headers=hdr) as r:
1606
- r.raise_for_status()
1607
- with open(archive, "wb") as f:
1608
- for part in r.iter_content(chunk_size=1 << 20):
1609
- f.write(part)
1610
- with tarfile.open(archive) as tf:
1611
- if hasattr(tarfile, "data_filter"):
1612
- tf.extractall(dest, filter="data")
1613
- else:
1614
- tf.extractall(dest)
1615
- os.remove(archive)
1616
- except Exception as e: # noqa: BLE001
1617
- last_err = f"{tag}: {type(e).__name__}: {_redact(str(e))[:160]}"
1618
- continue
1619
- found = glob.glob(os.path.join(dest, "**", "llama-server"), recursive=True)
1620
- if found:
1621
- os.chmod(found[0], 0o755)
1622
- return found[0]
1623
- raise RuntimeError(f"could not obtain a prebuilt llama-server ({last_err})")
1624
-
1625
-
1626
- def ensure_llama_server(acc: dict):
1627
- """-> (binary, lib_dir, kind). Order: GIJL_LLAMA_SERVER override, cached CUDA build, fresh CUDA build,
1628
- prebuilt CPU release (with a loud warning when a GPU exists but a CUDA build is impossible)."""
1629
- override = os.environ.get("GIJL_LLAMA_SERVER")
1630
- if override:
1631
- return override, os.path.dirname(override) or None, ("gpu" if acc["kind"] == "gpu" else "cpu")
1632
- if acc["kind"] == "gpu":
1633
- archs = _cuda_archs(acc)
1634
- cdir = os.path.join(LLAMA_BIN_DIR, "cuda-sm" + "-".join(archs))
1635
- cbin = os.path.join(cdir, "llama-server")
1636
- if os.path.isfile(cbin) and os.environ.get("GIJL_REBUILD_LLAMA") != "1":
1637
- for name in os.listdir(cdir):
1638
- try:
1639
- os.chmod(os.path.join(cdir, name), 0o755) # the hub does not preserve the executable bit
1640
- except OSError:
1641
- pass
1642
- if _binary_ok(cbin, cdir, True):
1643
- print(f"[llama] using cached CUDA build {cbin}", flush=True)
1644
- return cbin, cdir, "gpu"
1645
- print("[llama] cached CUDA build is unusable here (different image/driver?) -- rebuilding", flush=True)
1646
- built = build_llama_cuda(archs)
1647
- if built:
1648
- shutil.rmtree(cdir, ignore_errors=True)
1649
- os.makedirs(cdir, exist_ok=True)
1650
- for name in os.listdir(os.path.dirname(built)):
1651
- fp = os.path.join(os.path.dirname(built), name)
1652
- if os.path.isfile(fp):
1653
- shutil.copy2(fp, os.path.join(cdir, name))
1654
- if _binary_ok(cbin, cdir, True):
1655
- return cbin, cdir, "gpu"
1656
- print("[llama][build] the fresh build does not see a CUDA device", flush=True)
1657
- names = ", ".join(g["name"] for g in acc["gpus"])
1658
- if os.environ.get("GIJL_ALLOW_CPU_FALLBACK") != "1":
1659
- # This used to fall back silently: a T4x2 machine then ran a 30B model on 4 CPU cores (~1 token/s,
1660
- # 3 tok/s prefill) and nothing looked wrong for the first 15 minutes.
1661
- raise RuntimeError(
1662
- f"A GPU is present ({names}) but no CUDA llama-server could be built or loaded, so this would run on the CPU "
1663
- f"(~1 token/s). See the [llama][build] errors above and {os.path.join(LLAMA_BUILD_ROOT, 'build.log')}. "
1664
- f"Options: GIJL_LLAMA_SERVER=/path/to/your/cuda/llama-server, or GIJL_ALLOW_CPU_FALLBACK=1 to run on CPU anyway.")
1665
- print("[llama][WARNING] GIJL_ALLOW_CPU_FALLBACK=1 -> using the CPU build although a GPU is present (MUCH slower).", flush=True)
1666
- binary = download_llama_cpu_release()
1667
- if not _binary_ok(binary, os.path.dirname(binary), False):
1668
- raise RuntimeError(f"the downloaded llama-server does not run here: {binary}")
1669
- return binary, os.path.dirname(binary), "cpu"
1670
-
1671
-
1672
- def load_tokenizer():
1673
- from transformers import AutoTokenizer
1674
- return AutoTokenizer.from_pretrained(MODEL_ID, token=os.environ.get("HF_TOKEN"))
1675
 
1676
 
1677
  def _free_port() -> int:
@@ -1680,14 +1494,14 @@ def _free_port() -> int:
1680
  return s.getsockname()[1]
1681
 
1682
 
1683
- class LlamaServer:
1684
- """Owns the llama-server subprocess: starts it, waits for /health, restarts it if it dies, stops it at exit."""
1685
 
1686
- def __init__(self, binary, lib_dir, model_path, kind, ctx, acc=None, model_gb=0.0, extra_args=None):
1687
- self.binary, self.lib_dir, self.model_path, self.kind, self.ctx = binary, lib_dir, model_path, kind, ctx
1688
- self.acc, self.model_gb, self.extra_args = acc or {}, model_gb, list(extra_args or [])
1689
  self.port, self.proc, self.restarts, self._help_text = _free_port(), None, 0, None
1690
- self.log_path = os.path.join(WORK_DIR, "llama-server.log")
1691
  atexit.register(self.stop)
1692
 
1693
  @property
@@ -1696,30 +1510,14 @@ class LlamaServer:
1696
 
1697
  def _has(self, flag: str) -> bool:
1698
  if self._help_text is None:
1699
- rc, out, err = _run([self.binary, "--help"], 90, _llama_env(self.lib_dir))
1700
  self._help_text = out + err
1701
  return flag in self._help_text
1702
 
1703
  def command(self) -> list:
1704
- cmd = [self.binary, "-m", self.model_path, "--host", "127.0.0.1", "--port", str(self.port), "-c", str(self.ctx)]
1705
- if self._has("--parallel"):
1706
- cmd += ["--parallel", "1"] # one stream: the whole context belongs to one slot
1707
- if self._has("--special"):
1708
- cmd += ["--special"] # emit special tokens so <atem:...> tool markup survives
1709
- if self._has("--no-context-shift"):
1710
- cmd += ["--no-context-shift"] # error out instead of silently dropping the prompt's start
1711
- if self._has("--no-jinja"):
1712
- cmd += ["--no-jinja"] # we render the template ourselves: /completion stays raw pass-through
1713
- if self._has("--reasoning-format"):
1714
- cmd += ["--reasoning-format", "none"] # never move 'thinking' out of the text we parse
1715
- threads = "16"
1716
- cmd += ["--threads", threads]
1717
- if self.kind == "gpu":
1718
- if self.model_gb and self.model_gb + 2.5 <= self.acc.get("vram_gb", 0) * 0.92:
1719
- cmd += ["-ngl", "999"] # fits entirely: offload every layer
1720
- # else: leave -ngl unset so llama.cpp's default --fit adjusts layers to the free VRAM
1721
- else:
1722
- cmd += ["-ngl", "0"]
1723
  return cmd + self.extra_args
1724
 
1725
  def tail(self, n: int = 25) -> str:
@@ -1729,40 +1527,34 @@ class LlamaServer:
1729
  import requests
1730
  os.makedirs(os.path.dirname(self.log_path), exist_ok=True)
1731
  cmd = self.command()
1732
- print("[llama] starting: " + " ".join(cmd), flush=True)
1733
  self._log = open(self.log_path, "ab")
1734
- self.proc = subprocess.Popen(cmd, stdout=self._log, stderr=subprocess.STDOUT, env=_llama_env(self.lib_dir),
1735
  start_new_session=True)
1736
  t0, last = time.time(), time.time()
1737
- while time.time() - t0 < LLAMA_LOAD_TIMEOUT_S:
1738
  if self.proc.poll() is not None:
1739
- raise RuntimeError(f"llama-server exited during startup (code {self.proc.returncode}). Log tail:\n{self.tail(40)}")
1740
  try:
1741
- r = requests.get(self.base_url + "/health", timeout=5)
1742
- if r.status_code == 200 and r.json().get("status") == "ok":
1743
- print(f"[llama] server ready after {time.time() - t0:.0f}s on port {self.port}", flush=True)
1744
- self._print_offload_summary()
1745
  return
1746
  except (requests.RequestException, ValueError):
1747
  pass
1748
  if time.time() - last > 30:
1749
- print(f"[llama] loading model ... {time.time() - t0:.0f}s", flush=True)
1750
  last = time.time()
1751
  time.sleep(2)
1752
- raise TimeoutError(f"llama-server not ready after {LLAMA_LOAD_TIMEOUT_S}s. Log tail:\n{self.tail(40)}")
1753
-
1754
- def _print_offload_summary(self):
1755
- lines = [l for l in self.tail(400).splitlines() if re.search(r"offload|CUDA|fit|n_ctx|KV", l)]
1756
- for l in lines[-6:]:
1757
- print("[llama] " + l[-200:], flush=True)
1758
 
1759
  def ensure_running(self):
1760
  if self.proc is not None and self.proc.poll() is None:
1761
  return
1762
  self.restarts += 1
1763
- print(f"[llama][WARNING] server is not running (restart #{self.restarts}). Log tail:\n{self.tail(15)}", flush=True)
1764
  if self.restarts > 3:
1765
- raise RuntimeError("llama-server keeps dying; giving up")
1766
  self.port = _free_port()
1767
  self.start()
1768
 
@@ -1780,78 +1572,75 @@ class LlamaServer:
1780
  pass
1781
 
1782
 
1783
- class LlamaCppClient:
1784
- """Same interface as ModelClient.chat(): renders the model's OWN chat template (with tools) via the HF
1785
- tokenizer and streams /completion from llama-server.
1786
- - BOS: llama-server adds BOS itself, so a BOS already in the rendered text would double it (verified);
1787
- the leading BOS string is stripped when the server adds one.
1788
- - closing the HTTP connection cancels generation server-side (verified), so a timeout really frees the
1789
- slot -- unlike the old transformers thread that kept running.
1790
- - prompt caching is on: each scouting turn only prefills what is new."""
1791
-
1792
- def __init__(self, server: LlamaServer, tokenizer, kind: str):
1793
- self.server, self.tok, self.kind, self.hardware = server, tokenizer, kind, kind
1794
- self.extra_payload = {} # (tests / advanced use) merged into every /completion request
1795
- self.ctx = self._read_ctx()
1796
- self.server_adds_bos = self._probe_add_bos()
1797
-
1798
- def _read_ctx(self) -> int:
1799
- import requests
1800
- try:
1801
- n = requests.get(self.server.base_url + "/props", timeout=20).json()["default_generation_settings"]["n_ctx"]
1802
- return int(n)
1803
- except Exception: # noqa: BLE001
1804
- return self.server.ctx
1805
-
1806
- def _tokenize(self, text: str, add_special: bool) -> list:
1807
- import requests
1808
- r = requests.post(self.server.base_url + "/tokenize", timeout=120,
1809
- json={"content": text, "add_special": add_special, "parse_special": True})
1810
- r.raise_for_status()
1811
- return r.json()["tokens"]
1812
-
1813
- def _probe_add_bos(self) -> bool:
1814
- try:
1815
- return len(self._tokenize("a", True)) == len(self._tokenize("a", False)) + 1
1816
- except Exception: # noqa: BLE001
1817
- return False
1818
-
1819
- def _prepare_prompt(self, prompt: str) -> str:
1820
- bos = getattr(self.tok, "bos_token", None)
1821
- if self.server_adds_bos and bos and prompt.startswith(bos):
1822
- prompt = prompt[len(bos):]
1823
- return prompt
1824
 
1825
  def chat(self, messages, max_new_tokens=GEN_MAX_NEW_TOKENS, temperature=0.4, tools=None):
1826
  import requests
1827
- tool_mode = bool(tools)
1828
  self.server.ensure_running()
1829
- prompt = self._prepare_prompt(_render_chat(self.tok, messages, tools))
1830
- try:
1831
- n_prompt = len(self._tokenize(prompt, True))
1832
- except requests.RequestException:
1833
- n_prompt = -1
1834
- if n_prompt >= 0 and n_prompt + 256 > self.ctx:
1835
- print(f"[llama] prompt is {n_prompt} tokens with ctx={self.ctx} -- refusing to generate", flush=True)
1836
- return {"content": "", "error": "context_too_long", "timed_out": False, "prompt_tokens": n_prompt}
1837
- n_predict = max_new_tokens if n_prompt < 0 else max(64, min(max_new_tokens, self.ctx - n_prompt - 32))
1838
- payload = {"prompt": prompt, "n_predict": n_predict, "temperature": temperature, "top_p": TOP_P, "top_k": TOP_K,
1839
- "stream": True, "cache_prompt": True}
1840
  payload.update(self.extra_payload)
 
 
1841
 
1842
  gap = GEN_PER_CHUNK_GAP_TIMEOUT_S.get(self.kind, 300)
1843
  cap = GEN_MAX_TOTAL_S.get(self.kind, 900)
1844
- chunks, final, err, timed_out, resp = [], None, None, False, None
1845
  start = last_data = last_hb = time.time()
1846
  try:
1847
- resp = requests.post(self.server.base_url + "/completion", json=payload, stream=True, timeout=(15, gap))
1848
- if resp.status_code != 200:
 
 
 
1849
  body = resp.text[:400]
1850
- err = "context_too_long" if "exceed_context_size" in body else f"HTTP {resp.status_code}: {_redact(body)}"
1851
- else:
1852
- # bytes -> utf-8 ourselves: text/event-stream without a charset would be decoded as latin-1 (Arabic!).
1853
- # chunk_size=1: the default (512) blocks until 512 bytes arrive, so with un-chunked framing (HTTP/1.0,
1854
- # some proxies) keep-alive pings could hide a stalled server for minutes (found by the fake-server test).
 
 
 
 
 
 
 
 
 
 
 
1855
  for raw in resp.iter_lines(chunk_size=1):
1856
  now = time.time()
1857
  if now - start > cap:
@@ -1864,9 +1653,12 @@ class LlamaCppClient:
1864
  break
1865
  line = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
1866
  if not line.startswith("data:"):
1867
- continue # SSE keep-alive pings / blank lines
 
 
 
1868
  try:
1869
- d = json.loads(line[5:].strip())
1870
  except ValueError:
1871
  continue
1872
  if "error" in d:
@@ -1874,19 +1666,20 @@ class LlamaCppClient:
1874
  err = _redact(str(e.get("message", e)) if isinstance(e, dict) else str(e))
1875
  break
1876
  last_data = now
1877
- if d.get("content"):
1878
- chunks.append(d["content"])
1879
- if d.get("stop"):
1880
- final = d
1881
- break
 
 
1882
  if now - last_hb > 30:
1883
  print(f"[gen] {sum(len(c) for c in chunks)} chars, {now - start:.0f}s elapsed ({self.kind})", flush=True)
1884
  last_hb = now
1885
  except requests.RequestException as e:
1886
- # While streaming, requests surfaces a socket read timeout as ConnectionError("... Read timed out"),
1887
- # NOT as ReadTimeout -- so match on the message too.
1888
  if isinstance(e, requests.exceptions.Timeout) or "timed out" in str(e).lower():
1889
- print(f"[gen][TIMEOUT] llama-server sent nothing for {gap}s -- treating as stuck.", flush=True)
1890
  timed_out = True
1891
  else:
1892
  err = f"{type(e).__name__}: {_redact(str(e))[:200]}"
@@ -1894,52 +1687,43 @@ class LlamaCppClient:
1894
  if resp is not None:
1895
  resp.close() # closing the connection cancels generation on the server
1896
 
1897
- cached = gen = 0
1898
- if final:
1899
- t = final.get("timings") or {}
1900
- cached, gen = int(t.get("cache_n") or 0), int(final.get("tokens_predicted") or 0)
1901
- print(f"[llama] prompt {final.get('tokens_evaluated')} tok (cached {cached}) prefill "
1902
- f"{t.get('prompt_per_second') or 0:.0f} tok/s | generated {gen} tok @ "
1903
- f"{t.get('predicted_per_second') or 0:.1f} tok/s | stop={final.get('stop_type')}", flush=True)
1904
  if err and err != "context_too_long":
1905
- print(f"[llama] request failed: {err}", flush=True)
1906
- text = _strip_special_tokens(self.tok, "".join(chunks), keep_atem=tool_mode)
1907
- return {"content": text, "error": err, "timed_out": timed_out, "prompt_tokens": n_prompt,
1908
- "cached_tokens": cached, "gen_tokens": gen}
1909
 
1910
  def warmup(self):
1911
  """One tiny generation at startup: proves the whole path works and prints the real speed."""
1912
  r = self.chat([{"role": "user", "content": "Reply with the single word: ready"}], max_new_tokens=24, temperature=0.0)
1913
  ok = bool(r["content"].strip()) and not r["error"]
1914
- print(f"[llama] warmup {'ok' if ok else 'FAILED'}: {r['content'].strip()[:60]!r} {r['error'] or ''}", flush=True)
1915
  return ok
1916
 
1917
 
1918
- def start_llama_backend():
1919
- """-> (client, server). Model download, binary build/download and tokenizer load run in parallel."""
1920
  from concurrent.futures import ThreadPoolExecutor
1921
  acc = detect_accelerators()
1922
- gpu_desc = ", ".join(f"{g['name']} {g['mem_gb']:.1f}GB (sm{g['cc'] or '?'})" for g in acc["gpus"]) or "none"
1923
- print(f"[hardware] llama.cpp backend | GPUs: {gpu_desc} | RAM {acc['ram_gb']:.1f}GB | CPUs {acc['cpus']}", flush=True)
1924
- if acc["kind"] == "cpu":
1925
- print("[hardware][WARNING] no NVIDIA GPU found -> CPU inference, roughly 1 token/s for a 30B model. "
1926
- "On Kaggle choose Accelerator = GPU T4 x2.", flush=True)
1927
- if acc["tpu_hint"]:
1928
- print("[hardware][WARNING] a TPU VM was detected: llama.cpp cannot use TPUs, and Kaggle stops TPU sessions that do "
1929
- "not use the TPU (your last TPU log ended with Exit 137 after 2h idle).", flush=True)
1930
- with ThreadPoolExecutor(max_workers=3) as ex:
1931
- f_model, f_bin, f_tok = ex.submit(download_gguf, acc), ex.submit(ensure_llama_server, acc), ex.submit(load_tokenizer)
1932
- (model_path, meta), (binary, lib_dir, kind), tok = f_model.result(), f_bin.result(), f_tok.result()
1933
- server = LlamaServer(binary, lib_dir, model_path, kind, LLAMA_CTX, acc, meta["size_gb"])
1934
- print(f"[llama] inference device: {'GPU (CUDA)' if kind == 'gpu' else 'CPU'}", flush=True)
1935
  server.start()
1936
- if kind == "gpu" and not re.search(r"offloaded|CUDA\d", server.tail(600)):
1937
- print("[llama][WARNING] the server log does not mention CUDA/offloading -- it may be running on CPU. "
1938
- f"Check {server.log_path}", flush=True)
1939
- client = LlamaCppClient(server, tok, kind)
1940
- print(f"[llama] context per request: {client.ctx} tokens | server adds BOS: {client.server_adds_bos}", flush=True)
1941
  if not client.warmup():
1942
- print("[llama][WARNING] warmup produced no text; continuing, but check llama-server.log", flush=True)
1943
  return client, server
1944
 
1945
 
@@ -4384,7 +4168,7 @@ def _sync_patterns() -> list:
4384
  # filenames (MANIFEST.json/README.md) needed a MODEL_TAG suffix -- done where they are defined above.
4385
  pats = ([f"{m['folder']}/*" for m in DATA_TYPES.values()]
4386
  + [f"state/{MODEL_TAG}/*", os.path.basename(MANIFEST_PATH), os.path.basename(README_PATH)])
4387
- return pats + (["bin/*"] if CACHE_LLAMA_BINARY and BACKEND == "llamacpp" else [])
4388
 
4389
 
4390
  def _with_retry(fn, what: str, tries: int = 3, base_delay: float = 2.0):
@@ -4418,8 +4202,7 @@ def restore_from_hf():
4418
  # other two workers' data down into every container unnecessarily.
4419
  _with_retry(lambda: snapshot_download(
4420
  repo_id=TARGET_DATASET_REPO, repo_type="dataset", token=token, local_dir=WORK_DIR,
4421
- allow_patterns=[f"{m['folder']}/{MODEL_TAG}/*" for m in DATA_TYPES.values()] + [f"state/{MODEL_TAG}/*"]
4422
- + (["bin/*"] if CACHE_LLAMA_BINARY and BACKEND == "llamacpp" else [])), "restore")
4423
  print(f"[restore] pulled prior state from {TARGET_DATASET_REPO} (worker={MODEL_TAG})", flush=True)
4424
  except Exception as e:
4425
  print(f"[restore] nothing to restore yet ({_redact(str(e))})", flush=True)
@@ -4448,16 +4231,16 @@ def sync_to_hf():
4448
  # 14. Main loop -- single process, runs until the container stops (or GIJL_SESSION_BUDGET_S, if set)
4449
  # ---------------------------------------------------------------------------
4450
  def _start_backend():
4451
- """-> (client, close). close() stops llama-server (no-op for the transformers backend)."""
4452
  if BACKEND == "hf":
4453
  hardware = detect_hardware()
4454
  print(f"[hardware] transformers backend, selected: {hardware}", flush=True)
4455
  if hardware == "tpu":
4456
  print("[hardware][WARNING] the transformers path on TPU was ~3 chars/s and hung in XLA compilation (see your TPU "
4457
- "log). Use the default llama.cpp backend with a GPU accelerator instead.", flush=True)
4458
  model, tok = load_model(hardware)
4459
  return ModelClient(model, tok, hardware), (lambda: None)
4460
- client, server = start_llama_backend()
4461
  return client, server.stop
4462
 
4463
 
@@ -4537,7 +4320,7 @@ def main():
4537
  print(f"[cycle] took {time.time() - t_cycle:.0f}s. " + _status_line(cycle, state), flush=True)
4538
  maybe_sync(state)
4539
  if smoke:
4540
- print("[smoke] one cycle finished -- see the [llama] tok/s lines and [gen]/[write] lines above. "
4541
  "Unset GIJL_SMOKE for the full run.", flush=True)
4542
  finally:
4543
  try:
 
1
  """
2
  gijl/agentic-thinker-v1 -- multi-loop dataset generator (single-file app.py)
3
+ v8, worker "deepseek" (DeepSeek-V4-Flash-0731): ds4 backend (v8.2; was llama.cpp) + multi-loop + verified, source-grouped, auditable
4
  output, + genuine agent-task trajectories + five specialized workers (glm/minimax/deepseek/kimi/
5
  qwen) concurrent collection, now with real per-model output-compatibility handling. Sibling files
6
  run the identical pipeline -- see WHAT CHANGED IN v7 and WHAT CHANGED IN v8.
 
310
  whatever "latest" is by then -- no file change needed), or point GIJL_LLAMA_SERVER (already supported,
311
  see ensure_llama_server()) at a self-built binary from an experimental third-party CPU-capable fork --
312
  unofficial, so verify it yourself before trusting it with real data.
313
+ v8.2 (backend swap, this worker only): llama.cpp is REMOVED from this file and replaced by antirez/ds4 (DwarfStar),
314
+ the inference engine written specifically for DeepSeek-V4-Flash -- section 5b. `make cpu` builds a CPU-only ds4-server
315
+ from source on first start (plain C, a few minutes, built in GIJL_DS4_BUILD_DIR, not synced to the hub because it is
316
+ -march=native). ds4 loads ONLY its own GGUFs: GGUF_REPO is now antirez/deepseek-v4-gguf and GGUF_QUANT is one of
317
+ q2 (default, ~81GiB) | q2-q4 (~98GB) | q4 -- the unsloth/llama.cpp GGUFs no longer apply. ds4 renders the DeepSeek chat
318
+ template itself, so the client sends plain `messages` to /v1/chat/completions (tool role folded into user turns, `tools`
319
+ not forwarded, thinking off via `think:false` from _CHAT_TEMPLATE_EXTRA_KWARGS) instead of a locally-rendered raw
320
+ prompt; the tokenizer is no longer loaded for inference. NOTHING outside section 5b, the backend constants, the two
321
+ `bin/*` sync/restore patterns and _start_backend() was touched. UNVERIFIED in this file (no 90GB box available when it
322
+ was written): real CPU speed, and whether ds4-server cancels generation when the client disconnects -- run
323
+ GIJL_SMOKE=1 first and read the [ds4] lines.
324
  FOUR sibling files (glm/minimax/deepseek/kimi/qwen minus this one) run the same pipeline, each specialized
325
  differently -- see WHAT CHANGED IN v7 and WHAT CHANGED IN v8 at the top of this file for the full table and the
326
+ evidence behind each choice. all five write into the SAME dataset concurrently -- see MODEL_TAG and the v6 note above for how that stays race-free. (v8.2: ds4 renders the chat template itself; the weights come from the GGUF repo.) ONE ds4-server process, ONE generation
 
327
  stream (parallel slots = 1), context 12288 tokens by default (GIJL_CTX). The tool-call XML convention below
328
  (<atem:...>) is taught IN THE PROMPT (see build_scout_prompt/build_agent_task_prompt), not a trained-in format of
329
  any one model, so it is unchanged across workers -- but it is still worth a GIJL_SMOKE=1 run per worker before a
330
  long session, to confirm this model actually follows it turn after turn.
331
 
332
+ HARDWARE (ds4 backend, v8.2): CPU by default (`make cpu`, 16 vCPUs / ~103GB file ceiling as in v7); GIJL_DS4_BUILD=cuda-generic
333
+ builds ds4's CUDA path instead. TPU is not supported.
334
  (The old transformers backend's TPU notes: device_map="auto" did NOT place the model on the TPU -- the log showed
335
  "no hf_device_map" -- and generation stalled in XLA compilation.)
336
 
 
376
  Stack Exchange key (label must match SECRET_NAMES below).
377
  3. Optional env: GIJL_ONLY_LOOPS=judgment_dpo,sft_helpful (restrict loops),
378
  GIJL_TARGET_REPO=user/repo, GIJL_WORK_DIR=/some/dir, GIJL_MODEL_ID=..., GIJL_CTX=12288,
379
+ GIJL_GGUF_QUANT=q2|q2-q4|q4, GIJL_DS4_SERVER=/path/to/your/ds4-server, GIJL_REBUILD_DS4=1,
380
+ GIJL_DS4_REF=<tag/branch to pin>, GIJL_DS4_BUILD=cpu|cuda-generic, GIJL_REASONING_GEN=low, GIJL_REASONING_SCOUT=medium
381
  """
382
 
383
  import os
 
549
  SEARCH_LANGUAGE_CYCLE = ["arabic", "english"]
550
 
551
  # --- inference backend -------------------------------------------------------
552
+ # "ds4" (default): antirez/ds4 (DwarfStar) ds4-server + its own GGUF, built from source with `make cpu` (CPU) -- the
553
+ # engine written specifically for DeepSeek-V4-Flash; llama.cpp is no longer used. See section 5b.
554
  # "hf": the old transformers path (only kept for reference; on TPU it produced ~3 chars/s and hung).
555
+ BACKEND = os.environ.get("GIJL_BACKEND", "ds4").strip().lower()
556
+ GGUF_REPO = os.environ.get("GIJL_GGUF_REPO", "antirez/deepseek-v4-gguf") # ds4's own GGUF repo (the unsloth/llama.cpp GGUFs do NOT load in ds4)
557
+ GGUF_QUANT = os.environ.get("GIJL_GGUF_QUANT", "q2") # q2 (~81GiB, ds4f-q2, default) | q2-q4 (~98GB) | q4 (~153GiB) -- see DS4_GGUF_FILES; q2-q4 also fits the 103GB ceiling but needs more RAM
558
+ DS4_CTX = int(os.environ.get("GIJL_CTX", "12288")) # tokens passed to ds4-server --ctx
 
 
 
 
 
 
 
 
 
559
  # Meta's recommended sampling is temperature 1.0 / top_p 0.95 / top_k 64. A slightly lower temperature
560
  # suits structured-JSON tasks; top_p/top_k stay as recommended.
561
  TOP_P, TOP_K = 0.95, 64
 
1328
 
1329
 
1330
  # ---------------------------------------------------------------------------
1331
+ # 5b. ds4 (DwarfStar) backend (DEFAULT): a DeepSeek-V4-Flash-specific engine, CPU build (`make cpu`) or GPU build.
1332
+ # Replaces the llama.cpp backend (llama-server crashed on this model, see the v8.1 field report above).
1333
+ # ds4 only runs its own GGUFs (antirez/deepseek-v4-gguf) and renders the chat template itself.
 
 
 
1334
  # ---------------------------------------------------------------------------
1335
  def _run(cmd, timeout=60, env=None):
1336
  try:
 
1398
  return (acc["ram_gb"] or 24.0) * 0.75 - 2.0
1399
 
1400
 
1401
+ # --- ds4 (DwarfStar) backend: the DeepSeek-V4-Flash-specific engine, replaces llama.cpp ------------------------
1402
+ # WHY (field report in the module docstring, v8.1): llama-server crashed on CPU at startup with this model
1403
+ # (GGML_ASSERT(*cur_backend_id != -1) in ggml_backend_sched_split_graph) -- llama.cpp's DeepSeek-V4 support is
1404
+ # still the weak spot, not this script. antirez/ds4 is an inference engine written ONLY for DeepSeek V4 Flash:
1405
+ # * `make cpu` builds a CPU-only ds4-server (Linux, plain C, no CUDA/Metal needed) -- confirmed in its Makefile.
1406
+ # * it runs ONLY its own GGUF files (repo antirez/deepseek-v4-gguf, "-0731" files = Flash 0731), NOT the
1407
+ # unsloth/llama.cpp GGUFs. GIJL_GGUF_REPO/GIJL_GGUF_QUANT therefore now name THAT repo and one of the
1408
+ # DS4_GGUF_FILES keys below (q2 ~81GiB | q2-q4 ~98GB | q4 ~153GiB).
1409
+ # * it renders the DeepSeek chat template itself (incl. thinking on/off), so this client sends plain chat
1410
+ # `messages` to POST /v1/chat/completions instead of a locally-rendered raw prompt to /completion. The tokenizer
1411
+ # is therefore not needed for inference any more. `tools` is deliberately NOT forwarded: the atem: tool protocol
1412
+ # is taught in the system prompt (see section 6), and a native `tools` field would make the server parse
1413
+ # DeepSeek's own DSML tool calls into a structured field this pipeline never reads.
1414
+ DS4_REPO_URL = os.environ.get("GIJL_DS4_REPO_URL", "https://github.com/antirez/ds4")
1415
+ DS4_REF = os.environ.get("GIJL_DS4_REF", "").strip() # optional tag/branch/commit to pin (main moves fast)
1416
+ DS4_BUILD_TARGET = os.environ.get("GIJL_DS4_BUILD", "cpu").strip() # `make <target>`; anything but "cpu" is a GPU build
1417
+ DS4_BUILD_ROOT = os.environ.get("GIJL_DS4_BUILD_DIR", "/tmp/gijl_ds4_build")
1418
+ DS4_BUILD_TIMEOUT_S = 30 * 60
1419
+ DS4_LOAD_TIMEOUT_S = 3600 # ~80-100GB GGUF has to be read from disk first
1420
+ DS4_MODEL_ALIAS = "deepseek-v4-flash" # the server ignores it (the GGUF picks the model)
1421
+ DS4_GGUF_FILES = { # exact names from ds4's own download_model.sh (ds4f-q2 / ds4f-q2-q4 / ds4f-q4)
1422
+ "q2": "DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix-0731.gguf",
1423
+ "q2-q4": "DeepSeek-V4-Flash-Layers37-42Q4KExperts-OtherExpertLayersIQ2XXSGateUp-Q2KDown-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix-fixed-0731.gguf",
1424
+ "q4": "DeepSeek-V4-Flash-Q4KExperts-F16HC-F16Compressor-F16Indexer-Q8Attn-Q8Shared-Q8Out-chat-v2-imatrix-0731.gguf",
1425
+ }
1426
+ DS4_GGUF_SIZE_GB = {"q2": 87.0, "q2-q4": 98.0, "q4": 164.0} # approximate, from ds4's own download notes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1427
 
1428
 
1429
  def download_gguf(acc: dict):
1430
+ """-> (path_to_gguf, {"quant","size_gb"}). Stored in the default HF cache (NOT /kaggle/working)."""
1431
+ from huggingface_hub import hf_hub_download
 
1432
  token = os.environ.get("HF_TOKEN")
1433
  explicit_file = os.environ.get("GIJL_GGUF_FILE")
1434
  if explicit_file:
1435
  path = hf_hub_download(GGUF_REPO, explicit_file, token=token)
1436
  return path, {"quant": explicit_file, "size_gb": os.path.getsize(path) / 1e9}
1437
+ tag = re.sub(r"^ds4f-", "", (GGUF_QUANT or "q2").strip().lower())
1438
+ if tag not in DS4_GGUF_FILES:
1439
+ raise RuntimeError(f"GIJL_GGUF_QUANT={GGUF_QUANT!r} is not a ds4 quant; use one of {sorted(DS4_GGUF_FILES)} "
1440
+ f"(or GIJL_GGUF_FILE=<exact file name inside {GGUF_REPO}>)")
1441
+ size_gb = DS4_GGUF_SIZE_GB[tag]
1442
+ ram = acc.get("ram_gb") or 0.0
1443
+ if ram and size_gb > ram * 0.92:
1444
+ print(f"[ds4][WARNING] quant={tag} (~{size_gb:.0f}GB) is close to / above this machine's RAM ({ram:.0f}GB): "
1445
+ f"expect swapping or a failed load. Try GIJL_GGUF_QUANT=q2 (~87GB).", flush=True)
1446
+ print(f"[ds4] model: {GGUF_REPO}/{DS4_GGUF_FILES[tag]} quant={tag} (~{size_gb:.0f}GB, RAM {ram:.0f}GB)", flush=True)
 
 
 
 
1447
  t0 = time.time()
1448
+ path = hf_hub_download(GGUF_REPO, DS4_GGUF_FILES[tag], token=token)
1449
+ print(f"[ds4] GGUF ready in {time.time() - t0:.0f}s: {path}", flush=True)
1450
+ return path, {"quant": tag, "size_gb": os.path.getsize(path) / 1e9}
 
 
 
 
 
 
 
 
1451
 
1452
 
1453
+ # --- getting a ds4-server binary ---------------------------------------------------
1454
+ def _ds4_binary_ok(binary: str) -> bool:
1455
+ rc, out, err = _run([binary, "--help"], 90, None)
1456
+ return "--ctx" in (out + err)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1457
 
1458
 
1459
+ def ensure_ds4_server():
1460
+ """-> (binary, workdir). Order: GIJL_DS4_SERVER override, cached build, fresh `make <target>` of antirez/ds4."""
1461
+ override = os.environ.get("GIJL_DS4_SERVER")
1462
+ if override:
1463
+ return override, os.path.dirname(override) or None
1464
+ src = os.path.join(DS4_BUILD_ROOT, "ds4")
1465
+ binary = os.path.join(src, "ds4-server")
1466
+ if os.path.isfile(binary) and os.environ.get("GIJL_REBUILD_DS4") != "1" and _ds4_binary_ok(binary):
1467
+ print(f"[ds4] using cached build {binary}", flush=True)
1468
+ return binary, src
1469
+ for tool in ("git", "make", "cc"):
1470
+ if not shutil.which(tool):
1471
+ raise RuntimeError(f"cannot build ds4: '{tool}' is not installed in this container "
1472
+ f"(or point GIJL_DS4_SERVER at a ds4-server binary you built elsewhere)")
1473
+ log = os.path.join(DS4_BUILD_ROOT, "build.log")
1474
+ os.makedirs(DS4_BUILD_ROOT, exist_ok=True)
1475
+ shutil.rmtree(src, ignore_errors=True) # a half-finished clone/build is worse than a fresh one
 
 
 
 
 
 
 
 
1476
  t0 = time.time()
1477
+ if _run_logged(["git", "clone", "--depth", "1"] + (["--branch", DS4_REF] if DS4_REF else []) + [DS4_REPO_URL, src],
1478
+ os.environ.copy(), log, "ds4-git", 600) != 0:
1479
+ raise RuntimeError(f"git clone of {DS4_REPO_URL} failed:\n{_tail_file(log, 15)}")
 
 
 
 
 
 
 
 
1480
  jobs = str(max(2, os.cpu_count() or 4))
1481
+ print(f"[ds4][build] make {DS4_BUILD_TARGET} -j{jobs} (a few minutes, plain C) ...", flush=True)
1482
+ if _run_logged(["make", "-C", src, DS4_BUILD_TARGET, "-j", jobs], os.environ.copy(), log, "ds4-build",
1483
+ DS4_BUILD_TIMEOUT_S) != 0 or not os.path.isfile(binary):
1484
+ raise RuntimeError(f"ds4 build failed (make {DS4_BUILD_TARGET}):\n{_tail_file(log, 30)}")
1485
+ if not _ds4_binary_ok(binary):
1486
+ raise RuntimeError(f"the freshly built ds4-server does not run here: {binary}")
1487
+ print(f"[ds4][build] done in {time.time() - t0:.0f}s: {binary}", flush=True)
1488
+ return binary, src
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1489
 
1490
 
1491
  def _free_port() -> int:
 
1494
  return s.getsockname()[1]
1495
 
1496
 
1497
+ class Ds4Server:
1498
+ """Owns the ds4-server subprocess: starts it, waits for /v1/models, restarts it if it dies, stops it at exit."""
1499
 
1500
+ def __init__(self, binary, workdir, model_path, kind, ctx, model_gb=0.0, extra_args=None):
1501
+ self.binary, self.workdir, self.model_path, self.kind, self.ctx = binary, workdir, model_path, kind, ctx
1502
+ self.model_gb, self.extra_args = model_gb, list(extra_args or [])
1503
  self.port, self.proc, self.restarts, self._help_text = _free_port(), None, 0, None
1504
+ self.log_path = os.path.join(WORK_DIR, "ds4-server.log")
1505
  atexit.register(self.stop)
1506
 
1507
  @property
 
1510
 
1511
  def _has(self, flag: str) -> bool:
1512
  if self._help_text is None:
1513
+ rc, out, err = _run([self.binary, "--help"], 90, None)
1514
  self._help_text = out + err
1515
  return flag in self._help_text
1516
 
1517
  def command(self) -> list:
1518
+ cmd = [self.binary, "-m", self.model_path, "--host", "127.0.0.1", "--port", str(self.port), "--ctx", str(self.ctx)]
1519
+ if self._has("--threads"):
1520
+ cmd += ["--threads", "16"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1521
  return cmd + self.extra_args
1522
 
1523
  def tail(self, n: int = 25) -> str:
 
1527
  import requests
1528
  os.makedirs(os.path.dirname(self.log_path), exist_ok=True)
1529
  cmd = self.command()
1530
+ print("[ds4] starting: " + " ".join(cmd), flush=True)
1531
  self._log = open(self.log_path, "ab")
1532
+ self.proc = subprocess.Popen(cmd, stdout=self._log, stderr=subprocess.STDOUT, cwd=self.workdir,
1533
  start_new_session=True)
1534
  t0, last = time.time(), time.time()
1535
+ while time.time() - t0 < DS4_LOAD_TIMEOUT_S:
1536
  if self.proc.poll() is not None:
1537
+ raise RuntimeError(f"ds4-server exited during startup (code {self.proc.returncode}). Log tail:\n{self.tail(40)}")
1538
  try:
1539
+ r = requests.get(self.base_url + "/v1/models", timeout=5)
1540
+ if r.status_code == 200:
1541
+ print(f"[ds4] server ready after {time.time() - t0:.0f}s on port {self.port}", flush=True)
 
1542
  return
1543
  except (requests.RequestException, ValueError):
1544
  pass
1545
  if time.time() - last > 30:
1546
+ print(f"[ds4] loading model ... {time.time() - t0:.0f}s", flush=True)
1547
  last = time.time()
1548
  time.sleep(2)
1549
+ raise TimeoutError(f"ds4-server not ready after {DS4_LOAD_TIMEOUT_S}s. Log tail:\n{self.tail(40)}")
 
 
 
 
 
1550
 
1551
  def ensure_running(self):
1552
  if self.proc is not None and self.proc.poll() is None:
1553
  return
1554
  self.restarts += 1
1555
+ print(f"[ds4][WARNING] server is not running (restart #{self.restarts}). Log tail:\n{self.tail(15)}", flush=True)
1556
  if self.restarts > 3:
1557
+ raise RuntimeError("ds4-server keeps dying; giving up")
1558
  self.port = _free_port()
1559
  self.start()
1560
 
 
1572
  pass
1573
 
1574
 
1575
+ class Ds4Client:
1576
+ """Same interface as ModelClient.chat(): streams POST /v1/chat/completions from ds4-server (OpenAI-style SSE).
1577
+ - ds4 renders the DeepSeek chat template itself, so `messages` go over as plain chat. Messages with role='tool'
1578
+ are folded into user messages (the same fallback _render_chat uses) and consecutive same-role messages are
1579
+ merged, so the server only ever sees a clean system/user/assistant alternation.
1580
+ - thinking follows _CHAT_TEMPLATE_EXTRA_KWARGS["enable_thinking"] (False for this worker) via the request's
1581
+ `think` field, which is ds4's documented switch.
1582
+ - a separately streamed reasoning_content is NOT part of the returned text (only visible `content` is).
1583
+ - closing the HTTP connection is how a timeout cancels generation (assumed to be honored by ds4-server like any
1584
+ SSE server -- NOT verified here; if the log shows generation continuing after a [gen][TIMEOUT], that is why)."""
1585
+
1586
+ def __init__(self, server: Ds4Server, kind: str):
1587
+ self.server, self.kind, self.hardware = server, kind, kind
1588
+ self.tok = None # no local tokenizer: ds4 tokenizes and renders the template itself
1589
+ self.ctx = server.ctx
1590
+ self.extra_payload = {} # (tests / advanced use) merged into every request
1591
+ self._dropped = set() # optional request fields this server build rejected with HTTP 400
1592
+
1593
+ @staticmethod
1594
+ def _wire_messages(messages) -> list:
1595
+ out = []
1596
+ for m in messages:
1597
+ role, content = m.get("role", "user"), str(m.get("content", "") or "")
1598
+ if role == "tool":
1599
+ role, content = "user", f"[tool result from {m.get('name', 'tool')}]\n{content}"
1600
+ if out and out[-1]["role"] == role and role != "system":
1601
+ out[-1]["content"] += "\n\n" + content
1602
+ else:
1603
+ out.append({"role": role, "content": content})
1604
+ return out
 
 
 
 
 
 
 
 
 
 
 
1605
 
1606
  def chat(self, messages, max_new_tokens=GEN_MAX_NEW_TOKENS, temperature=0.4, tools=None):
1607
  import requests
 
1608
  self.server.ensure_running()
1609
+ payload = {"model": DS4_MODEL_ALIAS, "messages": self._wire_messages(messages), "stream": True,
1610
+ "temperature": temperature, "top_p": TOP_P, "top_k": TOP_K, "max_tokens": max_new_tokens,
1611
+ "think": bool(_CHAT_TEMPLATE_EXTRA_KWARGS.get("enable_thinking", True)),
1612
+ "stream_options": {"include_usage": True}}
 
 
 
 
 
 
 
1613
  payload.update(self.extra_payload)
1614
+ for k in self._dropped:
1615
+ payload.pop(k, None)
1616
 
1617
  gap = GEN_PER_CHUNK_GAP_TIMEOUT_S.get(self.kind, 300)
1618
  cap = GEN_MAX_TOTAL_S.get(self.kind, 900)
1619
+ chunks, usage, err, timed_out, resp, finish = [], {}, None, False, None, None
1620
  start = last_data = last_hb = time.time()
1621
  try:
1622
+ for _attempt in range(2):
1623
+ resp = requests.post(self.server.base_url + "/v1/chat/completions", json=payload, stream=True,
1624
+ timeout=(15, gap))
1625
+ if resp.status_code == 200:
1626
+ break
1627
  body = resp.text[:400]
1628
+ resp.close()
1629
+ optional = [k for k in ("top_k", "stream_options") if k in payload and k in body]
1630
+ if resp.status_code == 400 and optional and _attempt == 0:
1631
+ for k in optional: # this build does not know the field -> drop it for good and retry
1632
+ self._dropped.add(k)
1633
+ payload.pop(k, None)
1634
+ print(f"[ds4] server rejected {optional}; retrying without", flush=True)
1635
+ continue
1636
+ low = body.lower()
1637
+ err = ("context_too_long" if resp.status_code == 400 and
1638
+ ("context" in low or "too long" in low or "exceed" in low) else
1639
+ f"HTTP {resp.status_code}: {_redact(body)}")
1640
+ resp = None
1641
+ break
1642
+ if resp is not None:
1643
+ # bytes -> utf-8 ourselves (Arabic!); chunk_size=1 so keep-alive pings can't hide a stalled server.
1644
  for raw in resp.iter_lines(chunk_size=1):
1645
  now = time.time()
1646
  if now - start > cap:
 
1653
  break
1654
  line = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
1655
  if not line.startswith("data:"):
1656
+ continue # SSE keep-alive pings / blank lines / comments
1657
+ data = line[5:].strip()
1658
+ if data == "[DONE]":
1659
+ break
1660
  try:
1661
+ d = json.loads(data)
1662
  except ValueError:
1663
  continue
1664
  if "error" in d:
 
1666
  err = _redact(str(e.get("message", e)) if isinstance(e, dict) else str(e))
1667
  break
1668
  last_data = now
1669
+ if isinstance(d.get("usage"), dict):
1670
+ usage = d["usage"]
1671
+ for ch in d.get("choices") or []:
1672
+ delta = ch.get("delta") or {}
1673
+ if delta.get("content"):
1674
+ chunks.append(delta["content"])
1675
+ finish = ch.get("finish_reason") or finish
1676
  if now - last_hb > 30:
1677
  print(f"[gen] {sum(len(c) for c in chunks)} chars, {now - start:.0f}s elapsed ({self.kind})", flush=True)
1678
  last_hb = now
1679
  except requests.RequestException as e:
1680
+ # While streaming, requests surfaces a socket read timeout as ConnectionError("... Read timed out").
 
1681
  if isinstance(e, requests.exceptions.Timeout) or "timed out" in str(e).lower():
1682
+ print(f"[gen][TIMEOUT] ds4-server sent nothing for {gap}s -- treating as stuck.", flush=True)
1683
  timed_out = True
1684
  else:
1685
  err = f"{type(e).__name__}: {_redact(str(e))[:200]}"
 
1687
  if resp is not None:
1688
  resp.close() # closing the connection cancels generation on the server
1689
 
1690
+ n_prompt, gen = int(usage.get("prompt_tokens") or -1), int(usage.get("completion_tokens") or 0)
1691
+ el = max(time.time() - start, 1e-6)
1692
+ print(f"[ds4] prompt {n_prompt if n_prompt >= 0 else '?'} tok | generated "
1693
+ f"{gen if gen else '?'} tok in {el:.0f}s{f' (~{gen / el:.1f} tok/s incl. prefill)' if gen else ''} "
1694
+ f"| finish={finish}", flush=True)
 
 
1695
  if err and err != "context_too_long":
1696
+ print(f"[ds4] request failed: {err}", flush=True)
1697
+ return {"content": "".join(chunks), "error": err, "timed_out": timed_out, "prompt_tokens": n_prompt,
1698
+ "cached_tokens": 0, "gen_tokens": gen}
 
1699
 
1700
  def warmup(self):
1701
  """One tiny generation at startup: proves the whole path works and prints the real speed."""
1702
  r = self.chat([{"role": "user", "content": "Reply with the single word: ready"}], max_new_tokens=24, temperature=0.0)
1703
  ok = bool(r["content"].strip()) and not r["error"]
1704
+ print(f"[ds4] warmup {'ok' if ok else 'FAILED'}: {r['content'].strip()[:60]!r} {r['error'] or ''}", flush=True)
1705
  return ok
1706
 
1707
 
1708
+ def start_ds4_backend():
1709
+ """-> (client, server). Model download and ds4 build run in parallel."""
1710
  from concurrent.futures import ThreadPoolExecutor
1711
  acc = detect_accelerators()
1712
+ kind = "cpu" if DS4_BUILD_TARGET == "cpu" else "gpu"
1713
+ print(f"[hardware] ds4 backend (make {DS4_BUILD_TARGET}) | RAM {acc['ram_gb']:.1f}GB | CPUs {acc['cpus']}", flush=True)
1714
+ if kind == "cpu" and acc["kind"] == "gpu":
1715
+ print("[hardware][WARNING] an NVIDIA GPU is present but GIJL_DS4_BUILD=cpu (default) -> CPU inference. "
1716
+ "For GPU set GIJL_DS4_BUILD=cuda-generic (builds ds4's CUDA path).", flush=True)
1717
+ with ThreadPoolExecutor(max_workers=2) as ex:
1718
+ f_model, f_bin = ex.submit(download_gguf, acc), ex.submit(ensure_ds4_server)
1719
+ (model_path, meta), (binary, workdir) = f_model.result(), f_bin.result()
1720
+ server = Ds4Server(binary, workdir, model_path, kind, DS4_CTX, meta["size_gb"])
1721
+ print(f"[ds4] inference device: {'GPU' if kind == 'gpu' else 'CPU'}", flush=True)
 
 
 
1722
  server.start()
1723
+ client = Ds4Client(server, kind)
1724
+ print(f"[ds4] context per request: {client.ctx} tokens", flush=True)
 
 
 
1725
  if not client.warmup():
1726
+ print("[ds4][WARNING] warmup produced no text; continuing, but check ds4-server.log", flush=True)
1727
  return client, server
1728
 
1729
 
 
4168
  # filenames (MANIFEST.json/README.md) needed a MODEL_TAG suffix -- done where they are defined above.
4169
  pats = ([f"{m['folder']}/*" for m in DATA_TYPES.values()]
4170
  + [f"state/{MODEL_TAG}/*", os.path.basename(MANIFEST_PATH), os.path.basename(README_PATH)])
4171
+ return pats
4172
 
4173
 
4174
  def _with_retry(fn, what: str, tries: int = 3, base_delay: float = 2.0):
 
4202
  # other two workers' data down into every container unnecessarily.
4203
  _with_retry(lambda: snapshot_download(
4204
  repo_id=TARGET_DATASET_REPO, repo_type="dataset", token=token, local_dir=WORK_DIR,
4205
+ allow_patterns=[f"{m['folder']}/{MODEL_TAG}/*" for m in DATA_TYPES.values()] + [f"state/{MODEL_TAG}/*"]), "restore")
 
4206
  print(f"[restore] pulled prior state from {TARGET_DATASET_REPO} (worker={MODEL_TAG})", flush=True)
4207
  except Exception as e:
4208
  print(f"[restore] nothing to restore yet ({_redact(str(e))})", flush=True)
 
4231
  # 14. Main loop -- single process, runs until the container stops (or GIJL_SESSION_BUDGET_S, if set)
4232
  # ---------------------------------------------------------------------------
4233
  def _start_backend():
4234
+ """-> (client, close). close() stops ds4-server (no-op for the transformers backend)."""
4235
  if BACKEND == "hf":
4236
  hardware = detect_hardware()
4237
  print(f"[hardware] transformers backend, selected: {hardware}", flush=True)
4238
  if hardware == "tpu":
4239
  print("[hardware][WARNING] the transformers path on TPU was ~3 chars/s and hung in XLA compilation (see your TPU "
4240
+ "log). Use the default ds4 backend instead.", flush=True)
4241
  model, tok = load_model(hardware)
4242
  return ModelClient(model, tok, hardware), (lambda: None)
4243
+ client, server = start_ds4_backend()
4244
  return client, server.stop
4245
 
4246
 
 
4320
  print(f"[cycle] took {time.time() - t_cycle:.0f}s. " + _status_line(cycle, state), flush=True)
4321
  maybe_sync(state)
4322
  if smoke:
4323
+ print("[smoke] one cycle finished -- see the [ds4] tok/s lines and [gen]/[write] lines above. "
4324
  "Unset GIJL_SMOKE for the full run.", flush=True)
4325
  finally:
4326
  try: