you-are-a-bug / engine.py
jnalv's picture
Upload folder using huggingface_hub
1082b22 verified
Raw
History Blame Contribute Delete
14.8 kB
"""engine.py — model inference + code-owned game rules. No Gradio in here.
The model proposes (stat + difficulty band) and narrates; this module owns all
randomness and arithmetic (DECISIONS §2): the dice, HP damage, XP, leveling,
and the day clock. It also owns the llama.cpp singleton and the lenient JSON
parser that degrades malformed output to a `say` (§13).
"""
import json
import os
import random
import re
import threading
import roster
from assemble_context import assemble_context
try:
import spaces # no-op decorator off Hugging Face Spaces
except ImportError:
spaces = None
# On ZeroGPU the H200 exists only inside @spaces.GPU calls, so the model must
# load there (all layers offloaded); anywhere else we run llama.cpp on CPU.
ZEROGPU = spaces is not None and bool(os.environ.get("SPACES_ZERO_GPU"))
APP_DIR = os.path.dirname(os.path.abspath(__file__))
# Frozen serve system prompt — the same string baked into every training row.
with open(os.path.join(APP_DIR, "system-prompt.md"), encoding="utf-8") as _f:
SYSTEM_PROMPT = _f.read().strip()
# --- Tunables ---------------------------------------------------------------
# Calibration-prone; revisit after playtesting.
TURNS_PER_DAY = 24
# How many recent log entries ride on the card (window size, DECISIONS §15 open).
LOG_WINDOW = 12
# 2d6 + stat >= DC.
BAND_DC = {"easy": 6, "medium": 8, "hard": 10}
# HP lost on a failed roll. MUST match explode_transcript.BAND_DAMAGE —
# the training data applied these exact values to the cards the model saw.
BAND_DAMAGE = {"easy": 0, "medium": 2, "hard": 3}
# XP gained on a successful roll.
BAND_XP = {"easy": 1, "medium": 2, "hard": 3}
# Cumulative XP required to *reach* each level. Level 7 is the cap.
XP_LADDER = {2: 4, 3: 10, 4: 18, 5: 28, 6: 40, 7: 54}
MAX_LEVEL = 7
DEATH_MESSAGE = (
"Your strength gives out, and the forest gently reclaims you. "
"Moss will grow where you fell. You were a good bug."
)
TORPOR_MESSAGE = (
"Torpor takes hold, and your day ends. Bugs have short memories, and the "
"forest is ever-shifting... Continue Turn to start a new day."
)
# --- World seeds & loading strings ------------------------------------------
with open(os.path.join(APP_DIR, "world_states.json"), encoding="utf-8") as _f:
WORLD_STATES = json.load(_f)
with open(os.path.join(APP_DIR, "loading-strings.txt"), encoding="utf-8") as _f:
LOADING_STRINGS = [line.strip() for line in _f if line.strip()]
def shuffled_world_deck():
deck = list(WORLD_STATES)
random.shuffle(deck)
return deck
def shuffled_loading_strings():
deck = list(LOADING_STRINGS)
random.shuffle(deck)
return deck
# --- Model loading ------------------------------------------------------------
# Two trained branches live in parallel. Flip MODEL_FAMILY to "llama" to fall
# back to the 3B-f16-v4 model (works on the HF Space CPU); "qwen3" loads the
# 8B-Q8_0 (heavier, persona-grounded, needs GPU to be tolerable).
MODEL_FAMILY = os.environ.get("BUG_MODEL_FAMILY", "qwen3")
_FAMILIES = {
"llama": {
"repo": "jnalv/you-are-a-bug-llama-3.2-3B",
"file": "you-are-a-bug-3B-f16-v4.gguf",
},
"qwen3": {
"repo": "jnalv/you-are-a-bug-qwen3-8B",
"file": "you-are-a-bug-qwen3-8B-Q8_0.gguf",
},
}
MODEL_REPO = _FAMILIES[MODEL_FAMILY]["repo"]
MODEL_FILE = _FAMILIES[MODEL_FAMILY]["file"]
N_CTX = 4096
# HF Spaces CPU Basic has 2 vCPUs, but the container reports the host's core
# count, so llama.cpp would otherwise oversubscribe and thrash. Override
# locally via BUG_N_THREADS.
N_THREADS = int(os.environ.get("BUG_N_THREADS", "2"))
_llm = None
# Spaces serve many sessions off one process; the single CPU model must
# answer one card at a time.
_llm_lock = threading.Lock()
def _model_path():
override = os.environ.get("BUG_MODEL_PATH")
if override:
return override
from huggingface_hub import hf_hub_download
return hf_hub_download(MODEL_REPO, MODEL_FILE)
def _preload_cuda():
"""Preload the pip-packaged CUDA runtime (RTLD_GLOBAL) so the dynamic
linker can resolve libllama.so's libcudart/libcublas deps — the ZeroGPU
image does not put them on the default search path."""
import ctypes
import glob
for pkg_glob in (
"nvidia/cuda_runtime/lib/libcudart.so.*",
"nvidia/cublas/lib/libcublasLt.so.*",
"nvidia/cublas/lib/libcublas.so.*",
):
for site_dir in __import__("site").getsitepackages():
for lib in sorted(glob.glob(os.path.join(site_dir, pkg_glob))):
try:
ctypes.CDLL(lib, mode=ctypes.RTLD_GLOBAL)
except OSError:
pass
def get_llm():
global _llm
with _llm_lock:
if _llm is None:
# The CUDA-built wheel needs libcudart just to dlopen, even when
# running CPU-only, so always preload (no-op if libs are absent).
_preload_cuda()
from llama_cpp import Llama
_llm = Llama(
model_path=_model_path(),
n_ctx=N_CTX,
n_threads=N_THREADS,
n_threads_batch=N_THREADS,
n_gpu_layers=-1 if ZEROGPU else 0,
verbose=False,
)
return _llm
def warmup():
"""Startup warm: on ZeroGPU only prefetch the GGUF (CUDA must not be
touched outside @spaces.GPU calls); on CPU, load the model outright."""
if ZEROGPU:
_model_path()
else:
get_llm()
# --- Inference ----------------------------------------------------------------
_ROLL_KEYS = {"stat", "difficulty", "on_success", "on_fail"}
_STATS = {"might", "speed", "smarts", "mystique"}
# Salvage patterns for the model's known shapes when narration contains
# unescaped double-quotes that break json.loads (the §13 no-quotes convention,
# violated). Greedy (.*) so interior quotes stay inside the field.
_SAY_SALVAGE = re.compile(
r'^\s*\{\s*"action"\s*:\s*"say"\s*,\s*"text"\s*:\s*"(.*)"\s*\}\s*$', re.S
)
_ROLL_SALVAGE = re.compile(
r'^\s*\{\s*"action"\s*:\s*"roll"\s*,\s*"stat"\s*:\s*"(\w+)"\s*,'
r'\s*"difficulty"\s*:\s*"(\w+)"\s*,\s*"on_success"\s*:\s*"(.*)"\s*,'
r'\s*"on_fail"\s*:\s*"(.*)"\s*\}\s*$',
re.S,
)
# Lines the model sometimes parrots from the card (or bare action words)
# when it drops the JSON wrapper around otherwise-good narration.
_ECHO_LINES = {"PLAYER", "THE WORLD", "WHAT JUST HAPPENED", "PLAYER'S TURN", "say", "roll"}
def _unescape(value: str) -> str:
return value.replace('\\"', '"').replace("\\n", "\n")
def _log_degraded(raw, path):
# Surfaces in Spaces container logs so fallback turns stay diagnosable.
print(f"[parse-degraded:{path}] {raw!r}", flush=True)
def _salvage_roll_fields(text):
"""Field-by-field salvage for roll JSON that is truncated (max_tokens) or
quote-broken. Returns a roll dict, a say dict (when only on_success
narration survives), or None."""
stat = re.search(r'"stat"\s*:\s*"(\w+)"', text)
band = re.search(r'"difficulty"\s*:\s*"(\w+)"', text)
if not (stat and band) or stat.group(1) not in _STATS or band.group(1) not in BAND_DC:
return None
succ = re.search(r'"on_success"\s*:\s*"(.*?)(?:"\s*,\s*"on_fail"|"?\s*\}?\s*$)', text, re.S)
fail = re.search(r'"on_fail"\s*:\s*"(.*?)"?\s*\}?\s*$', text, re.S)
succ_text = _unescape(succ.group(1)).strip() if succ else ""
fail_text = _unescape(fail.group(1)).strip() if fail else ""
if succ_text and fail_text:
return {
"action": "roll",
"stat": stat.group(1),
"difficulty": band.group(1),
"on_success": succ_text,
"on_fail": fail_text,
}
if succ_text:
# on_fail lost to truncation: a roll can't resolve, but the narration
# is still better than a stock line.
return {"action": "say", "text": succ_text}
return None
def _strip_log_tag(line):
"""'[dm · Might check, Easy] The log gives.' -> 'The log gives.'"""
return re.sub(r"^\s*\[[^\]]*\]\s*", "", line)
def parse_output(raw):
"""Lenient parse of the model's one-JSON-object turn (DECISIONS §13).
Anything that isn't a well-formed roll/say degrades to a say so the game
never stalls on a malformed turn.
"""
text = (raw or "").strip()
obj = None
try:
obj = json.loads(text)
except (json.JSONDecodeError, ValueError):
match = re.search(r"\{.*\}", text, re.S)
if match:
try:
obj = json.loads(match.group(0))
except (json.JSONDecodeError, ValueError):
obj = None
if not isinstance(obj, dict):
say = _SAY_SALVAGE.match(text)
if say:
_log_degraded(raw, "say-salvage")
return {"action": "say", "text": _unescape(say.group(1))}
roll = _ROLL_SALVAGE.match(text)
if roll and roll.group(1) in _STATS and roll.group(2) in BAND_DC:
_log_degraded(raw, "roll-salvage")
return {
"action": "roll",
"stat": roll.group(1),
"difficulty": roll.group(2),
"on_success": _unescape(roll.group(3)),
"on_fail": _unescape(roll.group(4)),
}
salvaged = _salvage_roll_fields(text)
if salvaged:
_log_degraded(raw, "roll-fields")
return salvaged
# A card echo (model parroting its prompt) is worse than a stock line.
if not text or text.startswith("PLAYER\n") or "\nTHE WORLD\n" in text:
_log_degraded(raw, "card-echo")
return {"action": "say", "text": "Something went wrong in the forest. Nature is like that."}
# Headers-then-prose shape ('PLAYER'S TURN\nsay\n<narration>'): drop the
# echoed labels; strip '[dm · ...]' log tags but keep their narration.
kept = [
_strip_log_tag(line)
for line in text.splitlines()
if _strip_log_tag(line).strip() not in _ECHO_LINES
]
kept = [line for line in kept if line.strip()]
_log_degraded(raw, "prose")
text = "\n".join(kept).strip() or "Something went wrong in the forest. Nature is like that."
return {"action": "say", "text": text}
action = obj.get("action")
if (
action == "roll"
and _ROLL_KEYS <= obj.keys()
and obj.get("stat") in _STATS
and obj.get("difficulty") in BAND_DC
):
return {k: obj[k] for k in ("action", *_ROLL_KEYS)}
if action == "say" and isinstance(obj.get("text"), str):
return {"action": "say", "text": obj["text"]}
# Wrong/missing keys: salvage any narration we can find.
_log_degraded(raw, "wrong-keys")
fallback = obj.get("text") or obj.get("on_success") or text
return {"action": "say", "text": str(fallback)}
def _qwen3_prompt(card):
# Qwen3's GGUF chat template defaults to thinking-on, which would wrap the
# JSON response in <think>…</think>. We trained with enable_thinking=False,
# so build the prompt by hand and stop at <|im_end|>. Empty <think></think>
# blocks still sometimes leak in; parse_output handles them downstream.
return (
f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n"
f"<|im_start|>user\n{card}<|im_end|>\n"
f"<|im_start|>assistant\n"
)
def _generate(card, max_tokens, temperature, top_p):
llm = get_llm()
with _llm_lock:
if MODEL_FAMILY == "qwen3":
out = llm(
_qwen3_prompt(card),
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
stop=["<|im_end|>"],
)
return out["choices"][0]["text"]
out = llm.create_chat_completion(
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": card},
],
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
)
return out["choices"][0]["message"]["content"]
if spaces is not None:
# Runs in a forked worker with the H200 attached; budget covers a cold
# model load (GGUF -> VRAM) plus generation.
_generate = spaces.GPU(duration=120)(_generate)
def infer(card, max_tokens=384, temperature=0.7, top_p=0.95):
"""One inference: [system, user-card] -> parsed action dict."""
return parse_output(_generate(card, max_tokens, temperature, top_p))
# --- Cards ----------------------------------------------------------------------
def opening_card(sheet, daily_context):
"""Turn-0 card. system_prompt='' matches the training split: the frozen
prompt rides as the system message, the card keeps its leading blank block."""
return assemble_context(
sheet=sheet,
daily_context=daily_context,
system_prompt="",
opening=True,
)
def turn_card(sheet, daily_context, log, message, activations):
return assemble_context(
sheet=sheet,
daily_context=daily_context,
system_prompt="",
log=log[-LOG_WINDOW:],
user_input={"message": message, "activations": activations},
)
# --- Dice, damage, XP ------------------------------------------------------------
def resolve_roll(stat_value, band):
"""2d6 + stat vs the band's DC -> 'success' | 'fail'."""
total = random.randint(1, 6) + random.randint(1, 6) + stat_value
return "success" if total >= BAND_DC[band] else "fail"
def apply_damage(sheet, band):
hp = sheet["hp"]
new_cur = max(0, hp["cur"] - BAND_DAMAGE[band])
return {**sheet, "hp": {**hp, "cur": new_cur}}
def level_for_xp(xp):
level = 1
for lvl, threshold in sorted(XP_LADDER.items()):
if xp >= threshold:
level = lvl
return level
def maybe_level_up(sheet, xp):
"""Return (sheet, announcements). Carries damage: the level-up grants the
+1 max HP/Moxie but does not heal (runs stay lethal, §7)."""
new_level = min(level_for_xp(xp), MAX_LEVEL)
if new_level <= sheet["level"]:
return sheet, []
old = sheet
new = roster.load_at_level(sheet["bug"], new_level)
for pool in ("hp", "moxie"):
gained = new[pool]["max"] - old[pool]["max"]
new[pool]["cur"] = min(new[pool]["max"], old[pool]["cur"] + gained)
announcements = [f"You have grown! You are now level {new_level}."]
old_abilities = {a["name"] for a in old.get("abilities", [])}
for ability in new.get("abilities", []):
if ability["name"] not in old_abilities:
announcements.append(f"New ability unlocked: {ability['name']}!")
return new, announcements