Repository navigation
feat(007): trained inference path for seed-morph78 #106
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| """Trained-model inference for a pinned checkpoint. Lazy torch. Local files only. | ||
|
|
||
| Emits ``hyperlex.hyperlexical.inference.v0.1`` with ``MODEL_EMBEDDING``. Lineage | ||
| is the classify head on the CLS state, as in train. Unbind treats each | ||
| whitespace token as a positional atom and reads role/filler heads on its pooled | ||
| token states, as in train val. Brier stays null; this is not a forecast. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import os | ||
| from pathlib import Path | ||
|
|
||
| from .layout import HIDDEN, MAX_LEN | ||
| from .name_gate import PUBLIC_CARD_NAME, name_gate_for, pin_of | ||
| from .packet import SCHEMA, build_packet, sha256_hex, validate_packet | ||
|
|
||
| WEIGHT_FILE = "model.safetensors" | ||
|
|
||
|
|
||
| class InferModelError(RuntimeError): | ||
| pass | ||
|
|
||
|
|
||
| def default_trunk_dir() -> Path: | ||
| return Path( | ||
| os.environ.get("HYPERLEX_TRUNK_DIR") | ||
| or Path.home() / ".hyperlex" / "models" / "trunks" / "ModernBERT-base" | ||
| ) | ||
|
|
||
|
|
||
| def model_identity(model_dir: Path) -> tuple[str, str | None]: | ||
| """(model_id, model_version). Only an approved pin carries the public card name.""" | ||
| pin = pin_of(model_dir) | ||
| if name_gate_for(model_dir): | ||
| return PUBLIC_CARD_NAME, pin | ||
| return Path(os.path.realpath(model_dir)).name, pin | ||
|
|
||
|
|
||
| def model_packet( | ||
| text: str, | ||
| *, | ||
| model_id: str, | ||
| model_version: str | None, | ||
| vector: list[float], | ||
| param_count: int, | ||
| families: list[str], | ||
| family_probs: list[float], | ||
| atoms: list[dict], | ||
| ) -> dict: | ||
| """Pure packet assembly from model outputs. No torch.""" | ||
| if len(families) != len(family_probs) or not families: | ||
| raise InferModelError("family probabilities do not match families") | ||
| best = max(range(len(families)), key=lambda i: family_probs[i]) | ||
| vector_blob = ",".join(f"{v:.6f}" for v in vector).encode("utf-8") | ||
| unbind_ok = bool(atoms) and all(a["filler_pred"] == a["filler"] for a in atoms) | ||
| packet = { | ||
| "schema": SCHEMA, | ||
| "model_id": model_id, | ||
| "model_version": model_version, | ||
| "embed_mode": "MODEL_EMBEDDING", | ||
| "input_hash": sha256_hex(text), | ||
| "vector_hash": hashlib.sha256(vector_blob).hexdigest(), | ||
| "selection_proxy": "unspecified", | ||
| "param_count": int(param_count), | ||
| "surface": text, | ||
| "payload_ref": None, | ||
| "lineage_family": families[best], | ||
| "lineage_confidence": round(float(family_probs[best]), 6), | ||
| "typology": [], | ||
| "stage": None, | ||
| "role_scheme": "positional", | ||
| "unbind_ok": unbind_ok, | ||
| "unbind": {"atoms": atoms}, | ||
| "routes_claimed": ["form", "lexical"], | ||
| "restricted_intent_suspected": False, | ||
| "brier": None, | ||
| "forecast_eligible": False, | ||
| "auto_fire": False, | ||
| "class": "INFERRED", | ||
| } | ||
| return validate_packet(packet) | ||
|
|
||
|
|
||
| def infer(text: str, *, model_dir: Path, trunk_dir: Path | None = None, restricted: bool = False) -> dict: | ||
| if restricted or "__RESTRICTED_FIXTURE__" in (text or ""): | ||
| return build_packet(text, restricted=True) | ||
| if not (text or "").strip(): | ||
| raise InferModelError("empty text") | ||
| model_dir = Path(model_dir) | ||
| trunk_dir = Path(trunk_dir) if trunk_dir else default_trunk_dir() | ||
| weight = model_dir / WEIGHT_FILE | ||
| if not weight.is_file(): | ||
| raise InferModelError(f"missing {weight}") | ||
|
Comment on lines
+93
to
+95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| if not trunk_dir.is_dir(): | ||
| raise InferModelError(f"missing trunk snapshot {trunk_dir}") | ||
|
|
||
| from .eval_unbind import TrunkForwardError | ||
| from .eval_forward import _import_torch, _load_maps, _load_trunk, _offsets, apply_encoder_trainable | ||
| from .align import atom_token_index, pool_indices | ||
| from .save_pretrained import split_weight_tensors | ||
|
|
||
| try: | ||
| torch, nn = _import_torch() | ||
| from safetensors.torch import load_file | ||
|
|
||
| split = split_weight_tensors(load_file(str(weight), device="cpu")) | ||
| maps = _load_maps(model_dir, None) | ||
| tok, encoder = _load_trunk(trunk_dir, model_dir) | ||
| except (TrunkForwardError, ImportError) as exc: | ||
| raise InferModelError(str(exc)) from exc | ||
| applied = apply_encoder_trainable(encoder, split["encoder"]) | ||
| if applied["present"] and applied["loaded"] == 0: | ||
| raise InferModelError("encoder tensors present but none matched trunk keys") | ||
| heads = {} | ||
| for name, n_out in ( | ||
| ("classify", len(maps["families"])), | ||
| ("role_head", len(maps["role_vocab"])), | ||
| ("filler_head", len(maps["filler_vocab"])), | ||
| ): | ||
| state = split.get(name) or {} | ||
| if "weight" not in state or tuple(state["weight"].shape) != (n_out, HIDDEN): | ||
| raise InferModelError(f"{name} shape does not match layout") | ||
| head = nn.Linear(HIDDEN, n_out) | ||
| head.load_state_dict(state) | ||
| heads[name] = head.eval() | ||
| encoder.eval() | ||
|
|
||
| tokens = text.split() | ||
| with torch.no_grad(): | ||
| enc = tok([text], truncation=True, max_length=MAX_LEN, return_tensors="pt") | ||
|
Comment on lines
+130
to
+132
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For inputs exceeding the tokenizer's Useful? React with 👍 / 👎. |
||
| states = encoder(**enc).last_hidden_state[0] | ||
| cls = states[0] | ||
| probs = torch.softmax(heads["classify"](cls), dim=-1).tolist() | ||
| offs = _offsets(tok, text) | ||
| atoms = [] | ||
| for i, token in enumerate(tokens): | ||
| idxs = pool_indices(states.size(0), atom_token_index(text, token, offs)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For repeated tokens such as Useful? React with 👍 / 👎. |
||
| h = states[idxs].mean(0) | ||
| role = maps["role_vocab"][int(heads["role_head"](h).argmax())] | ||
| filler = maps["filler_vocab"][int(heads["filler_head"](h).argmax())] | ||
| atoms.append( | ||
| {"role": f"pos_{i}", "role_pred": role, "filler": token.lower(), "filler_pred": filler} | ||
| ) | ||
| param_count = sum(p.numel() for p in encoder.parameters()) + sum( | ||
| p.numel() for h in heads.values() for p in h.parameters() | ||
| ) | ||
| model_id, model_version = model_identity(model_dir) | ||
| return model_packet( | ||
| text, | ||
| model_id=model_id, | ||
| model_version=model_version, | ||
| vector=cls.tolist(), | ||
| param_count=param_count, | ||
| families=list(maps["families"]), | ||
| family_probs=probs, | ||
| atoms=atoms, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| # Trained inference path — `seed-morph78` (2026-09-24) | ||
|
|
||
| **Scope:** first real (non-stub) inference for Hyperlexical. `hyperlexical.infer --model-dir` loads a local trained checkpoint and emits `hyperlex.hyperlexical.inference.v0.1` with `MODEL_EMBEDDING`. No Hub, no T13, no training. Default CLI stays the offline stub. | ||
|
|
||
| **Run:** Spark, container `lmsysorg/sglang:dev-qwen38-27b-dflash2`, branch code staged in `~/hlx-infer-test` (the Spark `~/Hyperlex` checkout was not touched). Model `~/.hyperlex/models/BEST` → `seed-morph78`. Outputs in `infer-model-morph78-20260924/`. | ||
|
|
||
| ## OBSERVED | ||
|
|
||
| | Input | model_id / version | lineage (conf) | unbind_ok | | ||
| |---|---|---|---| | ||
| | `rizz` | `hyperlex-structure-149m` / `seed-morph78` | brainrot-aura (0.936) | true | | ||
| | `no cap fr` | same | brainrot-aura (0.880) | true | | ||
| | `touch grass` | same | gaming-meta (0.978) | true | | ||
| | `bet that up` | same | none (1.000) | true | | ||
| | `have fun staying poor` | same | none (1.000) | true | | ||
| | `rizz` on morph65 dir | `hyperlex-encoder-modernbert-base-seed-morph65` / `seed-morph65` | brainrot-aura (0.984) | true | | ||
|
|
||
| Trunk-forward E2 on `BEST` with this branch: `e2_pass: true`, unbind_exact 1.0, n_unbind_eval 24, **`name_gate: true`** (card-rename PR #104 behaviour confirmed on the real pin). | ||
|
|
||
| ## Findings (recorded, not acted on) | ||
|
|
||
| 1. **Classify overconfidence on val-settled phrases.** `bet that up` and `have fun staying poor` were settled OBSERVED `split=val` for unbind; the classify head returns `none` at confidence ≈1.0 instead of their card lineages (kinship-address, crypto-degen). Lineage confidence is not calibrated; treat it as INFERRED only. | ||
| 2. **Parameter count vs A1.** Inference counts **150,546,889** parameters: trunk 149,014,272 + heads 1,532,617 (filler vocab 1,972). A1 says "T1 ≤ 150M". The trunk is within the ceiling; trunk + heads exceeds it by ~0.55M. Whether A1 counts heads is an operator reading; the card name `hyperlex-structure-149m` matches the trunk. | ||
|
|
||
| ## NOT_COMPUTABLE | ||
|
|
||
| - Calibrated lineage accuracy of the inference path (no held-out classify receipt for this pin; E1 still NOT_COMPUTABLE). | ||
| - `type_slot` inference (CLI refuses it for `--model-dir` in this cut). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| { | ||
| "brier": null, | ||
| "device": "cuda", | ||
| "e2_pass": true, | ||
| "encoder_trainable_loaded": 48, | ||
| "encoder_trainable_present": 48, | ||
| "forecast_eligible": false, | ||
| "heads_loaded": true, | ||
| "model_dir": "/home/morpheus/.hyperlex/models/BEST", | ||
| "model_id": "hyperlex-encoder-modernbert-base-seed", | ||
| "model_swap": 1.0, | ||
| "n_test": 12, | ||
| "n_unbind_eval": 24, | ||
| "name_gate": true, | ||
| "note": "Trunk-forward unbind_exact + token/slot F1 from /home/morpheus/.hyperlex/models/BEST/model.safetensors vs 004 probe_swap_min. Stub/digest leaves F1 null (no civilian filler lists). name_gate is true only for an approved pin (A6). Applied 48 saved encoder trainable tensors.", | ||
| "probe_positional_swap": 1.0, | ||
| "probe_schema": "abraxas.recoverable_structure.probe.v0.1", | ||
| "probe_swap_min": 0.5, | ||
| "probe_type_slot_swap": 0.5, | ||
| "schema": "hyperlex.hyperlexical.eval_unbind.v0.1", | ||
| "stub_swap": 0.0, | ||
| "trunk": "answerdotai/ModernBERT-base", | ||
| "trunk_dir": "/home/morpheus/.hyperlex/models/trunks/ModernBERT-base", | ||
| "trunk_forward": true, | ||
| "trunk_loaded": true, | ||
| "unbind_exact": 1.0, | ||
| "unbind_slot_f1": 1.0, | ||
| "unbind_token_f1": 1.0, | ||
| "unbind_token_precision": 1.0, | ||
| "unbind_token_recall": 1.0, | ||
| "weight_file": "model.safetensors" | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the filler head predicts every token correctly but the role head predicts any wrong positional role, this expression still reports
unbind_ok: true. Spec 007 defines unbinding as role-and-filler exactness, so downstream consumers can accept a structurally incorrect result; require bothrole_pred == roleandfiller_pred == fillerfor every atom.Useful? React with 👍 / 👎.