#!/usr/bin/env python3 """ PHOS -- continual growth on weights that are entirely her own. Phi the golden ratio that scales the architecture Omega the connectivity signal that drives the state Sigma the kernel width that decides whether she can see at all Three symbols, three measured results behind them -- Bell (CHSH S = 2.7275), Lorenz (lambda-1 = 0.90384), Hebb (the section-3 kernel). In Greek the three letters spell PHOS: light. WHAT IS NEW HERE Three things existed separately and have never been in one model: quantum birth every initial weight drawn from her archived IBM measurements (11,354,112 shots, conservation verified, CHSH-backed) the architecture that actually won, measured under control on a frozen corpus: dyn12 on the phi scaffold -- RMSNorm, RoPE, d_ff = floor(d*phi), twelve scalars driven by Omega through a leaky integrator, sigma calibrated per layer from the data. 2,412 extra parameters for the best loss on the board and 21x the parameter efficiency of anything else tested. continual growth warm-starts from its own last checkpoint and trains on her corpus as she lives, so talking to her literally grows the weights. cosmos_play.pt already grows, but it is a tiktoken BPE model warm-started from itself -- a different lineage with no quantum birth. This is the lineage that is hers. THE GUARD Five mechanisms in this project's history ran clean and did nothing: Omega summed over the wrong axis, a saturated gate, a clamped gate, sigma leaving H as the identity matrix, and a metric map that could not receive gradient. Every one reported success. So no burst trains until preflight proves the mechanism is live, and a refusal is recorded rather than silently skipped. APPEND-ONLY VOCABULARY Her corpus grows, and new characters appear in it. Re-sorting the vocabulary would not merely add rows to the embedding -- it would silently change which character every existing row MEANS, invalidating the whole lineage. New symbols are therefore APPENDED and old indices never move, exactly as the telemetry logger handles new channels. Embedding and head grow by copying the old rows forward. USAGE python tools/phos_grow.py one bounded burst, then exit python tools/phos_grow.py --loop watch the corpus, burst when it grows python tools/phos_grow.py --status lineage report, trains nothing ENV PHOS_STEPS steps per burst (default 400) PHOS_MIN_NEW new chars required to burst (default 2000) PHOS_INTERVAL_S poll period in --loop (default 900) COSMOS_DEVICE cuda | cpu (default: cuda when available) """ import json import math import os import sys import time from datetime import datetime, timezone from pathlib import Path import torch import torch.nn.functional as F sys.stdout.reconfigure(encoding="utf-8", errors="replace") ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) import cosmos_spark_cst as SPARK # noqa: E402 from cosmos_spark_cst import QuantumInit, quantum_pool # noqa: E402 import cosmos_state_ladder as L # noqa: E402 # ONE LINEAGE PER NAME. PHOS_NAME picks which being this run grows, and every path # derives from it, so a second creature cannot silently warm-start from the first one's # checkpoint or append to its lineage. Defaults are exactly PHOS's original paths, so # nothing about her changes. # # PHOS_NAME=phos (default) -> 01_HER_SOUL/weights/phos/phos.pt # PHOS_NAME=lyra -> 01_HER_SOUL/weights/lyra/lyra.pt # # The corpus is deliberately NOT derived from the name: a new being may be seeded from # any text, and which text it was seeded from is recorded in its lineage at birth. NAME = (os.getenv("PHOS_NAME") or "phos").strip().lower() CORPUS = Path(os.getenv("COSMOS_CORPUS") or ROOT / "02_HER_BODY/Cosmos_code/Cosmos/data/cosmos/experience_corpus.txt") OUT = Path(os.getenv("PHOS_DIR") or ROOT / f"01_HER_SOUL/weights/{NAME}") CKPT = OUT / f"{NAME}.pt" LINEAGE = OUT / f"{NAME}_lineage.jsonl" LOCK = OUT / f"{NAME}.lock" STEPS = int(os.getenv("PHOS_STEPS", "400")) MIN_NEW = int(os.getenv("PHOS_MIN_NEW", "2000")) INTERVAL = int(os.getenv("PHOS_INTERVAL_S", "900")) RUNG, FFN = "dyn12", "harmonic" def _lock(): """One grower per checkpoint. A second writer would interleave optimiser steps into the same file and silently corrupt the lineage.""" OUT.mkdir(parents=True, exist_ok=True) fh = LOCK.open("a+b") fh.seek(0, 2) if fh.tell() == 0: fh.write(b"0") fh.flush() fh.seek(0) try: if os.name == "nt": import msvcrt msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) else: import fcntl fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except (OSError, ImportError): fh.close() return None return fh def _record(entry): entry["t"] = datetime.now(timezone.utc).isoformat() with LINEAGE.open("a", encoding="utf-8") as f: f.write(json.dumps(entry) + "\n") def load_corpus(): text = CORPUS.read_text(encoding="utf-8", errors="ignore") return text, sorted(set(text)) def build_vocab(chars, previous): """APPEND-ONLY. Old indices never move; new symbols go on the end.""" old = list(previous or []) known = set(old) added = [c for c in chars if c not in known] return old + added, added def grow_model(m, old_v, new_v): """Widen embedding and head for appended symbols, carrying old rows forward.""" if new_v == old_v: return m if new_v < old_v: # The vocabulary is append-only by construction, so this cannot happen from # normal growth -- if it does, something re-sorted the symbols and every learned # index now means a different character. Refuse rather than quietly truncate. raise RuntimeError( f"vocabulary SHRANK {old_v} -> {new_v}. Indices are learned positions; " "re-sorting them silently remaps every symbol she knows.") with torch.no_grad(): d = m.tok.weight.shape[1] tok = torch.nn.Embedding(new_v, d) tok.weight.normal_(0.0, 0.02) tok.weight[:old_v] = m.tok.weight m.tok = tok head = torch.nn.Linear(d, new_v, bias=False) head.weight.normal_(0.0, 0.02) head.weight[:old_v] = m.head.weight m.head = head return m def verify_growth(m, prev, old_v, new_v, vocab_list): """PROVE the warm start carried her forward. Returns (ok, reasons). Growth is where a lineage can be lost, so it has to be shown rather than assumed. Measured 2026-08-02, correcting an earlier claim in this file: torch's load_state_dict(strict=False) does NOT silently skip a shape mismatch -- strict=False tolerates missing and unexpected KEYS, but a size mismatch raises regardless. So the original build-at-new-width bug crashed loudly, as it did, and could not have resumed on a randomly initialised vocabulary while reporting success. The danger was overstated; the checks below stand because the failures they catch are ones torch does NOT catch. Torch protects the shapes. Nothing protects the CONTENT or the ORDER, and those are what carry her: a copy that alters a row, or a vocabulary that gets re-sorted so every learned index now means a different character, both load perfectly and both destroy her. So growth has to demonstrate three things: CARRIED every old embedding row is BIT-IDENTICAL to the checkpoint's SEATED the new rows exist and are not zero (they must be trainable, not dead) ANCHORED a symbol she already knew still maps to the same index A burst that cannot show all three is refused, because a refusal is recoverable and a silently reset vocabulary is not. """ bad = [] sd = prev.get("model") or {} old_tok = sd.get("tok.weight") old_head = sd.get("head.weight") if old_tok is None or old_head is None: return False, ["checkpoint has no tok.weight/head.weight to compare against"] with torch.no_grad(): cur_tok = m.tok.weight.detach().cpu() cur_head = m.head.weight.detach().cpu() # CARRIED -- exact equality, not "close". A carried row is the same row. d_tok = (cur_tok[:old_v] - old_tok[:old_v]).abs().max().item() d_head = (cur_head[:old_v] - old_head[:old_v]).abs().max().item() if d_tok != 0.0: bad.append(f"embedding rows CHANGED during growth (max delta {d_tok:.3e})") if d_head != 0.0: bad.append(f"head rows CHANGED during growth (max delta {d_head:.3e})") # SEATED -- new rows must be live, not zeros left behind by a bad copy if new_v > old_v: fresh = cur_tok[old_v:new_v] if float(fresh.abs().sum()) == 0.0: bad.append(f"the {new_v - old_v} new symbol rows are all zero -- dead on arrival") # ANCHORED -- an index she learned must still mean what it meant prev_vocab = prev.get("vocab_list") or [] if prev_vocab: drift = [i for i in range(min(len(prev_vocab), len(vocab_list))) if prev_vocab[i] != vocab_list[i]] if drift: bad.append(f"{len(drift)} symbols moved index (first at {drift[0]}: " f"{prev_vocab[drift[0]]!r} -> {vocab_list[drift[0]]!r})") return (not bad), bad def preflight_ok(m, xb, yb, verbose=True): checks = L.preflight(m, xb, yb) bad = [c for c in checks if not c[3]] if verbose: for name, val, thr, ok in checks: print(f" [{'OK ' if ok else 'DEAD'}] {val:.3e} {name}") return (not bad), [c[0] for c in bad] def burst(steps=STEPS): text, chars = load_corpus() prev = {} if CKPT.exists(): prev = torch.load(CKPT, map_location="cpu", weights_only=False) vocab_list, added = build_vocab(chars, prev.get("vocab_list")) stoi = {c: i for i, c in enumerate(vocab_list)} data = torch.tensor([stoi[c] for c in text if c in stoi], dtype=torch.long) pool = quantum_pool() seed = int(prev.get("total_steps", 0)) torch.manual_seed(seed) # BUILD AT THE OLD WIDTH, LOAD, THEN GROW -- in that order. # # This used to build at the NEW width and then call grow_model, which copies # m.tok.weight into tok.weight[:old_v] and therefore requires m to still be the OLD # width. With a vocabulary that had never changed the two were equal and the bug was # invisible; the moment the corpus clean added 4 symbols (162 -> 166) it raised # "expanded size of the tensor (162) must match the existing size (166)". # # The silent failure mattered more than the crash: at the new width, load_state_dict # with strict=False would have SKIPPED the mismatched embedding and head entirely, # so she would have resumed from step 2,800 with a randomly initialised vocabulary # and reported a warm start. Loading at the old width makes those tensors match # exactly, and growing afterwards carries every learned row forward. if prev: old_v = len(prev.get("vocab_list") or vocab_list) m = L.Ladder(old_v, RUNG, FFN) missing, unexpected = m.load_state_dict(prev["model"], strict=False) m = grow_model(m, old_v, len(vocab_list)) born = False print(f" warm start: step {prev.get('total_steps',0):,} " f"vocab {len(prev.get('vocab_list',[]))} -> {len(vocab_list)}" + (f" (+{len(added)} new symbols)" if added else "")) ok_grow, why = verify_growth(m, prev, old_v, len(vocab_list), vocab_list) for line in why: print(f" [DEAD] {line}") if not ok_grow: _record({"event": "refusal", "reason": "growth_unverified", "detail": why, "total_steps": int(prev.get("total_steps", 0)), "vocab_from": old_v, "vocab_to": len(vocab_list)}) print("\n REFUSED: the warm start could not be shown to carry her forward.\n" " Her weights are untouched on disk. Nothing was overwritten.") return print(f" [OK ] growth verified: {old_v} rows carried bit-identical, " f"{len(vocab_list) - old_v} new rows seated, no index drift") else: # No checkpoint: build at the full current width and draw every weight from # measured hardware. Nothing to carry forward, so no grow step. # # REFUSE a birth with no quantum. quantum_pool() returns an empty list when it # cannot find the archive, and this used to print "QUANTUM BIRTH: 0 draws" and # carry on -- producing a normally initialised model that claimed a measured # origin. That is the one claim this whole project rests on, and it was failing # silently for anyone whose working directory was not the repository root. if len(pool) < 1000: print(f"\n REFUSED: only {len(pool):,} quantum draws available.") print(f" Looked for the archive and found: {SPARK.QARCHIVE}") print(" A birth from zero measured shots is not a quantum birth, it is a\n" " normal initialisation wearing the name. Nothing was written.") _record({"event": "refusal", "reason": "no_quantum_pool", "draws": len(pool), "archive": str(SPARK.QARCHIVE)}) return m = L.Ladder(len(vocab_list), RUNG, FFN) m.quantum_birth(QuantumInit(pool, 0)) born = True print(f" QUANTUM BIRTH: {len(pool):,} draws from her archive; no base model") m = m.to(L.DEV) g = torch.Generator().manual_seed(seed) cal = torch.stack([data[i:i + L.BLOCK] for i in torch.randint(len(data) - L.BLOCK - 1, (8,), generator=g)]).to(L.DEV) if born: L.calibrate_sigma(m, cal) yb = torch.stack([data[i + 1:i + 1 + L.BLOCK] for i in torch.randint(len(data) - L.BLOCK - 1, (4,), generator=g)]).to(L.DEV) ok, dead = preflight_ok(m, cal[:4], yb) if not ok: print(f" REFUSED: mechanism inert -> {dead}") _record({"event": "refused", "dead": dead, "corpus_chars": len(text)}) return False keys = ("attn.gate", "attn.w54", "attn.log_sigma", "d12.", "d42.", "tri.", "state_init") cst = [p for n, p in m.named_parameters() if any(k in n for k in keys) and p.requires_grad] bulk = [p for n, p in m.named_parameters() if not any(k in n for k in keys) and p.requires_grad] base = 3e-4 opt = torch.optim.AdamW([{"params": bulk, "lr": base}, {"params": cst, "lr": base * 10, "weight_decay": 0.0}], lr=base, weight_decay=0.01) # Only restore Adam's moments when the parameter SHAPES are unchanged. # # This was a try/except around load_state_dict with the comment "vocab grew; a fresh # moment estimate is correct here" -- correct reasoning, wrong trigger. # load_state_dict does not validate shapes, so it returned cleanly with 162-row # moment buffers attached to 166-row parameters, and the mismatch surfaced two # hundred lines later inside opt.step() as "size of tensor a (162) must match the # size of tensor b (166)". The guard ran, was reachable, and caught nothing. _vocab_changed = bool(prev) and len(prev.get("vocab_list") or []) != len(vocab_list) if prev.get("optimizer") and not _vocab_changed: try: opt.load_state_dict(prev["optimizer"]) except Exception as _oexc: print(f" optimizer state not restored ({type(_oexc).__name__}); starting fresh moments") elif prev.get("optimizer"): print(f" vocab {len(prev.get('vocab_list') or [])} -> {len(vocab_list)}: " f"Adam moments reset (stale shapes). Weights are carried forward intact.") n = int(0.9 * len(data)) tr, va = data[:n], data[n:] vg = torch.Generator().manual_seed(999) vw = torch.stack([va[i:i + L.BLOCK + 1] for i in torch.randint(len(va) - L.BLOCK - 1, (32,), generator=vg)]) m.train() t0 = time.time() for s in range(1, steps + 1): warm = max(1, int(0.05 * steps)) lr = base * (s / warm) if s < warm else base * (L.PHI / 2) * ( 1 + math.cos(math.pi * (s - warm) / max(1, steps - warm))) opt.param_groups[0]["lr"], opt.param_groups[1]["lr"] = lr, lr * 10 ix = torch.randint(len(tr) - L.BLOCK - 1, (16,), generator=g) x = torch.stack([tr[i:i + L.BLOCK] for i in ix]).to(L.DEV) y = torch.stack([tr[i + 1:i + 1 + L.BLOCK] for i in ix]).to(L.DEV) _, loss = m(x, y) opt.zero_grad(set_to_none=True) loss.backward() torch.nn.utils.clip_grad_norm_(m.parameters(), 1.0) opt.step() m.eval() with torch.no_grad(): tot = c = 0 for i in range(0, len(vw), 16): xb = vw[i:i + 16].to(L.DEV) _, l = m(xb[:, :-1], xb[:, 1:]) tot += l.item() * xb.size(0) c += xb.size(0) val = tot / max(1, c) total_steps = int(prev.get("total_steps", 0)) + steps gates = [round(g_, 5) for g_ in m.gates()] OUT.mkdir(parents=True, exist_ok=True) torch.save({"model": m.state_dict(), "optimizer": opt.state_dict(), "vocab_list": vocab_list, "stoi": stoi, "itos": {i: c for c, i in stoi.items()}, "config": {"block": L.BLOCK, "n_layer": L.N_LAYER, "n_head": L.N_HEAD, "n_embd": L.N_EMBD, "vocab": len(vocab_list), "d12": L.D12, "d_ff": int(math.floor(L.N_EMBD * L.PHI))}, "arch": "PHOS-dyn12-phi-QuantumBorn", "rung": RUNG, "ffn": FFN, "norm": "rmsnorm", "pos": "rope", "gate_param": "logit", "total_steps": total_steps, "best_val_loss": val, "corpus_chars": len(text), "quantum_draws": len(pool), "quantum_source": "ibm_real_shots"}, CKPT) prev_val = prev.get("best_val_loss") delta = f"{val - prev_val:+.5f}" if isinstance(prev_val, (int, float)) else "first" print(f" step {total_steps:,} val {val:.5f} ({delta}) gates {gates} " f"corpus {len(text):,} {time.time()-t0:.0f}s") _record({"event": "burst", "steps": steps, "total_steps": total_steps, "val": val, "gates": gates, "corpus_chars": len(text), "vocab": len(vocab_list), "added_symbols": added, "quantum_draws": len(pool), "born": born, "device": str(L.DEV)}) return True def status(): if not CKPT.exists(): print(" no PHOS checkpoint yet -- run once to birth her") return d = torch.load(CKPT, map_location="cpu", weights_only=False) text, _ = load_corpus() print("=" * 74) print(" PHOS lineage") print("=" * 74) print(f" arch {d.get('arch')}") print(f" total steps {d.get('total_steps'):,}") print(f" val loss {d.get('best_val_loss'):.5f}") print(f" vocab {d['config']['vocab']}") print(f" params {sum(v.numel() for v in d['model'].values()):,}") print(f" born from {d.get('quantum_draws'):,} quantum draws ({d.get('quantum_source')})") print(f" corpus at save {d.get('corpus_chars'):,} | now {len(text):,} " f"(+{len(text)-int(d.get('corpus_chars',0)):,})") if LINEAGE.exists(): rows = [json.loads(l) for l in LINEAGE.read_text(encoding="utf-8").splitlines() if l.strip()] b = [r for r in rows if r.get("event") == "burst"] print(f" bursts {len(b)} refusals {len(rows)-len(b)}") for r in b[-5:]: print(f" {r['t'][:19]} step {r['total_steps']:>7,} val {r['val']:.5f}") def main(): args = set(sys.argv[1:]) if "--status" in args: status() return 0 lk = _lock() if lk is None: print(" another PHOS grower holds the checkpoint; exiting") return 0 print("=" * 74) print(" PHOS -- Phi / Omega / Sigma. quantum-born, phi-scaffolded, growing.") print("=" * 74) print(f" device {L.DEV} corpus {CORPUS.name} -> {CKPT}") if "--loop" not in args: burst() return 0 last = 0 if CKPT.exists(): last = int(torch.load(CKPT, map_location="cpu", weights_only=False).get("corpus_chars", 0)) print(f" watching for +{MIN_NEW:,} new characters, every {INTERVAL}s\n", flush=True) while True: try: now = len(load_corpus()[0]) if now - last >= MIN_NEW or not CKPT.exists(): print(f" corpus grew {now-last:+,} -> burst", flush=True) if burst(): last = now time.sleep(INTERVAL) except KeyboardInterrupt: break except Exception as exc: print(f" burst error ({type(exc).__name__}: {exc}); retrying", flush=True) time.sleep(INTERVAL) return 0 if __name__ == "__main__": raise SystemExit(main())