"""deg model: Qwen3.5-0.8B trunk + decision heads + span pointer + LM head (SPEC.md §1, §12). Layout (token ids; plain text delimiters, no new tokens):: trunk : PREFIX "\\n" -- read once per request query : "FIELD /path\\n" ... "OPTIONS:\\n- a\\n- b\\n" "ANSWER:" -- one per decide/extract field, array count, or jev question; continues a copy of the trunk state gen : "KNOWN VALUES:\\n..." "WRITE /path\\n" ... "VALUE:\\n" <|im_end|> Field independence: the trunk holds the context only (never the schema). Every query is a *fork* that continues its own copy of the trunk state (Gated DeltaNet recurrent state + causal-conv tail, attention K/V); forks never see each other. At inference forks run in fixed :data:`SLOTS`-row micro-batches padded to a :data:`BUCKET` multiple of their own length, so a field's kernels and shapes depend only on that field (bitwise independence; ``tests/test_model.py``). Padding is exact: a padded position has ``beta = 0`` and ``g = 0`` in the delta rule (no write, no decay), its K/V are masked, and the conv tail is gathered at each row's length. Heads (fp32, on the final-norm hidden state; ``a`` = hidden at the query's last token, ``o_i`` = hidden at the last token of option line i, ``c_t`` = trunk hidden at context token t): * choice: ``q(a) . k(o_i) / sqrt(d) + u . o_i`` over the options (jev v1 pointer head); * score: a separate pointer head over the level lines; * noul: ``w . a + b`` -> 2-way logits ``[l, 0]`` (``[p_true, p_false]``); * extract: start/end pointers ``qs(a) . ks(c_t)`` over the context tokens only, plus a null logit ``ns(a)`` (``ne(a)``) as one extra outcome; * count: a linear head over 0..:data:`MAX_COUNT` items, masked to the array bounds (index addressed arrays only: run-1 checkpoints, and arrays whose items have no extract leaf); * anchor: per context token ``t`` an independent "an item starts here" logit ``qa(a) . ka(c_t) / sqrt(d) + ua . c_t + b`` (sigmoid, threshold 0.5), masked to the array's region (the whole context, or the parent item's region for a nested array). The item count is the number of anchors: no cap (SPEC.md §12.6); * anchored extract: the span logits of a field inside an anchored item get a learned bias by bucket of ``t - anchor`` and by whether ``t`` lies inside the item's region; * generate: the base LM head (tied embeddings), only for ``x-generate`` fields. Calibration: one temperature per head kind (``choice, score, noul, extract, count``), fit on validation data after training and stored with the checkpoint. """ from __future__ import annotations import bisect import glob import json import math import os import re from dataclasses import dataclass, field from pathlib import Path from typing import Any import torch import torch.nn.functional as F from torch import Tensor, nn from torch.utils.checkpoint import checkpoint from deg import profile # DEG_HF_HUB: another HF hub root (tests on a machine without /data) HF_HUB = os.environ.get("DEG_HF_HUB", "/data/gwillen-cache/huggingface/hub") BASE_GLOB = f"{HF_HUB}/models--Qwen--Qwen3.5-0.8B/snapshots/*" PREFIX = ("<|im_start|>system\nFill JSON Schema fields from the context: decide, copy spans " "from the context, and write text only for fields marked x-generate.<|im_end|>\n" "<|im_start|>user\nCONTEXT:\n") TRUNK_SUFFIX = "\n" EOS_TEXT = "<|im_end|>" CPU_BUCKET = 64 # CPU reference: per-row fork width grid FORK_TOKENS = 16384 # padded fork tokens per batched forward (memory bound only) FORK_BYTES_PER_TOKEN = 128 * 1024 # peak fork-pass work per padded token (CUDA; measured ~114-125 KiB) FORK_MEM_FRACTION = 0.5 # share of free device memory one fork batch may plan to use HEAD_LOGIT_BYTES = 64 * 2**20 # extract logits per head micro-batch; larger sets go to host DENSE_PREFILL_MAX = 8192 # inputs up to this many tokens are read in one shot PREFILL_CHUNK = 4096 # longer inputs stream in chunks of this many tokens (memory bound only) HEAD_ROWS = 8192 # context rows per span-key projection block (memory bound only) MAX_COUNT = 64 # count head capacity (index-addressed arrays only; anchors have no cap) ARRAY_MODES = ("index", "anchor") # model.meta["array_mode"]: how array items are addressed ANCHOR_INIT_BIAS = -6.0 # anchor logits start near p = 0.0025 (few starts among many tokens) # relative-offset buckets (t - anchor, in tokens) for the anchored span bias REL_EDGES = (-256, -64, -16, -4, 0, 1, 2, 4, 8, 16, 32, 64, 128, 256) HEAD_DIM = 256 KINDS = ("choice", "score", "noul", "extract", "count") @dataclass(frozen=True) class BackboneSpec: """One supported base model (``deg.train --backbone NAME``, ``deg.json["backbone"]``). The deg layout (queries, KNOWN VALUES) is the same text for every backbone; a spec says how to load the weights and tokenizer and the chat tokens that differ: ``bos`` (prepended to every trunk), ``pad`` (padding token), ``gen_turn`` (closes the user turn and opens the assistant turn for base-model generation, :func:`deg.infer.base_generate_header`; thinking off), ``prefix`` (system turn + opened user turn before the context; ChatML :data:`PREFIX` by default) and ``eos`` (the end-of-turn token every generated value ends with; ChatML :data:`EOS_TEXT`).""" name: str snapshot_glob: str lm_class: str # transformers class that loads the text model bos: str pad: str gen_turn: str prefix: str = PREFIX eos: str = EOS_TEXT QWEN_GEN_TURN = "<|im_end|>\n<|im_start|>assistant\n\n\n\n\n" def qwen35_spec(size: str) -> BackboneSpec: """Qwen3.5 of any size (``0.8B``, ``2B``, ``4B``, ...): one family, one tokenizer and chat format; layer counts, hidden/MLP widths and heads come from the snapshot's config (:class:`Backbone` reads them).""" return BackboneSpec( f"qwen3.5-{size.lower()}", f"{HF_HUB}/models--Qwen--Qwen3.5-{size.upper()}/snapshots/*", "Qwen3_5ForCausalLM", "", "<|endoftext|>", QWEN_GEN_TURN) # Gemma 4 chat format (HF chat_template, thinking off): ``<|turn>system\n...\n # <|turn>user\n...\n<|turn>model\n``; a model turn ends with ````. Gemma's # vocabulary has no ChatML tokens (``<|im_end|>`` would be ), so the layout's chat # text moves into the spec; the system/user wording is the ChatML layout's. GEMMA_PREFIX = ("<|turn>system\nFill JSON Schema fields from the context: decide, copy spans " "from the context, and write text only for fields marked x-generate.\n" "<|turn>user\nCONTEXT:\n") GEMMA_GEN_TURN = "\n<|turn>model\n" # the revision of google/gemma-4-E2B-it every deg-gemma run uses (lab and the training # box): pinned, so the frozen PLE table and embeddings a checkpoint reuses are its base's GEMMA4_E2B_IT_REVISION = "3e22461f65e89153144f8adb70e3b8c2cc9845a7" def gemma4_spec(repo: str) -> BackboneSpec: """google/gemma-4-E2B(-it): the text model only (Gemma4ForCausalLM; vision/audio towers are not loaded), Gemma's chat format.""" return BackboneSpec(repo.split("/")[-1].lower(), f"{HF_HUB}/models--{repo.replace('/', '--')}/snapshots/*", "Gemma4ForCausalLM", "", "", GEMMA_GEN_TURN, GEMMA_PREFIX, "") BACKBONES = { "qwen3.5-0.8b": qwen35_spec("0.8B"), "qwen3.5-2b": qwen35_spec("2B"), # LiquidAI LFM2.5-1.2B-Instruct: 10 gated short-conv + 6 GQA attention layers, hidden # 2048, tied embeddings, ChatML with a <|startoftext|> BOS, no thinking block "lfm2.5-1.2b": BackboneSpec( "lfm2.5-1.2b", f"{HF_HUB}/models--LiquidAI--LFM2.5-1.2B-Instruct/snapshots/*", "Lfm2ForCausalLM", "<|startoftext|>", "<|pad|>", "<|im_end|>\n<|im_start|>assistant\n"), # Google Gemma 4 E2B: 35 attention layers (28 sliding-window 512 + 7 global), the last # 20 reuse the K/V of layers 13/14, per-layer embeddings (PLE), hidden 1536, 262k vocab "gemma-4-e2b-it": gemma4_spec("google/gemma-4-E2B-it"), "gemma-4-e2b": gemma4_spec("google/gemma-4-E2B"), } DEFAULT_BACKBONE = "qwen3.5-0.8b" def backbone_name(name: str) -> str: """Canonical backbone name: ``Qwen/Qwen3.5-2B`` (HF repo id, ``--base-model``) and ``qwen3.5-2b`` are the same backbone; ``LiquidAI/LFM2.5-1.2B-Instruct`` is ``lfm2.5-1.2b``.""" low = name.strip().lower() if low == "liquidai/lfm2.5-1.2b-instruct": return "lfm2.5-1.2b" for org in ("qwen/", "google/"): if low.startswith(org): low = low[len(org):] return low META_FILE = "deg.json" # checkpoints saved before the rename to deg carry their metadata under this name LEGACY_META_FILE = "deg.json" def meta_path(ckpt: Path | str) -> Path: """The metadata file of checkpoint dir ``ckpt``: ``deg.json``, else the legacy name of checkpoints saved before the rename, else ``deg.json`` (absent).""" ckpt = Path(ckpt) for name in (META_FILE, LEGACY_META_FILE): if (ckpt / name).is_file(): return ckpt / name return ckpt / META_FILE def read_meta(ckpt: Path | str) -> dict[str, Any]: """The parsed metadata of checkpoint dir ``ckpt`` (see :func:`meta_path`).""" return json.loads(meta_path(ckpt).read_text()) def backbone_spec(name: str | None = None) -> BackboneSpec: name = backbone_name(name or DEFAULT_BACKBONE) if name in BACKBONES: return BACKBONES[name] m = re.fullmatch(r"qwen3\.5-(\d+(?:\.\d+)?b)", name) if m: # any other Qwen3.5 size: the same family, sized by its config return qwen35_spec(m.group(1)) raise ValueError(f"backbone must be one of {sorted(BACKBONES)} or qwen3.5-: " f"{name!r}") def base_snapshot(backbone: str | None = None) -> Path: spec = backbone_spec(backbone) found = sorted(glob.glob(spec.snapshot_glob)) if not found: raise FileNotFoundError(f"{spec.name} snapshot not found under {spec.snapshot_glob}") return Path(found[-1]) def load_base_lm(backbone: str | None = None, dtype: torch.dtype = torch.bfloat16 ) -> nn.Module: """The base causal LM of ``backbone`` (text model only) from the local HF cache.""" import transformers spec = backbone_spec(backbone) snap = str(base_snapshot(spec.name)) if spec.lm_class == "Gemma4ForCausalLM": # the hub checkpoint is the multimodal Gemma4ForConditionalGeneration: load its text # model only (model.language_model.* -> model.*; towers are skipped) cfg = transformers.AutoConfig.from_pretrained(snap) return transformers.Gemma4ForCausalLM.from_pretrained( snap, config=getattr(cfg, "text_config", cfg), dtype=dtype, key_mapping={r"^model\.language_model\.": "model."}) return getattr(transformers, spec.lm_class).from_pretrained(snap, dtype=dtype) def spec_of_lm(lm: nn.Module, backbone: str | None = None) -> BackboneSpec: """The backbone spec whose architecture ``lm`` is (tests pass tiny random models): ``backbone`` when it names that architecture's family (a tiny 2B-shaped Qwen3.5 may stand in for ``qwen3.5-2b``), else the family's default size.""" kind = lm.config.model_type cls = {"lfm2": "Lfm2ForCausalLM", "gemma4_text": "Gemma4ForCausalLM"}.get( kind, "Qwen3_5ForCausalLM") if backbone is not None: spec = backbone_spec(backbone) if spec.lm_class != cls: raise ValueError(f"lm is a {kind} model, not {spec.name}") return spec return BACKBONES[{"Lfm2ForCausalLM": "lfm2.5-1.2b", "Gemma4ForCausalLM": "gemma-4-e2b-it"}.get(cls, "qwen3.5-0.8b")] # ------------------------------------------------------------------------------ encoding @dataclass(frozen=True) class Query: """One fork's input: what the field is, rendered from the field alone. ``anchor``/``region`` (context token indices; ``region`` = [lo, hi), hi = -1 means the end of the context) locate the item this query belongs to: an anchor query's logits are masked to ``region``; an extract field inside an anchored item gets the relative span bias from ``anchor`` and ``region[1]``. ``anchor = -1``: not inside an anchored item.""" kind: str # choice | score | noul | extract | count | anchor header: str # FIELD/ITEM/TITLE/DESCRIPTION/KIND lines options: tuple[str, ...] = () anchor: int = -1 region: tuple[int, int] = (0, -1) @dataclass(frozen=True) class EncodedQuery: kind: str ids: tuple[int, ...] option_positions: tuple[int, ...] answer_position: int anchor: int = -1 region: tuple[int, int] = (0, -1) @dataclass(frozen=True) class EncodedContext: ids: tuple[int, ...] ctx_start: int # first context token index in ids offsets: tuple[tuple[int, int], ...] # per context token: [start, end) code points # multimodal contexts (Gemma 4): per media item (index in ids of its first soft token, # soft-token embeddings [n, hidden]); its n placeholder tokens are replaced by them media: tuple[tuple[int, Tensor], ...] = () @dataclass(frozen=True) class Media: """One image / audio item of a multimodal context: ``embeds`` [n, hidden] are its soft tokens in the language model's space (:meth:`MediaTowers.image_embeds` / :meth:`MediaTowers.audio_embeds`, or a precomputed cache).""" kind: str # image | audio embeds: Tensor # grounding (deg.ground): the record's media id and the source geometry a pointer maps # back to (image (width, height) pixels, audio seconds); rendering ignores them id: str | None = None size: tuple[int, int] | None = None seconds: float | None = None class Renderer: def __init__(self, tokenizer: Any, spec: BackboneSpec | None = None) -> None: spec = spec or backbone_spec() self.tokenizer = tokenizer self.eos_id = tokenizer.convert_tokens_to_ids(spec.eos) self.pad_id = tokenizer.convert_tokens_to_ids(spec.pad) unk = getattr(tokenizer, "unk_token_id", None) if unk is not None and unk in (self.eos_id, self.pad_id): raise ValueError(f"{spec.name}: eos {spec.eos!r} / pad {spec.pad!r} is not a " "token of this tokenizer") self.gen_turn = spec.gen_turn # base-model generation: assistant turn opener self._prefix = self.enc(spec.bos + spec.prefix) self._suffix = self.enc(TRUNK_SUFFIX) def enc(self, text: str) -> list[int]: return list(self.tokenizer(text, add_special_tokens=False)["input_ids"]) if text else [] def context(self, text: str | list[str | Media]) -> EncodedContext: """The trunk of a context: text, or (multimodal, Gemma 4) a list of text parts and :class:`Media` items. Offsets are code points of the joined text parts; a media item renders as HF Gemma 4's placeholder span (`` *n ``, audio ``