"""v5 — LINEAGE. The grammar stops being mine. Every version so far ended by finding my own hand somewhere in the metal: v1 the rim grain was EDGE_MM, a distance I chose v2 the only length in the pattern was my step size v3 the grammar is a constant I hardcoded — AND I selected it out of ~20k candidates for being maximally opaque to statistics, so the opacity itself was my authorship v4 the body was not mine (it came from the specification), and it handed over a length nobody chose. But G_STEP was still sitting there. v5 removes it. The transition table is no longer a constant: it MUTATES, and the metal decides when and how. at a chord's end the grammar makes a one-bit PREDICTION about which side the next completing crossing will fall on. The metal answers. When the metal refutes the prediction, one transition is rewired. Three things make this the series' own next verb rather than a new gadget: * v1's answer was "intelligence is changing how you move only when your prediction fails". v5 applies that sentence to the LANGUAGE instead of the path. Error drove the trajectory in v1; here it drives the grammar. * the mutagen is the metal. The swap partner is read off a four-bit shift register of side bits — and the side bit was measured to be a fair, memoryless coin guaranteed by the felt's isotropy (0.498, lag-1 < 1e-3). So the randomness is PHYSICALLY SOURCED, and the run stays deterministic given the disc. No PRNG enters the work. * rewiring is a swap, so both tables stay permutations. That preserves the uniform state distribution, which is what keeps the exit-angle histogram flat and the disc grey to the eye. The v3 concealment survives mutation. The claim to be tested, and it is falsifiable both ways: if the side bit really is a fair coin, no predictor can beat it, so refutations never stop and the grammar never settles. Then the disc does not carry a language — it carries a LINEAGE, one language drifting into the next, and the question becomes how far it has walked from my seed and whether different seeds end up anywhere alike. Measured here: the refutation rate over time, the distance from the seed, whether different seeds converge in structure, and whether the reading gradient survives a drifting grammar at all. Usage: python3 v5.py --self-test python3 v5.py --hours 8 --seed 0 --out ../plate/v5/s0 python3 v5.py --lineage ../plate/v5/s0 """ from __future__ import annotations import argparse import json import math import os import time import numpy as np import grammar as G import v4 import yieldbreak as YB K = G.K # 16 states, as v3/v4 NSYM = len(G.ALPHABET) # 4 exit angles FOLLOW, CROSS = YB.FOLLOW, YB.CROSS def _lehmer(n, k=K): """The n-th permutation of k items, by Lehmer code. A bijection from the integers, so it involves no choosing and no measuring.""" items = list(range(k)) out = [] for i in range(k, 0, -1): n, j = divmod(n, i) out.append(items.pop(j)) return out def seed_tables(seed): """A STARTING grammar, indexed rather than chosen. v3's table was selected by me out of ~20k candidates for being opaque to statistics, and that selection was the authorship this version exists to remove. So the seed is simply an INDEX into the permutation space via a Lehmer code — nothing measured, nothing preferred. The first version of this function used rotations, which looked plain and were useless: the output map is `i mod 4`, so only a stride's residue mod 4 can ever reach the metal, and every odd stride falls into one of two classes. Seeds 0 and 2 produced byte-identical discs. A seed family has to be checked for behavioural diversity, not just for looking arbitrary. """ p0 = _lehmer(seed * 2654435761 % 20922789888000) # 16! is larger, p1 = _lehmer((seed * 40503 + 12345) % 20922789888000) # so this indexes pred = [(seed >> (i % 8)) & 1 for i in range(K)] # a subrange only return p0, p1, pred class Rule5: """The v4 rule, with a grammar that the metal is allowed to edit. Memory: mode bit, state (4 bits), the counter, the crossing edge bit, the side bit, a four-bit shift register of side bits, and THE TABLES. The tables are the machine's genome; everything else is as before. """ __slots__ = ("mode", "state", "count", "crossing", "last_side", "rail_sym", "p0", "p1", "pred", "reg", "mutate", "steps", "refutations", "rewires", "run_len", "slow", "hamming_seed", "seed_p0", "seed_p1", "seed_pred", "history") def __init__(self, seed=0, mutate=True, slow=0): self.mode = CROSS self.state = 0 self.count = 0 self.crossing = False self.last_side = 0 self.rail_sym = None self.p0, self.p1, self.pred = seed_tables(seed) self.seed_p0 = tuple(self.p0) self.seed_p1 = tuple(self.p1) self.seed_pred = tuple(self.pred) self.reg = 0 self.mutate = mutate self.steps = 0 self.refutations = 0 self.rewires = 0 self.run_len = 0 self.slow = slow # rewire only after `slow` refutations in a # row. Reuses the rule's own integer N, so no # new number enters; slow=0 means every time. self.history = [] # (grammar step, table snapshot) checkpoints def steer(self, psi, marked, phi): if self.mode == FOLLOW and marked and YB.axial_diff(psi, phi) <= G.CHI_C: return YB.forward_sense(phi, psi), True return psi, False def tick(self, psi, events, n_impulse): self.count += events if self.count < n_impulse: return psi, None, None self.count = 0 if self.mode == CROSS: side = self.last_side guess = self.pred[self.state] self.steps += 1 self.reg = ((self.reg << 1) | side) & 0xF # the metal's own coin if guess != side: self.refutations += 1 self.run_len += 1 fire = self.mutate and (self.slow == 0 or self.run_len >= self.slow) if fire: self.run_len = 0 if fire: # the metal refuted the grammar. Rewire ONE transition of # the table the metal just used, and flip the local guess. # The partner is whatever four side bits the metal last # handed over, so the mutation is sourced from the surface. tab = self.p1 if side else self.p0 j = self.reg if j != self.state: tab[self.state], tab[j] = tab[j], tab[self.state] self.rewires += 1 self.pred[self.state] = side else: if guess == side: self.run_len = 0 self.state = (self.p1 if side else self.p0)[self.state] self.mode = FOLLOW return psi, self.mode, None sym = G.G_OUT[self.state] psi += G.ALPHABET[sym] self.mode = CROSS return psi, self.mode, sym def rail_exit(self, psi): self.count = 0 self.mode = CROSS self.rail_sym = G.G_OUT[self.state] return psi + G.ALPHABET[self.rail_sym] def distance_from_seed(self): """How many table entries no longer say what the seed said.""" d = sum(1 for i in range(K) if self.p0[i] != self.seed_p0[i]) d += sum(1 for i in range(K) if self.p1[i] != self.seed_p1[i]) d += sum(1 for i in range(K) if self.pred[i] != self.seed_pred[i]) return d def cycle_signature(self): """The multiset of cycle lengths of both tables — a structural fingerprint that survives renaming the states, so two grammars can be compared without asking whether they are literally the same table.""" out = [] for p in (self.p0, self.p1): seen = [False] * K cyc = [] for s in range(K): if seen[s]: continue n = 0 t = s while not seen[t]: seen[t] = True t = p[t] n += 1 cyc.append(n) out.append(tuple(sorted(cyc))) return tuple(out) def snapshot(self): return {"step": self.steps, "p0": list(self.p0), "p1": list(self.p1), "pred": list(self.pred), "cycles": [list(c) for c in self.cycle_signature()], "distance_from_seed": self.distance_from_seed()} def run(hours=8.0, n_impulse=3, seed=0, mutate=True, out_dir="../plate/v5/s0", checkpoint_every=2000, log_every=400000, slow=0): field = v4.Field() rule = Rule5(seed=seed, mutate=mutate, slow=slow) ax = v4.Axes(199.66, 0.0, math.radians(37.0)) total = int(hours * 3600.0 * v4.CTRL_HZ) pts = np.empty((total // 8 + 2, 2), dtype="float32") pts[0] = ax.xy() np_i = 1 junctions, junction_at = [], [] refut_trace = [] settle = math.radians(v4.SETTLE_DEG) travel = 0.0 last_ref = 0 last_steps = 0 t0 = time.time() for i in range(total): seg, x0, y0, x1, y1, stopped = ax.tick() events = field.cross_and_cut(x0, y0, x1, y1, ax.heading(), rule) if stopped: ax.psi_cmd = 2.0 * ax.th + math.pi - ax.psi_cmd if rule.mode == FOLLOW: ax.psi_cmd = rule.rail_exit(ax.psi_cmd) if rule.rail_sym is not None: junctions.append(rule.rail_sym) junction_at.append(np_i - 1) rule.rail_sym = None if (i & 7) == 0: pts[np_i] = (x1, y1) np_i += 1 travel += seg if travel >= v4.STEP_MM and \ YB.axial_diff(ax.psi_cmd, ax.heading()) < settle: travel = 0.0 px = x1 + field.cf * math.cos(ax.psi_cmd) py = y1 + field.cf * math.sin(ax.psi_cmd) marked, phi, _ = field.read(px, py) ax.psi_cmd, _c = rule.steer(ax.psi_cmd, marked, phi) ax.psi_cmd, flipped, sym = rule.tick(ax.psi_cmd, events, n_impulse) if flipped is not None and sym is not None: junctions.append(sym) junction_at.append(np_i - 1) if rule.steps and rule.steps % checkpoint_every == 0 \ and rule.steps != last_steps: last_steps = rule.steps rule.history.append(rule.snapshot()) refut_trace.append({ "grammar_steps": rule.steps, "refutation_rate": round( (rule.refutations - last_ref) / checkpoint_every, 4), "distance_from_seed": rule.distance_from_seed(), "coverage": round(field.coverage(), 5)}) last_ref = rule.refutations else: rule.count += events wall = time.time() - t0 os.makedirs(out_dir, exist_ok=True) np.save(os.path.join(out_dir, "stroke.npy"), pts[:np_i]) for fn, obj in (("junctions.json", junctions), ("junction_at.json", junction_at), ("refutation_trace.json", refut_trace), ("grammar_history.json", rule.history)): with open(os.path.join(out_dir, fn), "w") as f: json.dump(obj, f) r = np.hypot(pts[:np_i, 0], pts[:np_i, 1]) q = max(1, len(r) // 8) summary = { "rule": "lineage (v5): the metal edits the grammar", "seed": seed, "mutate": mutate, "slow": slow, "hours": hours, "cut_m": round(ax.cut_mm / 1000.0, 2), "coverage": round(field.coverage(), 5), "junctions": len(junctions), "grammar_steps": rule.steps, "refutations": rule.refutations, "refutation_rate": round(rule.refutations / max(1, rule.steps), 4), "rewires": rule.rewires, "distance_from_seed": rule.distance_from_seed(), "max_distance": 3 * K, "cycle_signature": [list(c) for c in rule.cycle_signature()], "seed_cycle_signature": [ list(c) for c in Rule5(seed=seed).cycle_signature()], "radial_spread_ratio": round(float(r[-q:].std() / max(1e-9, r.std())), 3), "following_error_mean_deg": round( math.degrees(ax.err_sum / max(1, ax.ticks)), 3), "mean_surface_mm_s": round(ax.speed_sum / max(1, ax.ticks), 3), "wall_s": round(wall, 1), } with open(os.path.join(out_dir, "summary.json"), "w") as f: json.dump(summary, f, ensure_ascii=False, indent=1) print(json.dumps(summary, ensure_ascii=False, indent=1)) return summary def self_test(): ok = True def check(name, got, want, tol=0.0): nonlocal ok good = (abs(got - want) <= tol) if isinstance(want, float) \ else (got == want) ok = ok and good print(" %-58s %10s want %8s %s" % (name, ("%.4f" % got) if isinstance(got, float) else got, ("%.4f" % want) if isinstance(want, float) else want, "ok" if good else "FAIL")) print("the seed grammars are plain, and not the same for different seeds") for s in (0, 1, 5): p0, p1, pr = seed_tables(s) check("seed %d: p0 is a permutation" % s, len(set(p0)), K) check("seed %d: p1 is a permutation" % s, len(set(p1)), K) check("different seeds give different tables", 1 if seed_tables(0)[0] != seed_tables(3)[0] else 0, 1) check("no seed equals v3's selected table", sum(1 for s in range(20) if tuple(seed_tables(s)[0]) == G.G_STEP[0]), 0) # the rotation family this replaced collapsed to two behaviour classes, # because the output map is i mod 4 and only a stride's residue survives it. # Check the seeds are distinct IN WHAT THE METAL SEES, not just on paper. sigs = set() for s in range(8): p0, p1, _ = seed_tables(s) sigs.add(tuple(G.G_OUT[p0[i]] for i in range(K)) + tuple(G.G_OUT[p1[i]] for i in range(K))) check("8 seeds give 8 distinct emitted-symbol maps", len(sigs), 8) print("mutation keeps both tables permutations, forever") r = Rule5(seed=2) for i in range(4000): # drive it with a fair coin from outside, as the felt would r.last_side = (i * 2654435761 >> 13) & 1 r.mode = CROSS r.count = 3 r.tick(0.0, 0, 3) r.mode = FOLLOW r.count = 3 r.tick(0.0, 0, 3) check("p0 still a permutation after 4000 steps", len(set(r.p0)), K) check("p1 still a permutation after 4000 steps", len(set(r.p1)), K) check("it did rewire", 1 if r.rewires > 100 else 0, 1) check("and it walked away from its seed", 1 if r.distance_from_seed() > 8 else 0, 1) print("a fair coin cannot be predicted, so refutation does not die out") rate = r.refutations / r.steps print(" refutation rate over 4000 grammar steps: %.4f" % rate) check("the rate stays near a half", rate, 0.5, 0.08) print("the uniform state distribution survives mutation " "(this is what keeps the disc grey)") visits = [0] * K s = 0 for i in range(20000): b = (i * 2246822519 >> 11) & 1 s = (r.p1 if b else r.p0)[s] visits[s] += 1 check("state visits uniform to 12%", 1 if max(visits) / min(visits) < 1.12 else 0, 1) print("the mutagen is the metal: no PRNG anywhere in the rule") src = open(__file__).read() body = src[src.index("class Rule5"):src.index("def run(")] check("Rule5 imports no randomness", sum(1 for t in ("random", "np.random", "urandom") if t in body), 0) check("the swap partner is the side-bit register", 1 if "j = self.reg" in body else 0, 1) print("\nSELF-TEST %s" % ("PASSED" if ok else "FAILED")) return ok def read(plate_dir, block=3000): """Can a drifting grammar be read at all? Two readers, one instrument. GLOBAL assumes a single fixed machine wrote everything — that is the v3/v4 reader. BLOCKWISE assumes the machine is only locally constant, infers one per block, and pays for each block's model separately (32 log2(16!) bits, amortised over the block). If the blockwise number is far below the global one, the disc is not carrying a LANGUAGE that can be recovered whole; it is carrying a LINEAGE that can only be read in passages. """ seq = json.load(open(os.path.join(plate_dir, "junctions.json"))) out = {"n": len(seq), "block": block, "iid": round(G.bits_iid(seq), 4), "markov1": round(G.bits_markov(seq, 1), 4), "markov2": round(G.bits_markov(seq, 2), 4), "markov3": round(G.bits_markov(seq, 3), 4)} f = G.infer_machine(seq[:4000]) b, _d = G.bits_machine(seq, f["p0"], f["p1"]) out["global_inference"] = round(b, 4) tot, n = 0.0, 0 for c0 in range(0, len(seq) - block, block): blk = seq[c0:c0 + block] fb = G.infer_machine(blk, inits=4, em_iters=40) bb, _ = G.bits_machine(blk, fb["p0"], fb["p1"]) tot += bb * len(blk) n += len(blk) out["blockwise_inference"] = round(tot / max(1, n), 4) out["gain_from_admitting_drift"] = round( out["global_inference"] - out["blockwise_inference"], 4) print(json.dumps(out, ensure_ascii=False, indent=1)) return out def lineage(plate_dir): """What the disc says about its own language, over time.""" hist = json.load(open(os.path.join(plate_dir, "grammar_history.json"))) tr = json.load(open(os.path.join(plate_dir, "refutation_trace.json"))) out = {"checkpoints": len(hist)} if hist: out["distance_from_seed"] = [h["distance_from_seed"] for h in hist] out["first_cycles"] = hist[0]["cycles"] out["last_cycles"] = hist[-1]["cycles"] # how fast does the language turn over: entries changed per 1000 steps d = [h["distance_from_seed"] for h in hist] out["saturating_distance"] = d[-1] if tr: out["refutation_rate_first"] = tr[0]["refutation_rate"] out["refutation_rate_last"] = tr[-1]["refutation_rate"] print(json.dumps(out, ensure_ascii=False, indent=1)) return out def main(): ap = argparse.ArgumentParser() ap.add_argument("--self-test", action="store_true") ap.add_argument("--lineage", default=None) ap.add_argument("--read", default=None) ap.add_argument("--block", type=int, default=3000) ap.add_argument("--hours", type=float, default=8.0) ap.add_argument("--n", type=int, default=3) ap.add_argument("--seed", type=int, default=0) ap.add_argument("--slow", type=int, default=0, help="rewire only after this many refutations in a row; " "use the rule's own N so no new number enters") ap.add_argument("--frozen", action="store_true", help="control: no mutation, so the seed grammar is kept") ap.add_argument("--out", default=None) args = ap.parse_args() if args.self_test: raise SystemExit(0 if self_test() else 1) if args.lineage: lineage(os.path.abspath(args.lineage)) return if args.read: read(os.path.abspath(args.read), args.block) return out = args.out or ("../plate/v5/%s%d" % ("frozen" if args.frozen else "s", args.seed)) run(hours=args.hours, n_impulse=args.n, seed=args.seed, mutate=not args.frozen, out_dir=out, slow=args.slow) if __name__ == "__main__": main()