#!/usr/bin/env python3 """PsiLM on Gemma 4 12B: one command, three numbers. python psilm_infer.py # a=1.28, phi=0.5, x0=0.76 python psilm_infer.py --a 0.9 --phi 2.1 --x0 0.33 python psilm_infer.py --question-only # print the prompt, load nothing python psilm_infer.py --no-baseline # skip the (slow) backbone-alone arm Loads a frozen 4-bit language model (default ``mlx-community/gemma-4-12B-it-4bit``), the trained PsiLM bridges for it (``bridges//{bridges.safetensors,config.json}``) and the frozen 1D Burgers FNO (``physics/fno_burgers_singlemode.safetensors``), then answers one Burgers field-value question three ways: PsiLM the coupled system: the forward bridge reads the initial condition and the queried position x0 out of the prompt's hidden states, the FNO evolves the field, and the looked-up value u(x0) returns to the language model as soft tokens through a gated cross-attention. No text crosses the interface. backbone the same language model alone, "Answer: " protocol with answer forcing (the baseline arm of eval/mlx_stage2_eval.py in the repository). physics the FNO's own value at x0 on the TRUE initial condition -- the reference the coupled answer should match to +-0.05 -- plus the spectral solver's ground truth for the same question. Dependencies: ``pip install -r requirements.txt`` (mlx, mlx-lm, transformers, torch, huggingface_hub, and the ``psilm`` package from https://github.com/ryoji-info/PsiLM). If ``psilm`` is not installed, set ``PSILM_REPO=/path/to/PsiLM`` (a clone) instead. """ import argparse import importlib import importlib.util import json import math import os import re import sys import time from pathlib import Path HERE = Path(__file__).resolve().parent DEFAULT_BACKBONE = "mlx-community/gemma-4-12B-it-4bit" DEFAULT_BRIDGES = HERE / "bridges" / "gemma-4-12b-4bit-mlx-1d-value-selective" DEFAULT_PHYSICS = HERE / "physics" / "fno_burgers_singlemode.safetensors" TOL = 0.05 # --------------------------------------------------------------------------- imports def _psilm_available(): try: return importlib.util.find_spec("psilm.mlx") is not None except ModuleNotFoundError: return False def _ensure_psilm(): """Import path for the ``psilm`` package: the installed package first, else the optional ``PSILM_REPO`` environment variable (a clone of the GitHub repository).""" if _psilm_available(): return None repo = os.environ.get("PSILM_REPO") if repo and (Path(repo) / "psilm" / "mlx").is_dir(): sys.path.insert(0, str(Path(repo).resolve())) for name in [m for m in sys.modules if m == "psilm" or m.startswith("psilm.")]: del sys.modules[name] # a partial install may already be imported importlib.invalidate_caches() if _psilm_available(): return str(Path(repo).resolve()) sys.exit( "psilm_infer.py: the 'psilm' package (with its psilm.mlx subpackage) is not importable.\n" " Install it: pip install -r requirements.txt\n" " (or: pip install 'git+https://github.com/ryoji-info/PsiLM')\n" " Or point at a clone: PSILM_REPO=/path/to/PsiLM python psilm_infer.py ..." ) PSILM_REPO = _ensure_psilm() import mlx.core as mx # noqa: E402 from mlx.utils import tree_flatten # noqa: E402 import mlx_lm # noqa: E402 import numpy as np # noqa: E402 from transformers import AutoTokenizer # noqa: E402 from psilm.mlx.bridges import PsiBridgesMLX, build_ic_mlx # noqa: E402 from psilm.mlx.fno import convert_from_torch, load_fno_safetensors # noqa: E402 from psilm.mlx.gemma_loader import load_backbone_any # noqa: E402 from psilm.mlx.model import PsiLMMLX # noqa: E402 from psilm.physics.burgers import initial_condition, solve # noqa: E402 from psilm.stage2.qa import QUESTION, SYSTEM, QABuilder, fourier_interp # noqa: E402 # --------------------------------------------------------------- backbone-alone arm # The baseline protocol is eval/mlx_stage2_eval.py's: the question plus the "Answer:" # nudge, greedy generation, and answer forcing when the reply used its budget without # an Answer line. When a repository clone is reachable (PSILM_REPO) the functions are # imported from that file so the release cannot drift from the evaluation; otherwise # the verbatim copies below are used. NUDGE = "\nEnd your reply with a line of the form \"Answer: \"." FORCE_SUFFIX = "\n\nAnswer:" def parse_value(text): m = re.findall(r"Answer:\s*\$?\\?\(?\s*(-?\d+\.?\d*)", text) if m: return float(m[-1]) m = re.findall(r"-?\d+\.\d+", text) return float(m[-1]) if m else None def chat_generate(model, hf_tok, user, max_new=768, gen_tok=None, force_answer=True): """Returns (text, forced). gen_tok is the mlx-lm tokenizer wrapper (knows all of a backbone's stop ids, e.g. Gemma's /); hf_tok builds the prompt.""" messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}] ids = hf_tok.apply_chat_template(messages, tokenize=True, add_generation_prompt=True, enable_thinking=False) if not isinstance(ids, list): ids = ids["input_ids"] if ids and isinstance(ids[0], list): ids = ids[0] text = mlx_lm.generate(model, gen_tok or hf_tok, prompt=list(ids), max_tokens=max_new, verbose=False) if force_answer and "Answer:" not in text: cont = list(ids) + hf_tok.encode(text + FORCE_SUFFIX, add_special_tokens=False) tail = mlx_lm.generate(model, gen_tok or hf_tok, prompt=cont, max_tokens=16, verbose=False) return text + FORCE_SUFFIX + tail, True return text, False def _baseline_protocol(): """(chat_generate, parse_value, NUDGE, source): the repository's eval functions when a clone is reachable, else the copies in this file.""" roots = [Path(PSILM_REPO)] if PSILM_REPO else [] for root in roots: f = root / "eval" / "mlx_stage2_eval.py" if f.is_file(): spec = importlib.util.spec_from_file_location("psilm_release_eval_stage2", f) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod.chat_generate, mod.parse_value, mod.NUDGE, str(f) return chat_generate, parse_value, NUDGE, "vendored copy (eval/mlx_stage2_eval.py)" def parse_psilm_answer(text): """The trained reply is 'u at x = {x0} equals {u}.'; the number after 'equals' is the answer (the evaluation scores only that). Returns (value, strict): strict is False when the reply left the template and the last decimal number is used.""" m = re.search(r"equals\s*(-?\d+\.?\d*)", text) if m: return float(m.group(1)), True m = re.findall(r"-?\d+\.\d+", text) return (float(m[-1]), False) if m else (None, False) # ------------------------------------------------------------------------ bridges def load_bridges(bridges_dir, d_model, n_layers, args): """PsiBridgesMLX from /config.json['construct'] (defaults when the file is absent, inferred from the tensors present) + /bridges.safetensors with load_weights(strict=False): the Gemma export omits the retired learned-pointer tensors (fwd.x0_query, fwd.x0_key.*), which the deterministic span pointer never uses. Shapes of every provided tensor are checked explicitly, since non-strict loading would not. Returns (bridges, config, coupling, report).""" bridges_dir = Path(bridges_dir) cfg_path = bridges_dir / "config.json" cfg = json.loads(cfg_path.read_text()) if cfg_path.is_file() else {} weights = mx.load(str(bridges_dir / "bridges.safetensors")) keys = set(weights) construct = { "d_model": int(weights["fwd.query"].shape[0]) if "fwd.query" in keys else d_model, "channel": "value" if any(k.startswith("val.") for k in keys) else "field", "gate_bias": -2.0, "inj_cap": None, "readout_norm": "dim" if "fwd.dim_mu" in keys else "rms", } construct.update(cfg.get("construct", {})) for name in ("channel", "gate_bias", "inj_cap", "readout_norm"): # CLI overrides v = getattr(args, name) if v is not None: construct[name] = v if int(construct["d_model"]) != int(d_model): sys.exit(f"bridges were trained for hidden size {construct['d_model']} but the " f"backbone has {d_model}: bridges do not transfer between backbones") bridges = PsiBridgesMLX(**construct) # explicit shape check + missing/unexpected report params = dict(tree_flatten(bridges.parameters())) missing = sorted(k for k in params if k not in keys) unexpected = sorted(k for k in keys if k not in params) bad = [(k, tuple(weights[k].shape), tuple(params[k].shape)) for k in keys if k in params and tuple(weights[k].shape) != tuple(params[k].shape)] if bad: lines = "\n".join(f" {k}: file {a} vs module {b}" for k, a, b in bad) sys.exit(f"bridge tensor shapes do not match the constructed bridges:\n{lines}") if unexpected: sys.exit(f"bridges.safetensors has tensors the bridges do not define: {unexpected}") bridges.load_weights(list(weights.items()), strict=False) mx.eval(bridges.parameters()) coupling = dict(cfg.get("coupling", {})) if args.l_fwd is not None: coupling["l_fwd"] = args.l_fwd if args.l_rev is not None: coupling["l_rev"] = args.l_rev if "n_layers" in coupling and int(coupling["n_layers"]) != int(n_layers): sys.exit(f"config.json expects a {coupling['n_layers']}-layer backbone; " f"this one has {n_layers}") n_trained = sum(int(np.prod(v.shape)) for k, v in params.items() if k in keys) report = {"construct": construct, "missing": missing, "n_tensors": len(keys), "n_params": n_trained} return bridges, cfg, coupling, report def load_physics(path): path = str(path) if path.endswith(".pt"): return convert_from_torch(path) return load_fno_safetensors(path) # --------------------------------------------------------------------------- main def build_parser(): ap = argparse.ArgumentParser( description="PsiLM (frozen LLM + frozen Burgers FNO through latent bridges): " "answer one field-value question three ways.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="Training ranges: a in [0.5, 1.5], phi in [0, 6.28], x0 in [0, 0.99], " "all with two decimals. Outside them the bridges are extrapolating.") ap.add_argument("--a", type=float, default=1.28, help="amplitude of u(x,0) = a sin(2 pi x + phi)") ap.add_argument("--phi", type=float, default=0.5, help="phase (radians)") ap.add_argument("--x0", type=float, default=0.76, help="queried position in [0, 1)") ap.add_argument("--backbone", default=DEFAULT_BACKBONE, help="mlx-lm checkpoint (Hugging Face id or local path)") ap.add_argument("--bridges", default=str(DEFAULT_BRIDGES), help="directory with bridges.safetensors (+ config.json)") ap.add_argument("--physics", default=str(DEFAULT_PHYSICS), help="FNO weights: safetensors export, or a PyTorch fno.pt") ap.add_argument("--hf-tokenizer", default=None, help="HF tokenizer id for the chat template (default: config.json's " "hf_tokenizer, else the backbone id)") ap.add_argument("--no-baseline", action="store_true", help="skip the backbone-alone arm") ap.add_argument("--question-only", action="store_true", help="print the question the models see and exit (loads nothing)") ap.add_argument("--max-new", type=int, default=24, help="PsiLM reply budget (tokens)") ap.add_argument("--baseline-max-new", type=int, default=768, help="backbone-alone reply budget before answer forcing (the " "evaluation used 768; Gemma 4 uses all of it, ~75 s)") g = ap.add_argument_group("bridge construction overrides (default: config.json)") g.add_argument("--channel", choices=["value", "field"], default=None) g.add_argument("--gate-bias", type=float, default=None) g.add_argument("--inj-cap", type=float, default=None) g.add_argument("--readout-norm", choices=["rms", "dim"], default=None) g.add_argument("--l-fwd", type=int, default=None, help="readout layer") g.add_argument("--l-rev", type=int, default=None, help="injection layer") ap.add_argument("-v", "--verbose", action="store_true") return ap def _clean(text): return re.sub(r"<\|?[a-z_|]+\|?>", "", text).strip() def main(): args = build_parser().parse_args() item = {"a": round(args.a, 2), "phi": round(args.phi, 2), "x0": round(args.x0, 2)} if (item["a"], item["phi"], item["x0"]) != (args.a, args.phi, args.x0): print(f"note: inputs rounded to two decimals (the trained readout reads 2-decimal " f"numbers): a={item['a']} phi={item['phi']} x0={item['x0']}") if not (0.0 <= item["x0"] < 1.0): sys.exit("x0 must lie in [0, 1): the domain is periodic") if not (0.5 <= item["a"] <= 1.5 and 0.0 <= item["phi"] <= 6.28): print("warning: a or phi lies outside the training ranges (a in [0.5, 1.5], " "phi in [0, 6.28]); the bridges are extrapolating") question = QUESTION.format(a=item["a"], phi=item["phi"], x0=item["x0"]) if args.question_only: print(f"[system] {SYSTEM}") print(f"[user] {question}") print(f"\n(the backbone-alone arm appends: {NUDGE.strip()!r})") return # ---- load t0 = time.time() print(f"backbone : {args.backbone}", flush=True) model, stock, tok = load_backbone_any(args.backbone) n_layers = len(model.model.layers) d_model = int(model.args.hidden_size) bridges, cfg, coupling, rep = load_bridges(args.bridges, d_model, n_layers, args) hf_id = args.hf_tokenizer or cfg.get("hf_tokenizer") or args.backbone hf_tok = AutoTokenizer.from_pretrained(hf_id) fno = load_physics(args.physics) psi = PsiLMMLX(model, tok, fno, bridges, l_fwd=coupling.get("l_fwd"), l_rev=coupling.get("l_rev")) builder = QABuilder(hf_tok) t_load = time.time() - t0 c = rep["construct"] print(f"bridges : {args.bridges}\n" f" channel={c['channel']} readout_norm={c['readout_norm']} " f"gate_bias={c['gate_bias']} inj_cap={c['inj_cap']} | " f"{rep['n_params']/1e6:.1f}M params in {rep['n_tensors']} tensors" + (f" | not in file (unused): {rep['missing']}" if rep["missing"] else "")) print(f"coupling : read @ layer {psi.l_fwd}, inject @ layer {psi.l_rev} of {psi.n_layers} " f"(hidden {d_model}" + (")" if cfg.get("coupling") else "; no config.json coupling entry: PsiLMMLX defaults)")) # parameter count with each complex spectral weight counted once (wr/wi are one number) n_fno = sum(int(np.prod(v.shape)) for k, v in tree_flatten(fno.parameters()) if not k.endswith(".wi")) print(f"physics : {args.physics} (FNO1d, {n_fno/1e3:.0f}K params)") print(f"loaded in {t_load:.1f} s\n") print(f"question : {question}\n", flush=True) # ---- 1. PsiLM: the coupled system t1 = time.time() psi_text = psi.generate(builder, item, max_new=args.max_new) t_psi = time.time() - t1 psi_val, strict = parse_psilm_answer(psi_text) print(f"[1] PsiLM (coupled) : {_clean(psi_text)!r}") print(f" value : {psi_val if psi_val is not None else 'no number parsed'}" f" ({t_psi:.1f} s{'' if strict or psi_val is None else '; reply left the trained template, last number taken'})", flush=True) # ---- 2. backbone alone base_val = None if not args.no_baseline: gen, parse, nudge, src = _baseline_protocol() t2 = time.time() base_text, forced = gen(stock, hf_tok, question + nudge, args.baseline_max_new, gen_tok=tok, force_answer=True) t_base = time.time() - t2 base_val = parse(base_text) tail = _clean(base_text) tail = tail if args.verbose or len(tail) <= 240 else "..." + tail[-240:] print(f"[2] backbone alone : {tail!r}") print(f" value : {base_val if base_val is not None else 'no number parsed'}" f" ({t_base:.1f} s, {len(tok.encode(base_text))} tokens" f"{', answer forced' if forced else ''}; protocol: {src})", flush=True) else: print("[2] backbone alone : skipped (--no-baseline)") # ---- 3. the physics model on the true initial condition t3 = time.time() params = mx.array([[item["a"], math.sin(item["phi"]), math.cos(item["phi"])]], dtype=mx.float32) ic = build_ic_mlx(params) # the bridges' IC parameterization u_field = np.array(fno(ic), dtype=np.float64)[0] # u(x, t=0.5) on the 128-grid u_fno = fourier_interp(u_field, item["x0"]) t_fno = time.time() - t3 u_true = fourier_interp(solve(initial_condition(item["a"], item["phi"])), item["x0"]) if args.verbose: ic_np = initial_condition(item["a"], item["phi"]) print(f" (IC parameterization check: max|build_ic - initial_condition| = " f"{float(np.abs(np.array(ic)[0] - ic_np).max()):.2e})") print(f"[3] physics model (FNO) : u({item['x0']}) = {u_fno:+.4f} ({t_fno*1e3:.0f} ms; " f"the reference the coupled answer should match to +-{TOL})") print(f" spectral solver : u({item['x0']}) = {u_true:+.4f} (ground truth)") # ---- verdict def mark(v): return "n/a" if v is None else ("match" if abs(v - u_fno) <= TOL else "off") if psi_val is None: verdict = "PsiLM n/a (no number in the reply)" else: verdict = (f"PsiLM {mark(psi_val)} (|{psi_val}-{u_fno:.2f}| " f"{'<=' if abs(psi_val-u_fno) <= TOL else '>'} {TOL})") print("\n" + verdict + ("" if args.no_baseline else f"; backbone alone {mark(base_val)}")) if __name__ == "__main__": main()