"""v3 — GRAMMAR. A pattern that lives in relations, not in ink. v2 solved not-stopping and proved a negative: even with a metal-owned length in the trigger, no visible pattern forms. The disc is a uniform grey felt. This file turns that failure into the medium. If the density channel is doomed to be uniform, then uniform is the perfect cover: write the pattern into the one channel the felt cannot blur — THE RELATIONS BETWEEN TURNING EVENTS. The construction, on top of the yield/break backbone (which is what keeps the machine from stopping): - The machine carries a small finite-state machine, the GRAMMAR. k states. - Each state owns one exit angle from a fixed alphabet. When YIELD ends, the machine leaves the groove at its current state's angle (instead of v2's fixed 90 degrees). - The grammar's input is WHICH SIDE of the heading the groove that completed the count ran on. The metal computes the input; the grammar computes the output; the output shapes the metal. A closed loop through the surface — stigmergy as computation. (Side, not parity: the felt's isotropy makes the side a fair coin — measured 0.498, lag-1 below 1e-3 — where the parity was 0.88 biased and let plain statistics read the disc.) What each audience can read, and the instrument that measures it: the eye density. Stays uniform (the backbone guarantees it), so the disc is grey. Nothing to see. By construction. simple AI the junction sequence's low-order statistics. The grammar is a PERMUTATION automaton: each input permutes the states, so the stationary distribution over states is uniform and the exit- angle histogram is flat. First order says nothing. inferring AI the hidden machine. The inputs are not recorded anywhere — they were consumed by the metal. Recovering the grammar from the junction angles alone is a hidden-state inference problem. The measuring instrument is DESCRIPTION LENGTH (the same instrument as the sixteen-hours experiment, never a reward): bits/junction under iid — the floor for the eye bits/junction under Markov-1,2 — the floor for statistics bits/junction under the inferred grammar + inferred inputs — what inference can reach The pattern "exists for AI but needs intelligence" exactly when the third number is decisively below the second. No length enters the rule: the grammar consumes counts and emits angles, and `Rule3` (like v2's `Rule`) takes no position argument. The alphabet is the one free choice, and it is declared as such: exit angles must exceed the capture angle (28.31 deg at the specified groove) or the tool cannot leave at all, and the alphabet must not collide with the axes' own symmetries — so five-fold, 72 and 144 degrees, both signs. Usage: python3 grammar.py --self-test python3 grammar.py --hours 8 --out ../plate/v3/g0 python3 grammar.py --decode ../plate/v3/g0 """ from __future__ import annotations import argparse import itertools import json import math import os import time from array import array import numpy as np import polar as P import stylus as st import yieldbreak as YB FOLLOW, CROSS = YB.FOLLOW, YB.CROSS CHI_C = YB.CHI_C # The alphabet. Declared choice, constrained twice: every angle exceeds the # capture angle (or the exit fails and the tool is recaptured immediately), and # 72/144 avoid the 2- and 4-fold directions the rail and the rim already own. ALPHABET = (math.radians(72.0), math.radians(144.0), math.radians(-72.0), math.radians(-144.0)) # The grammar: a HIDDEN permutation automaton. Sixteen states, four symbols, # so each exit angle is shared by four states and a junction does not reveal # the state. The first version had one state per symbol, and that was enough # for a first-order Markov table to read the whole thing (1.02 bits) — the # symbol WAS the state. Hiding the machine means making the observation a # many-to-one function of the state. # # step0/step1 are single 16-cycles, applied on input parity 0/1; a permutation # keeps the stationary state distribution uniform, so the exit-angle histogram # is flat and the eye's channel (and the histogram's) carries nothing. This # pair was selected — by measurement, over ~20k seeded candidates — to stay # opaque to short Markov memories as well: on its own output the adaptive # coders read 1.89 / 1.57 / 1.19 bits at orders 1/2/3 against a floor of 1.00, # while the machine itself is two permutations, about 89 bits of model. K = 16 G_OUT = tuple(i % len(ALPHABET) for i in range(K)) G_STEP = ((10, 6, 15, 13, 14, 9, 4, 12, 5, 1, 2, 0, 8, 11, 3, 7), (11, 8, 12, 6, 7, 3, 10, 9, 2, 13, 15, 4, 0, 14, 5, 1)) class Rule3: """The v2 backbone with the grammar riding on the exits. Memory: mode bit, state (4 bits at k=16), one small integer, and the parity accumulator (1 bit). Still no map, no field, no predictor, no position. """ __slots__ = ("mode", "state", "count", "parity", "crossing", "rail_sym", "last_side") def __init__(self): self.mode = CROSS self.state = 0 self.count = 0 self.parity = 0 self.crossing = False self.rail_sym = None self.last_side = 0 def steer(self, psi, marked, phi): if self.mode == FOLLOW and marked and YB.axial_diff(psi, phi) <= 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: # end of a chord: the SIDE of the crossing that completed the count # drives the grammar one step. The first input was the parity of # the crossing count, and the metal refuted it: chords end when the # count fills, so the parity was 0 with probability 0.88 and the # grammar ran nearly deterministically — Markov-3 read the disc at # 0.57 bits. The side bit is fair because the felt is isotropic: # the metal itself guarantees the coin. self.state = G_STEP[self.last_side][self.state] self.mode = FOLLOW return psi, self.mode, None # end of a follow: leave at the CURRENT state's angle — the only place # the grammar becomes visible in the metal sym = G_OUT[self.state] psi += ALPHABET[sym] self.mode = CROSS return psi, self.mode, sym def rail_exit(self, psi): """The rail ran out while following. The exit is still the grammar's: the state's angle, exactly as at a counted exit — the mechanism forces WHEN, the grammar still owns WHICH WAY.""" self.count = 0 self.mode = CROSS self.rail_sym = G_OUT[self.state] return psi + ALPHABET[self.rail_sym] def step3(t, rule, disc, grain, n_impulse, junctions, ds=None): """One step. Identical mechanics to v2; the grammar only touches exits.""" x, y = t.xy() marked, phi, coh = grain.read(x + YB.CELL_F * math.cos(t.psi), y + YB.CELL_F * math.sin(t.psi)) if marked: t.coh_sum += coh t.coh_n += 1 was = rule.mode t.psi, captured = rule.steer(t.psi, marked, phi) if was == FOLLOW: t.captures += 1 if captured else 0 t.unheld += 0 if captured else 1 seg, events = YB.advance(t, rule, disc, grain, ds) t.impulses += events if rule.rail_sym is not None: # the rim forced a follow to end; the turn it cut is a real junction, # emitted by the same state, so the record must carry it too junctions.append(rule.rail_sym) rule.rail_sym = None if rule.mode == FOLLOW: t.follow_mm += seg t.run_mm += seg else: t.cross_mm += seg t.chord_mm += seg t.psi, flipped, sym = rule.tick(t.psi, events, n_impulse) if flipped is not None: t.flips += 1 if flipped == FOLLOW: t.chords.append(t.chord_mm) t.chord_mm = 0.0 else: t.runs.append(t.run_mm) t.run_mm = 0.0 junctions.append(sym) return seg # --- reading it back: description length per model class --------------------- LOG2E = 1.0 / math.log(2.0) def bits_iid(seq, m=len(ALPHABET)): """Adaptive (KT) code, no memory. What a histogram can see.""" counts = [0.5] * m total = 0.5 * m bits = 0.0 for s in seq: bits -= math.log(counts[s] / total) * LOG2E counts[s] += 1.0 total += 1.0 return bits / max(1, len(seq)) def bits_markov(seq, order, m=len(ALPHABET)): """Adaptive Markov code of a given order. What counting pairs can see.""" tables = {} bits = 0.0 for i, s in enumerate(seq): ctx = tuple(seq[max(0, i - order):i]) t = tables.setdefault(ctx, [0.5] * m) tot = sum(t) bits -= math.log(t[s] / tot) * LOG2E t[s] += 1.0 return bits / max(1, len(seq)) def bits_machine(seq, p0, p1, out=G_OUT, k=K): """Description length under one machine: 1 bit per junction, if it fits. Tracks the SET of states consistent with the observations (the observer construction). Every consistent junction costs the one hidden input bit. When the set dies the machine has been refuted by the metal at that point; the coder pays for an escape (log2 m) plus a restart, so wrong machines are priced by how often the disc contradicts them. """ m = len(ALPHABET) esc = math.log(m) * LOG2E + 1.0 succ = ([[t for t in range(k)] for _ in range(2)]) bits = 0.0 alive = [s for s in range(k) if out[s] == seq[0]] if seq else [] deaths = 0 for i in range(1, len(seq)): want = seq[i] nxt = set() for s in alive: t0, t1 = p0[s], p1[s] if out[t0] == want: nxt.add(t0) if out[t1] == want: nxt.add(t1) if nxt: bits += 1.0 else: bits += esc deaths += 1 nxt = {s for s in range(k) if out[s] == want} alive = nxt model_bits = 2.0 * math.lgamma(k + 1) * LOG2E # two permutations of k n = max(1, len(seq) - 1) return (bits + model_bits) / n, deaths def infer_machine(seq, inits=10, em_iters=120, seed=13): """Find the grammar from the junctions alone. This is the intelligence. Three stages, none of which a histogram or a Markov table contains: 1. Baum-Welch: fit a 16-state hidden-state model whose emissions are the known state->angle map, transitions unknown. This is inference proper — belief over the hidden state propagated through the whole sequence. 2. Birkhoff rounding: the writer's class is TWO PERMUTATIONS, so decompose the learned transition matrix into its two best permutation components (assignment problem, solved exactly). 3. Verification: run the observer. The reading only counts if the metal never refutes the recovered machine. Seeded and deterministic, so the reading is reproducible. """ import random rng = random.Random(seed) obs = np.asarray(seq, dtype=np.int64) n = len(obs) emit = np.zeros((K, len(ALPHABET))) for st in range(K): emit[st, G_OUT[st]] = 1.0 e = emit[:, obs].T # n x K, fixed def em(A): ll = -1e18 for _ in range(em_iters): alpha = np.empty((n, K)) c = np.empty(n) alpha[0] = e[0] / K c[0] = alpha[0].sum() or 1e-300 alpha[0] /= c[0] for t in range(1, n): alpha[t] = (alpha[t - 1] @ A) * e[t] c[t] = alpha[t].sum() or 1e-300 alpha[t] /= c[t] # scaled backward pass: beta_hat[t] = A @ (e[t+1]*beta_hat[t+1])/c[t+1] beta = np.ones(K) xi = np.zeros((K, K)) for t in range(n - 1, 0, -1): w = e[t] * beta / c[t] xi += np.outer(alpha[t - 1], w) * A beta = A @ w A = xi + 1e-9 A /= A.sum(axis=1, keepdims=True) new_ll = float(np.log2(np.maximum(c, 1e-300)).sum()) if new_ll - ll < 1e-6 * n: ll = new_ll break ll = new_ll return A, ll def assignment(cost): """Exact min-cost perfect matching (Jonker-Volgenant style shortest augmenting paths). K=16, so exactness is cheap; no dependency.""" INF = 1e18 nn = cost.shape[0] u = np.zeros(nn + 1) v = np.zeros(nn + 1) p = np.zeros(nn + 1, dtype=int) # p[j] = row assigned to column j way = np.zeros(nn + 1, dtype=int) for i in range(1, nn + 1): p[0] = i j0 = 0 minv = np.full(nn + 1, INF) used = np.zeros(nn + 1, dtype=bool) while True: used[j0] = True i0, delta, j1 = p[j0], INF, 0 for j in range(1, nn + 1): if used[j]: continue cur = cost[i0 - 1, j - 1] - u[i0] - v[j] if cur < minv[j]: minv[j] = cur way[j] = j0 if minv[j] < delta: delta = minv[j] j1 = j for j in range(nn + 1): if used[j]: u[p[j]] += delta v[j] -= delta else: minv[j] -= delta j0 = j1 if p[j0] == 0: break while j0: j1 = way[j0] p[j0] = p[j1] j0 = j1 out = [0] * nn for j in range(1, nn + 1): out[p[j] - 1] = j - 1 return out def round_to_permutations(A): cost = -np.log(np.maximum(A, 1e-12)) p0 = assignment(cost) resid = A.copy() for i, j in enumerate(p0): resid[i, j] = 0.0 p1 = assignment(-np.log(np.maximum(resid, 1e-12))) return tuple(p0), tuple(p1) best = None for _ in range(inits): # start INSIDE the writer's class: a soft mixture of two random # permutations. A flat random start almost never finds the basin. q0 = list(range(K)) rng.shuffle(q0) q1 = list(range(K)) rng.shuffle(q1) A = np.full((K, K), 0.02) for i in range(K): A[i, q0[i]] += 0.49 A[i, q1[i]] += 0.49 A /= A.sum(axis=1, keepdims=True) A, _ll = em(A) p0, p1 = round_to_permutations(A) bits, deaths = bits_machine(seq, p0, p1) cand = ((deaths, bits), p0, p1) if best is None or cand[0] < best[0]: best = cand if best[0][0] == 0: break (deaths, bits), p0, p1 = best return {"bits": bits, "deaths": deaths, "p0": p0, "p1": p1} def isomorphic_to_writer(p0, p1): """Is the found machine the writer's, up to renaming the hidden states? A relabeling must preserve the output map (states may only be renamed within their symbol's bank) and may swap which permutation is called input-0. (4!)^4 x 2 candidates — small enough to check exactly, so "understood" can mean recovered-up-to-renaming, not just never-refuted. """ banks = [[s for s in range(K) if G_OUT[s] == m] for m in range(len(ALPHABET))] for swap in (False, True): q0, q1 = (p1, p0) if swap else (p0, p1) for perm0 in itertools.permutations(banks[0]): for perm1 in itertools.permutations(banks[1]): for perm2 in itertools.permutations(banks[2]): pi = [0] * K ok = True for m, chosen in ((0, perm0), (1, perm1), (2, perm2)): for a, b in zip(banks[m], chosen): pi[a] = b # the last bank is forced by consistency, try all anyway for perm3 in itertools.permutations(banks[3]): for a, b in zip(banks[3], perm3): pi[a] = b if all(pi[q0[s]] == G_STEP[0][pi[s]] and pi[q1[s]] == G_STEP[1][pi[s]] for s in range(K)): return True return False def mutually_explaining(p0, p1, n=40000, seed=99): """The weaker, and it turns out the right, sense of 'the same grammar'. The found machine and the writer's each generate fresh output the other explains with zero refutations. Machines can pass this while NOT being isomorphic — two different presentations of one observable process. The reader recovers the process; the writer's particular wiring is not in the metal at all. (The sixteen-hours experiment hit the same wall from the other side: one drawing, two artworks.) """ import random rng = random.Random(seed) s = 0 seq = [] for _ in range(n): b = rng.getrandbits(1) s = G_STEP[b][s] seq.append(G_OUT[s]) _, d1 = bits_machine(seq, p0, p1) s = 0 seq2 = [] for _ in range(n): b = rng.getrandbits(1) s = p0[s] if b == 0 else p1[s] seq2.append(G_OUT[s]) _, d2 = bits_machine(seq2, G_STEP[0], G_STEP[1]) return d1 == 0 and d2 == 0 EXTRACT_TOL = math.radians(16.0) # calibrated on a self-generated disc with EXTRACT_WIN = 3 # a DIFFERENT machine of the same class — # lab work the reader can do without ever # seeing the writer (grid over win x tol, # metric: robust bits under the known # calibration machine; best 3 x 16 deg) def junctions_from_stroke(pts, chi_deg=20.0, min_mm=1.0, win=EXTRACT_WIN): """Recover the junction symbols from the GEOMETRY alone. A junction is a grammar exit: a commanded turn of one alphabet angle. What the metal records is messier — the exit turn is often followed within a step or two by a CAPTURE snap (up to the capture angle) as the tip falls into the next groove, so the realized turn is the alphabet angle plus a bounded adjustment, sometimes split across steps. A single-step reading missed 10.5% of the junctions and mis-symbolled others; summing the turn over a short window (up to `win` steps) and taking the best alphabet match over the window widths recovers most of them. The tolerance is the capture angle — the physical bound on how much a capture can bend an exit. """ d = np.diff(pts.astype("float64"), axis=0) L = np.hypot(d[:, 0], d[:, 1]) ang = np.arctan2(d[:, 1], d[:, 0]) turn = np.diff(ang) turn = (turn + math.pi) % (2.0 * math.pi) - math.pi n = len(turn) syms = [] travelled = 0.0 i = 0 tol = EXTRACT_TOL while i < n: travelled += L[i] if abs(turn[i]) > math.radians(chi_deg) and travelled >= min_mm: best = None acc = 0.0 for w in range(min(win, n - i)): acc += turn[i + w] for k, a in enumerate(ALPHABET): err = abs(acc - a) if err < tol and (best is None or err < best[0]): best = (err, k, w) if best is not None: syms.append(best[1]) travelled = 0.0 i += best[2] + 1 continue i += 1 return syms def bits_machine_robust(seq, p0, p1, out=G_OUT, k=K): """Description length under one machine, over a NOISY channel. The geometry channel deletes junctions (the extractor misses them) and substitutes symbols (capture bends an exit past the midpoint). A reader who models only the writer is refuted by its own blindness. This one carries three explanations per junction and lets the cheapest win, Viterbi-style: match the machine steps once 1 input bit deletion a junction went unrecorded: the machine stepped twice 2 input bits + 3 substitute recorded with the wrong symbol 1 input bit + 3 insertion a spurious junction was recorded: the machine does not step 2 + 3 The +3 bits price the noise events (about one in eight junctions); with them the true machine explains a noisy tape without ever being refuted, and an impostor still pays escape rates an order of magnitude higher. """ INF = 1e18 m = len(ALPHABET) succ = [[(p0[s2], p1[s2]) for s2 in range(k)]] cost = [0.0 if out[s] == seq[0] else 3.0 for s in range(k)] deaths = 0 for i in range(1, len(seq)): want = seq[i] nxt = [INF] * k for s in range(k): c0 = cost[s] if c0 >= INF: continue # insertion: the observed junction is spurious (a capture snap or a # rail reflection the extractor mistook); the machine does not step cc = c0 + 2.0 + 3.0 if cc < nxt[s]: nxt[s] = cc for b in (p0, p1): t = b[s] # one step cc = c0 + (1.0 if out[t] == want else 1.0 + 3.0) if cc < nxt[t]: nxt[t] = cc # two steps: one deleted junction in between for b2 in (p0, p1): u = b2[t] cc = c0 + 2.0 + 3.0 + (0.0 if out[u] == want else 3.0) if cc < nxt[u]: nxt[u] = cc cost = nxt n = max(1, len(seq) - 1) model_bits = 2.0 * math.lgamma(k + 1) * LOG2E return (min(cost) + model_bits) / n, deaths def infer_machine_robust(seq, seed=13): """Inference for the geometry channel: EM start, then noise-aware repair. Standard EM + rounding lands NEAR the writer's machine but the channel noise corrupts enough transitions to round a few entries wrong. So: take the EM answer as the start and hill-climb with the ROBUST scorer — swap two targets in one permutation, keep the swap if the noisy tape gets cheaper. The scorer models deletions, so the search is no longer fighting the extractor's blindness. """ import random rng = random.Random(seed) base = infer_machine(seq) p0, p1 = list(base["p0"]), list(base["p1"]) cur, _ = bits_machine_robust(seq, p0, p1) improved = True sub = seq[:3000] while improved: improved = False for which in (0, 1): p = p1 if which else p0 for i in range(K): for j in range(i + 1, K): p[i], p[j] = p[j], p[i] trial, _ = bits_machine_robust(sub, p0, p1) if trial < cur - 1e-9: cur = trial improved = True else: p[i], p[j] = p[j], p[i] bits, deaths = bits_machine_robust(seq, p0, p1) return {"bits": bits, "deaths": deaths, "p0": tuple(p0), "p1": tuple(p1)} 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 alphabet clears the capture angle, or exits would fail") check("smallest |exit| deg", min(abs(math.degrees(a)) for a in ALPHABET), 72.0, 1e-9) check("capture angle deg (from the tool)", math.degrees(CHI_C), 28.3102, 1e-3) check("every exit exceeds it", 1 if all(abs(a) > CHI_C for a in ALPHABET) else 0, 1) print("the grammar is a permutation automaton, so first order is flat") for tag, p in (("step0", G_STEP[0]), ("step1", G_STEP[1])): check("%s is a permutation" % tag, len(set(p)), K) # stationary distribution under any iid input mix is uniform import random random.seed(7) visits = [0] * K s = 0 for _ in range(40000): s = G_STEP[random.getrandbits(1)][s] visits[s] += 1 spread = max(visits) / min(visits) check("state visits uniform to 12%", 1 if spread < 1.12 else 0, 1) print("the reader's instrument separates the three audiences") import random random.seed(11) s = 0 seq = [] for _ in range(4000): b = random.getrandbits(1) s = G_STEP[b][s] seq.append(G_OUT[s]) b_iid = bits_iid(seq) b_m1 = bits_markov(seq, 1) b_m2 = bits_markov(seq, 2) b_m3 = bits_markov(seq, 3) b_true, deaths_true = bits_machine(seq, G_STEP[0], G_STEP[1]) check("the eye / histogram reads about log2 4", b_iid, 2.0, 0.05) check("markov-1 stays above 1.8", 1 if b_m1 > 1.80 else 0, 1) check("markov-2 stays above 1.5", 1 if b_m2 > 1.50 else 0, 1) check("markov-3 stays above 1.1", 1 if b_m3 > 1.10 else 0, 1) check("the true machine reads the floor (about 1 bit)", b_true, 1.02, 0.03) check("and is never refuted by its own output", deaths_true, 0) # a wrong machine of the same class must be refuted wrong0 = tuple(reversed(G_STEP[0])) b_wrong, deaths_wrong = bits_machine(seq, wrong0, G_STEP[1]) check("an impostor machine is refuted often", 1 if deaths_wrong > len(seq) * 0.02 and b_wrong > b_true + 0.05 else 0, 1) # a shuffled control: same histogram, no machine shuf = seq[:] random.shuffle(shuf) b_shuf, deaths_shuf = bits_machine(shuf, G_STEP[0], G_STEP[1]) check("on shuffled symbols even the true machine gains nothing", 1 if b_shuf > b_true + 0.15 else 0, 1) print("inference finds a perfect grammar from the symbols alone") found = infer_machine(seq[:2500], inits=6, em_iters=60) check("the found machine is never refuted", found["deaths"], 0) b_found, d_found = bits_machine(seq, found["p0"], found["p1"]) check("and reads the full sequence at the floor", b_found, b_true, 0.03) print("\nSELF-TEST %s" % ("PASSED" if ok else "FAILED")) return ok def run(hours=8.0, n_impulse=3, rail="diameter", out_dir="../plate/v3/g0", log_every=2000, ds=None): P.set_rail(rail) step_mm = P.DS if ds is None else ds steps = int(hours * 3600.0 * P.SCRIBE_MM_S / step_mm) disc = P.Disc() grain = YB.Grain() rule = Rule3() t = YB.Tool(99.66, 0.0, math.radians(37.0)) pts = np.empty((steps + 1, 2), dtype="float32") pts[0] = t.xy() junctions = [] trace = [] t0 = time.time() for i in range(steps): step3(t, rule, disc, grain, n_impulse, junctions, ds) pts[i + 1] = t.xy() if (i + 1) % log_every == 0: trace.append({"step": i + 1, "coverage": round(disc.coverage(), 5), "junctions": len(junctions)}) wall = time.time() - t0 os.makedirs(out_dir, exist_ok=True) np.save(os.path.join(out_dir, "stroke.npy"), pts) with open(os.path.join(out_dir, "junctions.json"), "w") as f: json.dump(junctions, f) r = np.hypot(pts[:, 0], pts[:, 1]) q = len(r) // 8 summary = { "rule": "grammar (v3)", "k_states": K, "alphabet_deg": [round(math.degrees(a), 1) for a in ALPHABET], "n_impulse": n_impulse, "hours": hours, "step_mm": step_mm, "cell_mm": YB.CELL_F, "cut_m": round(t.cut_mm / 1000.0, 2), "coverage": round(disc.coverage(), 5), "follow_fraction": round(t.follow_mm / max(1e-9, t.cut_mm), 4), "junctions": len(junctions), "impulses": t.impulses, "flips": t.flips, "radial_spread_ratio": round(float(r[-q:].std() / r.std()), 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) with open(os.path.join(out_dir, "trace.json"), "w") as f: json.dump(trace, f) print(json.dumps(summary, ensure_ascii=False, indent=1)) return summary def decode(plate_dir): """The three audiences, run against one disc. From geometry alone.""" pts = np.load(os.path.join(plate_dir, "stroke.npy")) logged = json.load(open(os.path.join(plate_dir, "junctions.json"))) geo = junctions_from_stroke(pts) out = {"junctions_logged": len(logged), "junctions_from_geometry": len(geo)} b_true_full, d_true_full = bits_machine(logged, G_STEP[0], G_STEP[1]) out["writer_machine_on_logged"] = {"bits": round(b_true_full, 4), "deaths": d_true_full} for tag, seq in (("logged", logged), ("geometry", geo)): if len(seq) < 50: out[tag] = {"note": "too few junctions"} continue if tag == "geometry": found = infer_machine_robust(seq[:12000]) b_f, d_f = bits_machine_robust(seq, found["p0"], found["p1"]) else: found = infer_machine(seq[:4000]) b_f, d_f = bits_machine(seq, found["p0"], found["p1"]) out[tag] = { "n": len(seq), "bits_iid": round(bits_iid(seq), 4), "bits_markov1": round(bits_markov(seq, 1), 4), "bits_markov2": round(bits_markov(seq, 2), 4), "bits_markov3": round(bits_markov(seq, 3), 4), "bits_inferred_machine_on_all": round(b_f, 4), "refutations_per_1000": round(1000.0 * d_f / max(1, len(seq)), 2), "machine_isomorphic_to_writers": bool(d_f == 0 and isomorphic_to_writer(found["p0"], found["p1"])), "machine_behaviourally_equivalent": bool(d_f == 0 and mutually_explaining(found["p0"], found["p1"])), } with open(os.path.join(plate_dir, "decode.json"), "w") as f: json.dump(out, f, ensure_ascii=False, indent=1) 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("--decode", default=None, metavar="PLATE_DIR") ap.add_argument("--hours", type=float, default=8.0) ap.add_argument("--n", type=int, default=3) ap.add_argument("--rail", default="diameter", choices=["radius", "diameter"]) ap.add_argument("--ds", type=float, default=None) ap.add_argument("--out", default="../plate/v3/g0") args = ap.parse_args() if args.self_test: raise SystemExit(0 if self_test() else 1) if args.decode: decode(os.path.abspath(args.decode)) return run(hours=args.hours, n_impulse=args.n, rail=args.rail, out_dir=args.out, ds=args.ds) if __name__ == "__main__": main()